diff --git a/.travis.yml b/.travis.yml index 57a895e..f497494 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,19 +20,19 @@ jobs: - language: android dist: trusty jdk: - - openjdk8 + - openjdk11 android: components: - platform-tools - build-tools-29.0.3 - - android-30 + - android-31 - extra - before_install: - - yes | sdkmanager "platform-tools" - - yes | sdkmanager "build-tools;29.0.3" - - yes | sdkmanager "platforms;android-30" +# before_install: + # - yes | sdkmanager "platform-tools" +# - yes | sdkmanager "build-tools;29.0.3" + - yes | sdkmanager "platforms;android-31" script: - - ./gradlew build check jacocoTestReport + - ./gradlew :app:assembleDebug :UseCaseExecutor:check jacocoTestReport env: - CODECOV_TOKEN=d9615b91-c123-4003-ac18-2df188e88470 after_success: diff --git a/UseCaseExecutor/build.gradle b/UseCaseExecutor/build.gradle index da42f0d..6a7ba70 100644 --- a/UseCaseExecutor/build.gradle +++ b/UseCaseExecutor/build.gradle @@ -15,11 +15,12 @@ jacoco { tasks.withType(Test) { jacoco.includeNoLocationClasses = true + jacoco.excludes = ['jdk.internal.*'] } android { - compileSdkVersion 30 + compileSdkVersion 31 buildToolsVersion "29.0.3" defaultConfig { @@ -52,12 +53,13 @@ android { } dependencies { - implementation fileTree(dir: "libs", include: ["*.jar"]) - implementation 'com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:3.1' - implementation 'androidx.appcompat:appcompat:1.2.0' +// implementation fileTree(dir: "libs", include: ["*.aar"]) + implementation project(path: ':utils') + implementation 'com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:4.0.0' + implementation 'androidx.appcompat:appcompat:1.3.1' testImplementation 'junit:junit:4.12' testImplementation "org.mockito:mockito-core:4.6.1" - implementation 'com.google.guava:guava:24.1-jre' + implementation 'com.google.guava:guava:31.0-jre' implementation (group: 'com.googlecode.json-simple', name: 'json-simple', version: '1.1.1') { exclude group: 'junit', module: 'junit' } diff --git a/UseCaseExecutor/src/main/java/dev/navids/latte/MessageReceiver.java b/UseCaseExecutor/src/main/java/dev/navids/latte/MessageReceiver.java index 3672e92..9169fc3 100644 --- a/UseCaseExecutor/src/main/java/dev/navids/latte/MessageReceiver.java +++ b/UseCaseExecutor/src/main/java/dev/navids/latte/MessageReceiver.java @@ -7,6 +7,14 @@ import android.util.Xml; import android.view.accessibility.AccessibilityNodeInfo; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; + +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.accessibility.utils.Filter; +import com.google.android.accessibility.utils.traversal.OrderedTraversalController; +import com.google.android.accessibility.utils.traversal.OrderedTraversalStrategy; +import com.google.android.accessibility.utils.traversal.TraversalStrategy; +import com.google.android.accessibility.utils.traversal.TraversalStrategyUtils; import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckPreset; import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult; import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils; @@ -57,6 +65,27 @@ public MessageReceiver() { // ----------- General ---------------- messageEventMap.put("is_live", (extra) -> Utils.createFile(String.format(Config.v().IS_LIVE_FILE_PATH_PATTERN, extra), "I'm alive " + extra)); messageEventMap.put("log", (extra) -> Utils.getAllA11yNodeInfo(true)); + messageEventMap.put("dummy", (extra) -> { + Log.i(LatteService.TAG, "I'm in dummy message!"); + // Logs the ordered list of focusable nodes in TalkBack + AccessibilityNodeInfo nodeInfo = LatteService.getInstance().getRootInActiveWindow(); + if (nodeInfo != null) { + AccessibilityNodeInfoCompat nodeInfoCompat = AccessibilityNodeInfoCompat.wrap(nodeInfo); + OrderedTraversalStrategy orderedTraversalStrategy = new OrderedTraversalStrategy(nodeInfoCompat); + Filter focusNodeFilter = + AccessibilityNodeInfoUtils.FILTER_SHOULD_FOCUS; + AccessibilityNodeInfoCompat firstNode = TraversalStrategyUtils.findInitialFocusInNodeTree(orderedTraversalStrategy,nodeInfoCompat, TraversalStrategy.SEARCH_FOCUS_FORWARD, focusNodeFilter); + Log.i(LatteService.TAG, "First Node: " + firstNode); + AccessibilityNodeInfoCompat iterNode = TraversalStrategyUtils.searchFocus(orderedTraversalStrategy, firstNode, TraversalStrategy.SEARCH_FOCUS_FORWARD, focusNodeFilter); + Log.i(LatteService.TAG, "Iteration:"); + while(iterNode != null){ + Log.i(LatteService.TAG, "\t" + iterNode); + iterNode = TraversalStrategyUtils.searchFocus(orderedTraversalStrategy, iterNode, TraversalStrategy.SEARCH_FOCUS_FORWARD, focusNodeFilter); + } + Log.i(LatteService.TAG, "After Iteration"); + } + + }); messageEventMap.put("invisible_nodes", (extra) -> LatteService.considerInvisibleNodes = (extra.equals("true"))); messageEventMap.put("report_a11y_issues", (extra) -> { Context context2 = LatteService.getInstance().getApplicationContext(); diff --git a/UseCaseExecutor/src/main/java/dev/navids/latte/Utils.java b/UseCaseExecutor/src/main/java/dev/navids/latte/Utils.java index ca39756..f0e3489 100644 --- a/UseCaseExecutor/src/main/java/dev/navids/latte/Utils.java +++ b/UseCaseExecutor/src/main/java/dev/navids/latte/Utils.java @@ -174,6 +174,8 @@ private static Rect getVisibleBoundsInScreen(AccessibilityNodeInfo node, int wi private static void dumpNodeRec(AccessibilityNodeInfo node, XmlSerializer serializer, int index, int width, int height) throws IOException { + if (node == null) + return; boolean supportsWebAction = node.getActionList().contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_NEXT_HTML_ELEMENT) || node.getActionList().contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_PREVIOUS_HTML_ELEMENT); diff --git a/app/build.gradle b/app/build.gradle index b0c59e0..f6a36f4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,7 +1,7 @@ apply plugin: 'com.android.application' android { - compileSdkVersion 30 + compileSdkVersion 31 buildToolsVersion "29.0.3" defaultConfig { @@ -28,8 +28,10 @@ android { dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) - implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'androidx.appcompat:appcompat:1.3.1' implementation project(path: ':UseCaseExecutor') + implementation 'com.google.android.material:material:1.6.1' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c5edd63..d5b13da 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,15 +9,28 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> + + + + + + + + + android:exported="true" + android:label="@string/accessibility_service_label" + android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"> + diff --git a/app/src/main/java/dev/navids/latte/app/MainActivity.java b/app/src/main/java/dev/navids/latte/app/MainActivity.java new file mode 100644 index 0000000..9fd0bf7 --- /dev/null +++ b/app/src/main/java/dev/navids/latte/app/MainActivity.java @@ -0,0 +1,14 @@ +package dev.navids.latte.app; + +import androidx.appcompat.app.AppCompatActivity; + +import android.os.Bundle; + +public class MainActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..0b15a20 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 79f56f4..f1e0f59 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-all.zip diff --git a/py_src/A11yPuppetry/replayer.py b/py_src/A11yPuppetry/replayer.py index 891c20e..21363bb 100644 --- a/py_src/A11yPuppetry/replayer.py +++ b/py_src/A11yPuppetry/replayer.py @@ -34,6 +34,9 @@ 'com.dictionary': '/Users/navid/StudioProjects/Latte/BM_APKs/ase_apks/com.dictionary.apk', 'com.yelp.android': '/Users/navid/StudioProjects/Latte/BM_APKs/ase_apks/com.yelp.android.apk', 'com.offerup': '/Users/navid/StudioProjects/Latte/BM_APKs/ase_apks/com.offerup.apk', + 'com.espn.score_center': '/Users/navid/StudioProjects/Latte/BM_APKs/ase_apks/com.espn.score_center.apk', + 'com.expedia.bookings': '/Users/navid/StudioProjects/Latte/BM_APKs/ase_apks/com.expedia.bookings.apk', + 'com.dd.doordash': '/Users/navid/StudioProjects/Latte/Setup/com.dd.doordash.apk', 'com.different.toonme': '/Users/navid/StudioProjects/Latte/BM_APKs/topplay_apks/com.different.toonme.apk', # Didn't work 'com.zhiliaoapp.musically': '/Users/navid/StudioProjects/Latte/BM_APKs/topplay_apks/com.zhiliaoapp.musically.apk', 'com.squareup.cash': '/Users/navid/StudioProjects/Latte/BM_APKs/topplay_apks/com.squareup.cash.apk', diff --git a/py_src/genymotion_utils.py b/py_src/genymotion_utils.py index 9d55da9..b2ac625 100644 --- a/py_src/genymotion_utils.py +++ b/py_src/genymotion_utils.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) -BASE_RECIPE_NAME = 'AP-Base-2' +BASE_RECIPE_NAME = 'AP-Base-3' async def send_gmsaas_command(command: str) -> Optional[dict]: @@ -159,7 +159,7 @@ async def setup_ap_instance(instance_name: str, app_paths : List[str] = None) -> if app_paths is None: app_paths = [] instance = await create_instance(instance_name=instance_name) - if not instance.is_online(): + if instance is None or not instance.is_online(): logger.error(f"Instance {instance_name} could not be created") return False if not await instance.connect_adb(): diff --git a/py_src/task/create_a11y_puppetry_video.py b/py_src/task/create_a11y_puppetry_video.py index 9ec0c8b..72a85af 100644 --- a/py_src/task/create_a11y_puppetry_video.py +++ b/py_src/task/create_a11y_puppetry_video.py @@ -24,7 +24,7 @@ async def execute(self): create_gif(source_images=images, target_gif=record_manager.recorder_path.joinpath("video.gif"), image_to_nodes=image_to_nodes, - outline=(220, 20, 60), + outline= (220, 20, 60), duration=500) diff --git a/settings.gradle b/settings.gradle index 9c0082d..8dd5a58 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,4 @@ include ':UseCaseExecutor' include ':app' -rootProject.name = "Latte" \ No newline at end of file +rootProject.name = "Latte" +include ':utils' diff --git a/utils/.gitignore b/utils/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/utils/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/utils/b_build.gradle b/utils/b_build.gradle new file mode 100644 index 0000000..c597dc6 --- /dev/null +++ b/utils/b_build.gradle @@ -0,0 +1,35 @@ +plugins { + id 'com.android.library' +} + +android { + compileSdkVersion 31 + + defaultConfig { + minSdkVersion 26 + targetSdkVersion 31 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + + implementation 'androidx.appcompat:appcompat:1.5.0' + implementation 'com.google.android.material:material:1.6.1' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' +} \ No newline at end of file diff --git a/utils/build.gradle b/utils/build.gradle new file mode 100644 index 0000000..a4708ad --- /dev/null +++ b/utils/build.gradle @@ -0,0 +1,53 @@ +apply plugin: 'com.android.library' +ext { + talkbackApplicationId = "com.android.talkback" + talkbackMainPermission = "com.android.talkback.permission.TALKBACK" +} + +android { + compileSdkVersion 'android-31' + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + defaultConfig { + vectorDrawables.useSupportLibrary = true + minSdkVersion 26 + } +} + +dependencies { + + // Google common + implementation 'com.google.guava:guava:31.0-jre' + implementation 'com.google.android.gms:play-services-mlkit-text-recognition:17.0.1' + implementation 'com.google.android.material:material:1.4.0' + + // Support library + implementation 'androidx.annotation:annotation:1.2.0' + implementation 'androidx.appcompat:appcompat:1.3.1' + implementation 'androidx.collection:collection:1.1.0' + implementation 'androidx.core:core:1.6.0-alpha03' + implementation 'androidx.fragment:fragment:1.4.0-alpha08' + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' + implementation 'androidx.preference:preference:1.1.1' + implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation 'androidx.viewpager2:viewpager2:1.0.0' + + // Nullable +// implementation 'org.checkerframework:checker-qual:2.8.1' + + // Auto-value + implementation 'com.google.auto.value:auto-value-annotations:1.8.2' + annotationProcessor 'com.google.auto.value:auto-value:1.8.2' + implementation 'javax.annotation:javax.annotation-api:1.3.2' + +// implementation 'androidx.wear:wear:1.2.0-rc01' +// implementation 'com.google.android.support:wearable:2.8.1' +} + +android { + defaultConfig { + buildConfigField("String", "TALKBACK_APPLICATION_ID", '"' + talkbackApplicationId + '"') + } +} diff --git a/utils/consumer-rules.pro b/utils/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/utils/proguard-rules.pro b/utils/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/utils/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/utils/src/main/AndroidManifest.xml b/utils/src/main/AndroidManifest.xml new file mode 100644 index 0000000..93dae81 --- /dev/null +++ b/utils/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/utils/src/main/java/com/google/android/accessibility/utils/A11yAlertDialogWrapper.java b/utils/src/main/java/com/google/android/accessibility/utils/A11yAlertDialogWrapper.java new file mode 100644 index 0000000..8f1cefb --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/A11yAlertDialogWrapper.java @@ -0,0 +1,744 @@ +/* + * Copyright (C) 2021 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.drawable.Drawable; +import androidx.appcompat.app.AlertDialog; +import android.view.View; +import android.view.Window; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.Button; +import android.widget.ListAdapter; +import androidx.annotation.ArrayRes; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +/** + * Alertdialog wrapper to hold the wrapper of android.app.AlertDialog, MaterialAlertDialog or + * support.v7.app.AlertDialog. This is for the different platforms, including the Wear OS. + */ +public class A11yAlertDialogWrapper implements DialogInterface { + + // Hold the one of dialogWrapper for Alertdialog, such as android.app.AlertDialog, + // MaterialAlertDialog or support.v7.app.AlertDialog, for the platforms including the Wear OS. + private final DialogWrapperInterface dialogWrapper; + + /** + * Creates an A11yAlertDialogWrapper that holds a wrapper of MaterialAlertDialog or + * support.v7.app.AlertDialog. + * + * @param v7AlertDialog the dialog is held by A11yAlertDialogWrapper. + */ + private A11yAlertDialogWrapper(AlertDialog v7AlertDialog) { + dialogWrapper = new V7AlertDialogWrapper(v7AlertDialog); + } + + /** + * Creates an A11yAlertDialogWrapper that holds a wrapper of android.app.AlertDialog. + * + * @param appAlertDialog the dialog is held by A11yAlertDialogWrapper. + */ + private A11yAlertDialogWrapper(android.app.AlertDialog appAlertDialog) { + dialogWrapper = new AppAlertDialogWrapper(appAlertDialog); + } + + /** + * Starts the dialog and display it on screen. The window is placed in the application layer and + * opaque. + */ + public void show() { + dialogWrapper.show(); + } + + /** See {@link AlertDialog#getButton(int)} */ + public Button getButton(int whichButton) { + return dialogWrapper.getButton(whichButton); + } + + /** See {@link AlertDialog#isShowing()} */ + public boolean isShowing() { + return dialogWrapper.isShowing(); + } + + /** See {@link AlertDialog#getWindow()} */ + @Nullable + public Window getWindow() { + return dialogWrapper.getWindow(); + } + + /** See {@link AlertDialog#cancel()} */ + @Override + public void cancel() { + dialogWrapper.cancel(); + } + + /** See {@link AlertDialog#dismiss()} */ + @Override + public void dismiss() { + dialogWrapper.dismiss(); + } + + /** See {@link AlertDialog#setOnDismissListener(OnDismissListener)} */ + public void setOnDismissListener(@Nullable OnDismissListener listener) { + dialogWrapper.setOnDismissListener(listener); + } + + /** See {@link AlertDialog#setCanceledOnTouchOutside(boolean)} */ + public void setCanceledOnTouchOutside(boolean cancel) { + dialogWrapper.setCanceledOnTouchOutside(cancel); + } + + /** + * An interface of alert dialog wrapper to hold one type of AlertDialog that uses the default + * alert dialog theme. + */ + private interface DialogWrapperInterface { + /** See {@link AlertDialog#show()} */ + void show(); + + /** See {@link AlertDialog#getButton(int)} */ + Button getButton(int whichButton); + + /** See {@link AlertDialog#isShowing()} */ + boolean isShowing(); + + /** See {@link AlertDialog#getWindow()} */ + @Nullable + Window getWindow(); + + /** See {@link AlertDialog#cancel()} */ + void cancel(); + + /** See {@link AlertDialog#dismiss()} */ + void dismiss(); + + /** See {@link AlertDialog#setOnDismissListener(OnDismissListener)} */ + void setOnDismissListener(@Nullable OnDismissListener listener); + + /** See {@link AlertDialog#setCanceledOnTouchOutside(boolean)} */ + void setCanceledOnTouchOutside(boolean cancel); + } + + /** Alertdialog wrapper to hold android.app.AlertDialog. This is for the Wear OS. */ + private static class AppAlertDialogWrapper implements DialogWrapperInterface { + // Hold the android.app.AlertDialog which is used in the Wear OS. + private final android.app.AlertDialog appAlertDialog; + + AppAlertDialogWrapper(android.app.AlertDialog appAlertDialog) { + this.appAlertDialog = appAlertDialog; + } + + @Override + public void show() { + appAlertDialog.show(); + } + + @Override + public Button getButton(int whichButton) { + return appAlertDialog.getButton(whichButton); + } + + @Override + public boolean isShowing() { + return appAlertDialog.isShowing(); + } + + @Override + @Nullable + public Window getWindow() { + return appAlertDialog.getWindow(); + } + + @Override + public void cancel() { + appAlertDialog.cancel(); + } + + @Override + public void dismiss() { + appAlertDialog.dismiss(); + } + + @Override + public void setOnDismissListener(@Nullable OnDismissListener listener) { + appAlertDialog.setOnDismissListener(listener); + } + + @Override + public void setCanceledOnTouchOutside(boolean cancel) { + appAlertDialog.setCanceledOnTouchOutside(cancel); + } + } + + /** + * Alertdialog wrapper to hold MaterialAlertDialog or support.v7.app.AlertDialog. This is for the + * different platforms except the Wear OS. + */ + private static class V7AlertDialogWrapper implements DialogWrapperInterface { + // Hold the Alertdialog, such as MaterialAlertDialog or support.v7.app.AlertDialog, for the most + // of the platforms excpet the Wear OS. + private final AlertDialog v7AlertDialog; + + V7AlertDialogWrapper(AlertDialog v7AlertDialog) { + this.v7AlertDialog = v7AlertDialog; + } + + @Override + public void show() { + v7AlertDialog.show(); + } + + @Override + public Button getButton(int whichButton) { + return v7AlertDialog.getButton(whichButton); + } + + @Override + public boolean isShowing() { + return v7AlertDialog.isShowing(); + } + + @Override + @Nullable + public Window getWindow() { + return v7AlertDialog.getWindow(); + } + + @Override + public void cancel() { + v7AlertDialog.cancel(); + } + + @Override + public void dismiss() { + v7AlertDialog.dismiss(); + } + + @Override + public void setOnDismissListener(@Nullable OnDismissListener listener) { + v7AlertDialog.setOnDismissListener(listener); + } + + @Override + public void setCanceledOnTouchOutside(boolean cancel) { + v7AlertDialog.setCanceledOnTouchOutside(cancel); + } + } + + /** A builder for an alert dialog that uses the default alert dialog theme. */ + public interface Builder { + /** + * Returns a {@link Context} with the appropriate theme for dialogs created by this Builder. + * Applications should use this Context for obtaining LayoutInflaters for inflating views that + * will be used in the resulting dialogs, as it will cause views to be inflated with the correct + * theme. + * + * @return A Context for built Dialogs. + */ + @NonNull + Context getContext(); + + /** See {@link AlertDialog.Builder#setTitle(int)} */ + A11yAlertDialogWrapper.Builder setTitle(@StringRes int resId); + + /** See {@link AlertDialog.Builder#setTitle(CharSequence)} */ + A11yAlertDialogWrapper.Builder setTitle(@NonNull CharSequence title); + + /** See {@link AlertDialog.Builder#setCustomTitle(View)} */ + A11yAlertDialogWrapper.Builder setCustomTitle(@NonNull View customTitleView); + + /** See {@link AlertDialog.Builder#setMessage(int)} */ + A11yAlertDialogWrapper.Builder setMessage(@StringRes int resId); + + /** See {@link AlertDialog.Builder#setMessage(CharSequence)} */ + A11yAlertDialogWrapper.Builder setMessage(@NonNull CharSequence message); + + /** See {@link AlertDialog.Builder#setIcon(int)} */ + A11yAlertDialogWrapper.Builder setIcon(@DrawableRes int resId); + + /** See {@link AlertDialog.Builder#setIcon(Drawable)} */ + A11yAlertDialogWrapper.Builder setIcon(@NonNull Drawable drawable); + + /** See {@link AlertDialog.Builder#setPositiveButton(int, OnClickListener)} */ + A11yAlertDialogWrapper.Builder setPositiveButton( + @StringRes int resId, OnClickListener listener); + + /** See {@link AlertDialog.Builder#setPositiveButton(CharSequence, OnClickListener)} */ + A11yAlertDialogWrapper.Builder setPositiveButton(CharSequence text, OnClickListener listener); + + /** See {@link AlertDialog.Builder#setNegativeButton(int, OnClickListener)} */ + A11yAlertDialogWrapper.Builder setNegativeButton( + @StringRes int resId, OnClickListener listener); + + /** See {@link AlertDialog.Builder#setNegativeButton(CharSequence, OnClickListener)} */ + A11yAlertDialogWrapper.Builder setNegativeButton(CharSequence text, OnClickListener listener); + + /** See {@link AlertDialog.Builder#setNeutralButton(int, OnClickListener)} */ + A11yAlertDialogWrapper.Builder setNeutralButton(@StringRes int resId, OnClickListener listener); + + /** See {@link AlertDialog.Builder#setNeutralButton(CharSequence, OnClickListener)} */ + A11yAlertDialogWrapper.Builder setNeutralButton(CharSequence text, OnClickListener listener); + + /** See {@link AlertDialog.Builder#setCancelable(boolean)} */ + A11yAlertDialogWrapper.Builder setCancelable(boolean cancelable); + + /** See {@link AlertDialog.Builder#setOnCancelListener(OnCancelListener)} */ + A11yAlertDialogWrapper.Builder setOnCancelListener(OnCancelListener listener); + + /** See {@link AlertDialog.Builder#setOnDismissListener(OnDismissListener)} */ + A11yAlertDialogWrapper.Builder setOnDismissListener(OnDismissListener listener); + + /** See {@link AlertDialog.Builder#setOnKeyListener(OnKeyListener)} */ + A11yAlertDialogWrapper.Builder setOnKeyListener(OnKeyListener listener); + + /** See {@link AlertDialog.Builder#setItems(int, OnClickListener)} */ + A11yAlertDialogWrapper.Builder setItems(@ArrayRes int itemsResId, OnClickListener listener); + + /** See {@link AlertDialog.Builder#setItems(CharSequence[], OnClickListener)} */ + A11yAlertDialogWrapper.Builder setItems(CharSequence[] itemTitles, OnClickListener listener); + + /** See {@link AlertDialog.Builder#setAdapter(ListAdapter, OnClickListener)} */ + A11yAlertDialogWrapper.Builder setAdapter(ListAdapter listAdapter, OnClickListener listener); + + /** See {@link AlertDialog.Builder#setOnItemSelectedListener(OnItemSelectedListener)} */ + A11yAlertDialogWrapper.Builder setOnItemSelectedListener(OnItemSelectedListener listener); + + /** See {@link AlertDialog.Builder#setView(int)} */ + A11yAlertDialogWrapper.Builder setView(int layoutResId); + + /** See {@link AlertDialog.Builder#setView(View)} */ + A11yAlertDialogWrapper.Builder setView(View view); + + /** See {@link AlertDialog.Builder#create()} */ + @NonNull + A11yAlertDialogWrapper create(); + + /** See {@link AlertDialog.Builder#show()} */ + A11yAlertDialogWrapper show(); + } + + /** A builder for an android.app.AlertDialog that uses the default alert dialog theme. */ + private static class AppBuilder implements Builder { + // Hold the Alertdialog builder which is used in the Wear OS. + private final android.app.AlertDialog.Builder appBuilder; + + AppBuilder(android.app.AlertDialog.Builder appBuilder) { + this.appBuilder = appBuilder; + } + + @Override + @NonNull + public Context getContext() { + return appBuilder.getContext(); + } + + @Override + public A11yAlertDialogWrapper.Builder setTitle(@StringRes int resId) { + appBuilder.setTitle(resId); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setTitle(@NonNull CharSequence title) { + appBuilder.setTitle(title); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setCustomTitle(@NonNull View customTitleView) { + appBuilder.setCustomTitle(customTitleView); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setMessage(@StringRes int resId) { + appBuilder.setMessage(resId); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setMessage(@NonNull CharSequence message) { + appBuilder.setMessage(message); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setIcon(@DrawableRes int resId) { + appBuilder.setIcon(resId); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setIcon(@NonNull Drawable drawable) { + appBuilder.setIcon(drawable); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setPositiveButton( + @StringRes int resId, OnClickListener listener) { + appBuilder.setPositiveButton(resId, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setPositiveButton( + CharSequence text, OnClickListener listener) { + appBuilder.setPositiveButton(text, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setNegativeButton( + @StringRes int resId, OnClickListener listener) { + appBuilder.setNegativeButton(resId, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setNegativeButton( + CharSequence text, OnClickListener listener) { + appBuilder.setNegativeButton(text, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setNeutralButton( + @StringRes int resId, OnClickListener listener) { + appBuilder.setNeutralButton(resId, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setNeutralButton( + CharSequence text, OnClickListener listener) { + appBuilder.setNeutralButton(text, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setCancelable(boolean cancelable) { + appBuilder.setCancelable(cancelable); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setOnCancelListener(OnCancelListener listener) { + appBuilder.setOnCancelListener(listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setOnDismissListener(OnDismissListener listener) { + appBuilder.setOnDismissListener(listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setOnKeyListener(OnKeyListener listener) { + appBuilder.setOnKeyListener(listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setItems( + @ArrayRes int itemsResId, OnClickListener listener) { + appBuilder.setItems(itemsResId, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setItems( + CharSequence[] itemTitles, OnClickListener listener) { + appBuilder.setItems(itemTitles, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setAdapter( + ListAdapter listAdapter, OnClickListener listener) { + appBuilder.setAdapter(listAdapter, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setOnItemSelectedListener( + OnItemSelectedListener listener) { + appBuilder.setOnItemSelectedListener(listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setView(int layoutResId) { + appBuilder.setView(layoutResId); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setView(View view) { + appBuilder.setView(view); + return this; + } + + @Override + @NonNull + public A11yAlertDialogWrapper create() { + android.app.AlertDialog alertdialog = appBuilder.create(); + return new A11yAlertDialogWrapper(alertdialog); + } + + @Override + public A11yAlertDialogWrapper show() { + android.app.AlertDialog alertdialog = appBuilder.show(); + return new A11yAlertDialogWrapper(alertdialog); + } + } + + /** + * A builder for an androidx.appcompat.app.AlertDialog that uses the default alert dialog theme. + */ + private static class V7Builder implements Builder { + // Hold the Alertdialog builder, such as MaterialAlertDialog or support.v7.app.AlertDialog, for + // the most of the platforms excpet the Wear OS. + private final AlertDialog.Builder v7Builder; + + V7Builder(AlertDialog.Builder v7Builder) { + this.v7Builder = v7Builder; + } + + @Override + @NonNull + public Context getContext() { + return v7Builder.getContext(); + } + + @Override + public A11yAlertDialogWrapper.Builder setTitle(@StringRes int resId) { + v7Builder.setTitle(resId); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setTitle(@NonNull CharSequence title) { + v7Builder.setTitle(title); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setCustomTitle(@NonNull View customTitleView) { + + v7Builder.setCustomTitle(customTitleView); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setMessage(@StringRes int resId) { + v7Builder.setMessage(resId); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setMessage(@NonNull CharSequence message) { + v7Builder.setMessage(message); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setIcon(@DrawableRes int resId) { + v7Builder.setIcon(resId); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setIcon(@NonNull Drawable drawable) { + v7Builder.setIcon(drawable); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setPositiveButton( + @StringRes int resId, OnClickListener listener) { + v7Builder.setPositiveButton(resId, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setPositiveButton( + CharSequence text, OnClickListener listener) { + v7Builder.setPositiveButton(text, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setNegativeButton( + @StringRes int resId, OnClickListener listener) { + v7Builder.setNegativeButton(resId, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setNegativeButton( + CharSequence text, OnClickListener listener) { + v7Builder.setNegativeButton(text, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setNeutralButton( + @StringRes int resId, OnClickListener listener) { + v7Builder.setNeutralButton(resId, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setNeutralButton( + CharSequence text, OnClickListener listener) { + v7Builder.setNeutralButton(text, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setCancelable(boolean cancelable) { + v7Builder.setCancelable(cancelable); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setOnCancelListener(OnCancelListener listener) { + v7Builder.setOnCancelListener(listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setOnDismissListener(OnDismissListener listener) { + v7Builder.setOnDismissListener(listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setOnKeyListener(OnKeyListener listener) { + v7Builder.setOnKeyListener(listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setItems( + @ArrayRes int itemsResId, OnClickListener listener) { + v7Builder.setItems(itemsResId, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setItems( + CharSequence[] itemTitles, OnClickListener listener) { + v7Builder.setItems(itemTitles, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setAdapter( + ListAdapter listAdapter, OnClickListener listener) { + v7Builder.setAdapter(listAdapter, listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setOnItemSelectedListener( + OnItemSelectedListener listener) { + v7Builder.setOnItemSelectedListener(listener); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setView(int layoutResId) { + v7Builder.setView(layoutResId); + return this; + } + + @Override + public A11yAlertDialogWrapper.Builder setView(View view) { + v7Builder.setView(view); + return this; + } + + @Override + @NonNull + public A11yAlertDialogWrapper create() { + AlertDialog alertdialog = v7Builder.create(); + return new A11yAlertDialogWrapper(alertdialog); + } + + @Override + public A11yAlertDialogWrapper show() { + AlertDialog alertdialog = v7Builder.show(); + return new A11yAlertDialogWrapper(alertdialog); + } + } + + /** + * A builder for an alert dialog that creates {@link AlertDialog.Builder} or {@link + * com.google.android.material.dialog.MaterialAlertDialogBuilder} by the version of Android API + * for the most of the platforms except the Wear OS. It creates {@link + * android.app.AlertDialog.Builder} for the Wear OS. + */ + private static class MaterialDialogBuilder { + private final Context context; + + /** + * Creates a builder,{@link AlertDialog} or {@link + * com.google.android.material.dialog.MaterialAlertDialogBuilder}. + * + * @param context the parent context + */ + MaterialDialogBuilder(@NonNull Context context) { + this.context = context; + } + + /** + * Creates a builder,{@link AlertDialog} or {@link + * com.google.android.material.dialog.MaterialAlertDialogBuilder}. + * + * @return A11yAlertDialogWrapper.Builder + */ + A11yAlertDialogWrapper.Builder create() { + // Creates android.app.AlertDialog.Builder if this is the Wear OS. Otherwise, creates + // androidx.appcompat.app.AlertDialog.Builder before API 31 or + // com.google.android.material.dialog.MaterialAlertDialogBuilder from API 31. + if (FeatureSupport.isWatch(context)) { + return new A11yAlertDialogWrapper.AppBuilder(AlertDialogUtils.builder(context)); + } else { + return new A11yAlertDialogWrapper.V7Builder( + MaterialComponentUtils.alertDialogBuilder(context)); + } + } + } + + /** + * Creates {@link A11yAlertDialogWrapper.Builder} which holds {@link + * com.google.android.material.dialog.MaterialAlertDialogBuilder} for the most of the platforms + * except the Wear OS or {@link android.app.AlertDialog.Builder} for the Wear OS. + * + * @param context The current context + * @return {@link A11yAlertDialogWrapper.Builder} returns A11yAlertDialogWrapper.Builder + */ + public static A11yAlertDialogWrapper.Builder materialDialogBuilder(@NonNull Context context) { + return new A11yAlertDialogWrapper.MaterialDialogBuilder(context).create(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityEventListener.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityEventListener.java new file mode 100644 index 0000000..4857ff1 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityEventListener.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.view.accessibility.AccessibilityEvent; +import com.google.android.accessibility.utils.Performance.EventId; + +/** + * Listener for a11y events. Each class that extends {@link AccessibilityEventListener} should + * specify a mask of the events it would handle and {@return} that mask by implementing {@link + * #getEventTypes()} method. + */ +public interface AccessibilityEventListener { + + /** Returns whether event matches getEventTypes(). */ + default boolean matches(AccessibilityEvent event) { + return AccessibilityEventUtils.eventMatchesAnyType(event, getEventTypes()); + } + + /** @return mask of the events to be handled. */ + int getEventTypes(); + + /** + * Note: This method receives the events that are specified in the mask returned by {@link + * #getEventTypes()} method. + */ + void onAccessibilityEvent(AccessibilityEvent event, EventId eventId); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityEventUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityEventUtils.java new file mode 100644 index 0000000..8671e13 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityEventUtils.java @@ -0,0 +1,603 @@ +/* + * Copyright (C) 2010 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.google.android.accessibility.utils; + +import android.app.Notification; +import android.os.Build; +import android.os.Parcelable; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.view.Display; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityWindowInfo; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityWindowInfoCompat; +import com.google.common.base.Function; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** This class contains utility methods. */ +public class AccessibilityEventUtils { + + private static final String SYSTEM_UI_PACKAGE_NAME = "com.android.systemui"; + private static final String VOLUME_DIALOG_CLASS_NAME = "android.app.Dialog"; + private static final String VOLUME_CONTROLS_CLASS_IN_ANDROID_P = + "com.android.systemui.volume.VolumeDialogImpl$CustomDialog"; + private static final String GBOARD_PACKAGE_NAME_BASE_PREFIX = "com.android.inputmethod"; + private static final String GBOARD_PACKAGE_NAME_GOOGLE_PREFIX = "com.google.android.inputmethod"; + private static final String GBOARD_PACKAGE_NAME_APPS_PREFIX = + "com.google.android.apps.inputmethod"; + + /** Unknown window id. Must match private variable AccessibilityWindowInfo.UNDEFINED_WINDOW_ID */ + public static final int WINDOW_ID_NONE = -1; + + /** Undefined scroll delta. */ + public static final int DELTA_UNDEFINED = -1; + + private AccessibilityEventUtils() { + // This class is not instantiable. + } + + /** Returns the source node. */ + public static @Nullable AccessibilityNodeInfoCompat sourceCompat( + @Nullable AccessibilityEvent event) { + return (event == null) ? null : AccessibilityNodeInfoUtils.toCompat(event.getSource()); + } + + /** Returns window id from event, or WINDOW_ID_NONE. */ + public static int getWindowId(@Nullable AccessibilityEvent event) { + if (event == null) { + return WINDOW_ID_NONE; + } + // Try to get window id from event. + int windowId = event.getWindowId(); + if (windowId != WINDOW_ID_NONE) { + return windowId; + } + // Try to get window id from event source. + AccessibilityNodeInfo source = event.getSource(); + return (source == null) ? WINDOW_ID_NONE : source.getWindowId(); + } + + /** Returns the source display id from event. */ + public static int getDisplayId(AccessibilityEvent event) { + if (!FeatureSupport.supportMultiDisplay()) { + return Display.DEFAULT_DISPLAY; + } + AccessibilityNodeInfo source = event.getSource(); + AccessibilityWindowInfo window = AccessibilityNodeInfoUtils.getWindow(source); + return (window == null) + ? Display.DEFAULT_DISPLAY + : AccessibilityWindowInfoUtils.getDisplayId(window); + } + + /** + * Determines if an accessibility event is of a type defined by a mask of qualifying event types. + * + * @param event The event to evaluate + * @param typeMask A mask of event types that will cause this method to accept the event as + * matching + * @return {@code true} if {@code event}'s type is one of types defined in {@code typeMask}, + * {@code false} otherwise + */ + public static boolean eventMatchesAnyType(AccessibilityEvent event, int typeMask) { + return event != null && (event.getEventType() & typeMask) != 0; + } + + /** + * Gets the text of an event by returning the content description (if available) or + * by concatenating the text members (regardless of their priority) using space as a delimiter. + * + * @param event The event. + * @return The event text. + */ + public static @Nullable CharSequence getEventTextOrDescription(AccessibilityEvent event) { + if (event == null) { + return null; + } + + final CharSequence contentDescription = event.getContentDescription(); + + if (!TextUtils.isEmpty(contentDescription)) { + return contentDescription; + } + + return getEventAggregateText(event); + } + + /** + * Gets the text of an event by concatenating the text members (regardless of their + * priority) using space as a delimiter. + * + * @param event The event. + * @return The event text. + */ + public static @Nullable CharSequence getEventAggregateText(AccessibilityEvent event) { + if (event == null) { + return null; + } + + final SpannableStringBuilder aggregator = new SpannableStringBuilder(); + for (CharSequence text : event.getText()) { + StringBuilderUtils.appendWithSeparator(aggregator, text); + } + + return aggregator; + } + + public static boolean isCharacterTraversalEvent(AccessibilityEvent event) { + return (event.getEventType() + == AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY + && event.getMovementGranularity() + == AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER); + } + + /** + * Returns true if the {@link AccessibilityEvent#TYPE_WINDOW_STATE_CHANGED} event comes from the + * IME or volume dialog. + */ + public static boolean isIMEorVolumeWindow(AccessibilityEvent event) { + if (event.getEventType() != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { + return false; + } + // If there's an actual window ID, we need to check the window type (if window available). + AccessibilityNodeInfo source = event.getSource(); + AccessibilityWindowInfo window = AccessibilityNodeInfoUtils.getWindow(source); + if (window == null) { + // It may get null window after receiving TYPE_WINDOW_STATE_CHANGED + // because of framework timing issue. So we can't treat null window as non-main window + // directly, here use package name to check GBoard and volume cases. + if (isFromGBoardPackage(event.getPackageName()) || isFromVolumeControlPanel(event)) { + return true; + } + } else { + switch (window.getType()) { + case AccessibilityWindowInfoCompat.TYPE_INPUT_METHOD: + // Filters out TYPE_INPUT_METHOD_DIALOG. + return Role.getSourceRole(event) != Role.ROLE_ALERT_DIALOG; + case AccessibilityWindowInfoCompat.TYPE_SYSTEM: + return isFromVolumeControlPanel(event); + default: // fall out + } + } + + return false; + } + + /** + * Only cares about announcements from google IME (GBoard) because it will fire + * TYPE_WINDOW_STATE_CHANGED event to show its status updated, while other 3-party IME doesn't. + */ + public static boolean isFromGBoardPackage(CharSequence packageName) { + if (packageName == null) { + return false; + } + String packageNameString = packageName.toString(); + return packageNameString.startsWith(GBOARD_PACKAGE_NAME_BASE_PREFIX) + || packageNameString.startsWith(GBOARD_PACKAGE_NAME_GOOGLE_PREFIX) + || packageNameString.startsWith(GBOARD_PACKAGE_NAME_APPS_PREFIX); + } + + public static boolean isFromVolumeControlPanel(AccessibilityEvent event) { + // Volume slider case. + // TODO: Find better way to handle volume slider. + CharSequence packageName = event.getPackageName(); + CharSequence sourceClassName = event.getClassName(); + boolean isVolumeInAndroidP = + BuildVersionUtils.isAtLeastP() + && TextUtils.equals(sourceClassName, VOLUME_CONTROLS_CLASS_IN_ANDROID_P); + boolean isVolumeInAndroidO = + BuildVersionUtils.isAtLeastO() + && (!BuildVersionUtils.isAtLeastP()) + && TextUtils.equals(sourceClassName, VOLUME_DIALOG_CLASS_NAME); + return TextUtils.equals(SYSTEM_UI_PACKAGE_NAME, packageName) + && (isVolumeInAndroidO || isVolumeInAndroidP); + } + + /** Returns whether the {@link AccessibilityEvent} contains {@link Notification} data. */ + public static boolean isNotificationEvent(AccessibilityEvent event) { + // Real notification events always have parcelable data. + return event != null + && event.getEventType() == AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED + && event.getParcelableData() != null; + } + + /** + * Extracts a {@link Notification} from an {@link AccessibilityEvent}. + * + * @param event The event to extract from. + * @return The extracted Notification, or {@code null} on error. + */ + public static @Nullable Notification extractNotification(AccessibilityEvent event) { + final Parcelable parcelable = event.getParcelableData(); + + if (!(parcelable instanceof Notification)) { + return null; + } + + return (Notification) parcelable; + } + + /** + * Returns the progress percentage from the event. The value will be in the range [0, 100], where + * 100 is the maximum scroll amount. + * + * @param event The event from which to obtain the progress percentage. + * @return The progress percentage. + */ + public static float getProgressPercent(AccessibilityEvent event) { + if (event == null) { + return 0.0f; + } + final int maxProgress = event.getItemCount(); + final int progress = event.getCurrentItemIndex(); + final float percent = (progress / (float) maxProgress); + + return (100.0f * Math.max(0.0f, Math.min(1.0f, percent))); + } + + /** + * Returns the percentage scrolled within a scrollable view. The value will be in the range [0, + * 100], where 100 is the maximum scroll amount. + * + * @param event The event from which to obtain the scroll position. + * @param defaultValue Value to return if there is no scroll position from the event. This value + * should be in the range [0, 100]. + * @return The percentage scrolled within a scrollable view. + */ + public static float getScrollPercent(AccessibilityEvent event, float defaultValue) { + if (defaultValue < 0 || defaultValue > 100) { + throw new IllegalArgumentException( + "Default value should be in the range [0, 100]. Got " + defaultValue + "."); + } + final float position = getScrollPosition(event, defaultValue / 100); + + return (100.0f * Math.max(0.0f, Math.min(1.0f, position))); + } + + /** + * Returns a floating point value representing the scroll position of an {@link + * AccessibilityEvent}. This value may be outside the range {0..1}. If there's no valid way to + * obtain a position, this method returns the default value. + * + * @param event The event from which to obtain the scroll position. + * @param defaultValue Value to return if there is no valid scroll position from the event. + * @return A floating point value representing the scroll position. + */ + public static float getScrollPosition(AccessibilityEvent event, float defaultValue) { + if (event == null) { + return defaultValue; + } + + final int itemCount = event.getItemCount(); + final int fromIndex = event.getFromIndex(); + + // First, attempt to use (fromIndex / itemCount). + if ((fromIndex >= 0) && (itemCount > 0)) { + return (fromIndex / (float) itemCount); + } + + final int scrollY = event.getScrollY(); + final int maxScrollY = event.getMaxScrollY(); + + // Next, attempt to use (scrollY / maxScrollY). This will fail if the + // getMaxScrollX() method is not available. + if ((scrollY >= 0) && (maxScrollY > 0)) { + return (scrollY / (float) maxScrollY); + } + + // Finally, attempt to use (scrollY / itemCount). + // TODO: Investigate if it is still needed. + if ((scrollY >= 0) && (itemCount > 0) && (scrollY <= itemCount)) { + return (scrollY / (float) itemCount); + } + + return defaultValue; + } + + public static boolean hasSourceNode(AccessibilityEvent event) { + if (event == null) { + return false; + } + AccessibilityNodeInfo source = event.getSource(); + return source != null; + } + + /** Returns {@code true} if the event source window is anchored. */ + public static boolean hasAnchoredWindow(AccessibilityEvent event) { + if (event == null) { + return false; + } + AccessibilityWindowInfo sourceWindow = AccessibilityNodeInfoUtils.getWindow(event.getSource()); + return AccessibilityWindowInfoUtils.getAnchor(sourceWindow) != null; + } + + /** + * Recycles an old event, and obtains a copy of a new event to replace the old event. + * + *

Example usage: + * + *

{@code
+   * AccessibilityEvent lastEvent = firstEvent.obtain();
+   * // Use lastEvent...
+   * lastEvent = replaceWithCopy(lastEvent, secondEvent);
+   * // Use lastEvent...
+   * }
+ * + * @param old An old event, which will be replaced by this function. + * @param newEvent A new event which will be copied by this function. + * @return A copy of newEvent. + */ + public static @Nullable AccessibilityEvent replaceWithCopy( + @Nullable AccessibilityEvent old, @Nullable AccessibilityEvent newEvent) { + return (newEvent == null) ? null : AccessibilityEvent.obtain(newEvent); + } + + /** @deprecated Accessibility is discontinuing recycling. */ + @Deprecated + public static void recycle(AccessibilityEvent event) {} + + public static int[] getAllEventTypes() { + return new int[] { + AccessibilityEvent.TYPE_ANNOUNCEMENT, + AccessibilityEvent.TYPE_ASSIST_READING_CONTEXT, + AccessibilityEvent.TYPE_GESTURE_DETECTION_END, + AccessibilityEvent.TYPE_GESTURE_DETECTION_START, + AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED, + AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_END, + AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_START, + AccessibilityEvent.TYPE_TOUCH_INTERACTION_END, + AccessibilityEvent.TYPE_TOUCH_INTERACTION_START, + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED, + AccessibilityEvent.TYPE_VIEW_CLICKED, + AccessibilityEvent.TYPE_VIEW_CONTEXT_CLICKED, + AccessibilityEvent.TYPE_VIEW_FOCUSED, + AccessibilityEvent.TYPE_VIEW_HOVER_ENTER, + AccessibilityEvent.TYPE_VIEW_HOVER_EXIT, + AccessibilityEvent.TYPE_VIEW_LONG_CLICKED, + AccessibilityEvent.TYPE_VIEW_SCROLLED, + AccessibilityEvent.TYPE_VIEW_SELECTED, + AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED, + AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED, + AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY, + AccessibilityEvent.TYPE_WINDOWS_CHANGED, + AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED, + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED + }; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Methods for displaying event data + + public static String typeToString(int eventType) { + switch (eventType) { + case AccessibilityEvent.TYPE_ANNOUNCEMENT: + return "TYPE_ANNOUNCEMENT"; + case AccessibilityEvent.TYPE_ASSIST_READING_CONTEXT: + return "TYPE_ASSIST_READING_CONTEXT"; + case AccessibilityEvent.TYPE_GESTURE_DETECTION_END: + return "TYPE_GESTURE_DETECTION_END"; + case AccessibilityEvent.TYPE_GESTURE_DETECTION_START: + return "TYPE_GESTURE_DETECTION_START"; + case AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED: + return "TYPE_NOTIFICATION_STATE_CHANGED"; + case AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_END: + return "TYPE_TOUCH_EXPLORATION_GESTURE_END"; + case AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_START: + return "TYPE_TOUCH_EXPLORATION_GESTURE_START"; + case AccessibilityEvent.TYPE_TOUCH_INTERACTION_END: + return "TYPE_TOUCH_INTERACTION_END"; + case AccessibilityEvent.TYPE_TOUCH_INTERACTION_START: + return "TYPE_TOUCH_INTERACTION_START"; + case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED: + return "TYPE_VIEW_ACCESSIBILITY_FOCUSED"; + case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED: + return "TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED"; + case AccessibilityEvent.TYPE_VIEW_CLICKED: + return "TYPE_VIEW_CLICKED"; + case AccessibilityEvent.TYPE_VIEW_CONTEXT_CLICKED: + return "TYPE_VIEW_CONTEXT_CLICKED"; + case AccessibilityEvent.TYPE_VIEW_FOCUSED: + return "TYPE_VIEW_FOCUSED"; + case AccessibilityEvent.TYPE_VIEW_HOVER_ENTER: + return "TYPE_VIEW_HOVER_ENTER"; + case AccessibilityEvent.TYPE_VIEW_HOVER_EXIT: + return "TYPE_VIEW_HOVER_EXIT"; + case AccessibilityEvent.TYPE_VIEW_LONG_CLICKED: + return "TYPE_VIEW_LONG_CLICKED"; + case AccessibilityEvent.TYPE_VIEW_SCROLLED: + return "TYPE_VIEW_SCROLLED"; + case AccessibilityEvent.TYPE_VIEW_SELECTED: + return "TYPE_VIEW_SELECTED"; + case AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED: + return "TYPE_VIEW_TEXT_CHANGED"; + case AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED: + return "TYPE_VIEW_TEXT_SELECTION_CHANGED"; + case AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY: + return "TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY"; + case AccessibilityEvent.TYPE_WINDOWS_CHANGED: + return "TYPE_WINDOWS_CHANGED"; + case AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED: + return "TYPE_WINDOW_CONTENT_CHANGED"; + case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED: + return "TYPE_WINDOW_STATE_CHANGED"; + default: + return "(unhandled)"; + } + } + + public static String toStringShort(@Nullable AccessibilityEvent event) { + if (event == null) { + return "null"; + } + + @Nullable List text = event.getText(); + int scrollDeltaX = getScrollDeltaX(event); + int scrollDeltaY = getScrollDeltaY(event); + boolean scrollDeltaDefined = + ((scrollDeltaX != 0) && (scrollDeltaX != DELTA_UNDEFINED)) + || ((scrollDeltaY != 0) && (scrollDeltaY != DELTA_UNDEFINED)); + + return StringBuilderUtils.joinFields( + "AccessibilityEvent", + typeToString(event.getEventType()), + StringBuilderUtils.optionalField( + "ContentChangeTypes", contentChangeTypesToString(event.getContentChangeTypes())), + (Build.VERSION.SDK_INT >= 28) + ? StringBuilderUtils.optionalField( + "WindowChangeTypes", windowChangeTypesToString(event.getWindowChanges())) + : null, + StringBuilderUtils.optionalInt("time", event.getEventTime(), 0), + StringBuilderUtils.optionalText("class", event.getClassName()), + StringBuilderUtils.optionalText("package", event.getPackageName()), + StringBuilderUtils.optionalText( + "text", + (text == null || text.isEmpty()) + ? null + : FeatureSupport.logcatIncludePsi() + // Logs for DEBUG build or user had opt-in + ? String.format("text=%s", text) + : "***"), + StringBuilderUtils.optionalText("description", event.getContentDescription()), + StringBuilderUtils.optionalField( + "movementGranularity", + AccessibilityNodeInfoUtils.getMovementGranularitySymbolicName( + event.getMovementGranularity())), + StringBuilderUtils.optionalInt("action", event.getAction(), 0), + StringBuilderUtils.optionalInt("itemCount", event.getItemCount(), -1), + StringBuilderUtils.optionalInt("currentItemIndex", event.getCurrentItemIndex(), -1), + StringBuilderUtils.optionalTag("enabled", event.isEnabled()), + StringBuilderUtils.optionalTag("password", event.isPassword()), + StringBuilderUtils.optionalTag("checked", event.isChecked()), + StringBuilderUtils.optionalTag("fullScreen", event.isFullScreen()), + StringBuilderUtils.optionalTag("scrollable", event.isScrollable()), + StringBuilderUtils.optionalText("beforeText", event.getBeforeText()), + StringBuilderUtils.optionalInt("fromIndex", event.getFromIndex(), -1), + StringBuilderUtils.optionalInt("ToIndex", event.getToIndex(), -1), + StringBuilderUtils.optionalInt("ScrollX", event.getScrollX(), -1), + StringBuilderUtils.optionalInt("ScrollY", event.getScrollY(), -1), + scrollDeltaDefined ? String.format("scrollDelta=%d,%d", scrollDeltaX, scrollDeltaY) : null, + StringBuilderUtils.optionalInt("MaxScrollX", event.getMaxScrollX(), -1), + StringBuilderUtils.optionalInt("MaxScrollY", event.getMaxScrollY(), -1), + StringBuilderUtils.optionalInt("AddedCount", event.getAddedCount(), -1), + StringBuilderUtils.optionalInt("RemovedCount", event.getRemovedCount(), -1), + StringBuilderUtils.optionalSubObj("ParcelableData", event.getParcelableData())); + } + + private static String contentChangeTypesToString(int flags) { + return flagsToString(flags, AccessibilityEventUtils::singleContentChangeTypeToString); + } + + private static @Nullable String windowChangeTypesToString(int flags) { + return flagsToString(flags, AccessibilityEventUtils::singleWindowChangeTypeToString); + } + + private static @Nullable String flagsToString(int flags, Function flagMapper) { + if (flags == 0) { + return null; + } + StringBuilder s = new StringBuilder(); + for (int flag = 1; flag != 0; flag = (flag << 1)) { + if ((flags & flag) != 0) { + if (s.length() > 0) { + s.append(","); + } + s.append(flagMapper.apply(flag)); + } + } + return s.toString(); + } + + /** Copied from AccessibilityEvent.java */ + private static @Nullable String singleContentChangeTypeToString(int type) { + if (type == 0) { + return null; + } + switch (type) { + case AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION: + return "CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION"; + case AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION: + return "CONTENT_CHANGE_TYPE_STATE_DESCRIPTION"; + case AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE: + return "CONTENT_CHANGE_TYPE_SUBTREE"; + case AccessibilityEvent.CONTENT_CHANGE_TYPE_TEXT: + return "CONTENT_CHANGE_TYPE_TEXT"; + case AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_TITLE: + return "CONTENT_CHANGE_TYPE_PANE_TITLE"; + case AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED: + return "CONTENT_CHANGE_TYPE_UNDEFINED"; + case AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_APPEARED: + return "CONTENT_CHANGE_TYPE_PANE_APPEARED"; + case AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_DISAPPEARED: + return "CONTENT_CHANGE_TYPE_PANE_DISAPPEARED"; + default: + return Integer.toHexString(type); + } + } + + /** Copied from AccessibilityEvent.java */ + private static @Nullable String singleWindowChangeTypeToString(int type) { + if (type == 0) { + return null; + } + switch (type) { + case AccessibilityEvent.WINDOWS_CHANGE_ADDED: + return "WINDOWS_CHANGE_ADDED"; + case AccessibilityEvent.WINDOWS_CHANGE_REMOVED: + return "WINDOWS_CHANGE_REMOVED"; + case AccessibilityEvent.WINDOWS_CHANGE_TITLE: + return "WINDOWS_CHANGE_TITLE"; + case AccessibilityEvent.WINDOWS_CHANGE_BOUNDS: + return "WINDOWS_CHANGE_BOUNDS"; + case AccessibilityEvent.WINDOWS_CHANGE_LAYER: + return "WINDOWS_CHANGE_LAYER"; + case AccessibilityEvent.WINDOWS_CHANGE_ACTIVE: + return "WINDOWS_CHANGE_ACTIVE"; + case AccessibilityEvent.WINDOWS_CHANGE_FOCUSED: + return "WINDOWS_CHANGE_FOCUSED"; + case AccessibilityEvent.WINDOWS_CHANGE_ACCESSIBILITY_FOCUSED: + return "WINDOWS_CHANGE_ACCESSIBILITY_FOCUSED"; + case AccessibilityEvent.WINDOWS_CHANGE_PARENT: + return "WINDOWS_CHANGE_PARENT"; + case AccessibilityEvent.WINDOWS_CHANGE_CHILDREN: + return "WINDOWS_CHANGE_CHILDREN"; + case AccessibilityEvent.WINDOWS_CHANGE_PIP: + return "WINDOWS_CHANGE_PIP"; + default: + return Integer.toHexString(type); + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Methods to get scroll data + + public static int getScrollDeltaX(AccessibilityEvent event) { + return BuildVersionUtils.isAtLeastP() ? event.getScrollDeltaX() : DELTA_UNDEFINED; + } + + public static int getScrollDeltaY(AccessibilityEvent event) { + return BuildVersionUtils.isAtLeastP() ? event.getScrollDeltaY() : DELTA_UNDEFINED; + } + + public static boolean hasValidScrollDelta(AccessibilityEvent event) { + return BuildVersionUtils.isAtLeastP() + && ((event.getScrollDeltaX() != DELTA_UNDEFINED) + || (event.getScrollDeltaY() != DELTA_UNDEFINED)); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNode.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNode.java new file mode 100644 index 0000000..38edac2 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNode.java @@ -0,0 +1,568 @@ +/* + * Copyright (C) 2018 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.graphics.Rect; +import android.os.Bundle; +import android.view.accessibility.AccessibilityNodeInfo; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils.ViewResourceName; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.Role.RoleName; +import com.google.android.accessibility.utils.traversal.TraversalStrategy; +import com.google.android.accessibility.utils.traversal.TraversalStrategyUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * A wrapper around AccessibilityNodeInfo/Compat, to help with: + * + *
    + *
  • handling null nodes + *
  • refreshing + *
  • using compat vs bare methods + *
  • using correct methods for various android versions + *
+ * + *

Also wraps a single instance of AccessibilityWindowInfo/Compat, to help with: + * + *

    + *
  • reducing duplication of window info + *
  • handling null window info + *
+ * + *

Does not wrap null node-info to safely chain calls. Does contain intermediate objects, like + * window info, for pass-through functions instead of chaining. + */ +public class AccessibilityNode { + + private static final String TAG = "AccessibilityNode"; + + /** + * AccessibilityNodeInfoCompat should only be wrapper on AccessibilityNodeInfo. One may be null, + * created on demand from the other. Never expose these nodes. + */ + private @Nullable AccessibilityNodeInfo nodeBare; + + private AccessibilityNodeInfoCompat nodeCompat; + + /** Window data, created on demand. */ + private @Nullable AccessibilityWindow window; + + /////////////////////////////////////////////////////////////////////////////////////// + // Construction + + /** Caller keeps ownership of nodeArg. */ + public static @Nullable AccessibilityNode obtainCopy(@Nullable AccessibilityNodeInfo nodeArg) { + return construct(nodeArg, /* copy= */ true, FACTORY); + } + + /** Caller keeps ownership of nodeArg. */ + public static @Nullable AccessibilityNode obtainCopy( + @Nullable AccessibilityNodeInfoCompat nodeArg) { + return construct(nodeArg, /* copy= */ true, FACTORY); + } + + /** Gets a copy of this node. */ + public @Nullable AccessibilityNode obtainCopy() { + return obtainCopy(getCompat()); + } + + /** Gets a copy of this node. */ + public @Nullable AccessibilityNodeInfoCompat obtainCopyCompat() { + return AccessibilityNodeInfoCompat.obtain(getCompat()); + } + + /** Takes ownership of nodeArg. */ + public static @Nullable AccessibilityNode takeOwnership(@Nullable AccessibilityNodeInfo nodeArg) { + return construct(nodeArg, /* copy= */ false, FACTORY); + } + + /** Takes ownership of nodeArg. */ + public static @Nullable AccessibilityNode takeOwnership( + @Nullable AccessibilityNodeInfoCompat nodeArg) { + return construct(nodeArg, /* copy= */ false, FACTORY); + } + + /** + * Returns a node instance, or null. Applies null-checking and copying. Should only be called by + * this class and sub-classes. Uses factory argument to create sub-class instances, without + * creating unnecessary instances when result should be null. Method is protected so that it can + * be called by sub-classes without duplicating null-checking logic. + * + * @param nodeArg The wrapped node info. + * @param copy If true, a copy is wrapped. + * @param factory Creates instances of AccessibilityNode or sub-classes. + * @return AccessibilityNode instance. + */ + protected static @Nullable T construct( + @Nullable AccessibilityNodeInfo nodeArg, boolean copy, Factory factory) { + if (nodeArg == null) { + return null; + } + T instance = factory.create(); + AccessibilityNode instanceBase = instance; + instanceBase.nodeBare = copy ? AccessibilityNodeInfo.obtain(nodeArg) : nodeArg; + return instance; + } + + /** Returns a node instance, or null. Should only be called by this class and sub-classes. */ + protected static @Nullable T construct( + @Nullable AccessibilityNodeInfoCompat nodeArg, boolean copy, Factory factory) { + // See implementation notes in overloaded construct() method, above. + if (nodeArg == null) { + return null; + } + T instance = factory.create(); + AccessibilityNode instanceBase = instance; + instanceBase.nodeCompat = copy ? AccessibilityNodeInfoCompat.obtain(nodeArg) : nodeArg; + return instance; + } + + protected AccessibilityNode() {} + + /** A factory that can create instances of AccessibilityNode or sub-classes. */ + protected interface Factory { + T create(); + } + + private static final Factory FACTORY = + new Factory() { + @Override + public AccessibilityNode create() { + return new AccessibilityNode(); + } + }; + + /////////////////////////////////////////////////////////////////////////////////////// + // Refreshing + + /** Returns flag for success. */ + public final synchronized boolean refresh() { + + // Remove stale window info, so that refreshed node can re-generate window info. + window = null; + + // Try to refresh node. + try { + if (nodeCompat == null) { + return nodeBare.refresh(); + } else { + nodeBare = null; // Remove stale inner node reference. + return nodeCompat.refresh(); + } + } catch (IllegalStateException e) { + logOrThrow( + e, "Caught IllegalStateException from accessibility framework trying to refresh node"); + return false; + } + } + + /////////////////////////////////////////////////////////////////////////////////////// + // Recycling + + /** + * Returns whether the wrapped event is already recycled. + * + *

TODO: Remove once all dependencies have been removed. + * + * @deprecated Accessibility is discontinuing recycling. Function will return false. + */ + @Deprecated + public final synchronized boolean isRecycled() { + return false; + } + + /** + * Recycles non-null nodes and empties collection. + * + * @deprecated Accessibility is discontinuing recycling. Function will still clear nodes. + */ + @Deprecated + public static void recycle(String caller, Collection nodes) { + if (nodes != null) { + nodes.clear(); + } + } + + /** + * Recycles non-null nodes. + * + * @deprecated Accessibility is discontinuing recycling. + */ + @Deprecated + public static void recycle(@Nullable AccessibilityNode... nodes) {} + + /** + * Recycles non-null nodes. + * + * @deprecated Accessibility is discontinuing recycling. + */ + @Deprecated + public static void recycle(String caller, @Nullable AccessibilityNode... nodes) {} + + /** + * Recycles the wrapped node & window. Errors if called more than once. + * + * @deprecated Accessibility is discontinuing recycling. + */ + @Deprecated + public final void recycle() {} + + /** + * Recycles the wrapped node & window. Errors if called more than once. + * + * @deprecated Accessibility is discontinuing recycling. + */ + @Deprecated + public final synchronized void recycle(String caller) {} + + /** Overridable for testing. */ + protected boolean isDebug() { + return BuildConfig.DEBUG; + } + + /////////////////////////////////////////////////////////////////////////////////////// + // AccessibilityNodeInfo methods. If compat method available, ensure node is converted to compat. + // TODO: For thorough thread-safety, all data accessor methods should be synchronized, + // so they do not read node info that is being recycled. Also see: + // https://developer.android.com/reference/android/view/accessibility/AccessibilityNodeInfo + + /** Access bare node, which always exists. Extracts reference to bare node on demand. */ + private AccessibilityNodeInfo getBare() { + if (nodeBare == null) { + nodeBare = nodeCompat.unwrap(); // Available since compat library 26.1.0 + } + return nodeBare; + } + + /** Create and use compat wrapper on demand. */ + private AccessibilityNodeInfoCompat getCompat() { + if (nodeCompat == null) { + nodeCompat = AccessibilityNodeInfoCompat.wrap(nodeBare); // Available since compat 26.1.0 + } + return nodeCompat; + } + + /** Returns hash-code for use as a HashMap key. */ + @Override + public final int hashCode() { + return getBare().hashCode(); + } + + /** Returns equality check, for use as a HashMap key. */ + @Override + public final boolean equals(Object otherObj) { + if (this == otherObj) { + return true; + } + if (!(otherObj instanceof AccessibilityNode)) { + return false; + } + AccessibilityNode other = (AccessibilityNode) otherObj; + return getCompat().equals(other.getCompat()); + } + + /** Performs equality checking between this node's info compat and the given one. */ + public final boolean equalTo(AccessibilityNodeInfoCompat node) { + return getCompat().equals(node); + } + + /** Performs equality checking between this node's info and the given one. */ + public final boolean equalTo(AccessibilityNodeInfo node) { + return getBare().equals(node); + } + + public final List getActionList() { + return getBare().getActionList(); + } + + /** Gets the node bounds in parent coordinates. {@code rect} will be written to. */ + public final void getBoundsInScreen(Rect rect) { + getCompat().getBoundsInScreen(rect); + } + + /** Gets the child at the given index. Caller must recycle the returned node. */ + public final AccessibilityNode getChild(int index) { + return AccessibilityNode.takeOwnership(getCompat().getChild(index)); + } + + public final int getChildCount() { + return getCompat().getChildCount(); + } + + public final CharSequence getClassName() { + return getCompat().getClassName(); + } + + public CollectionInfoCompat getCollectionInfo() { + return getCompat().getCollectionInfo(); + } + + public CollectionItemInfoCompat getCollectionItemInfo() { + return getCompat().getCollectionItemInfo(); + } + + /** Gets the parent. */ + public AccessibilityNode getParent() { + return takeOwnership(getCompat().getParent()); + } + + @RoleName + public int getRole() { + return Role.getRole(getCompat()); + } + + public final @Nullable CharSequence getContentDescription() { + return getCompat().getContentDescription(); + } + + public final @Nullable CharSequence getText() { + return AccessibilityNodeInfoUtils.getText(getCompat()); // Use compat to get clickable-spans. + } + + public boolean isHeading() { + return AccessibilityNodeInfoUtils.isHeading(getCompat()); + } + + public final boolean isVisibleToUser() { + return getCompat().isVisibleToUser(); + } + + public final boolean performAction(int action, @Nullable EventId eventId) { + return PerformActionUtils.performAction( + getCompat(), action, eventId); // Use compat to perform action. + } + + public final boolean performAction(int action, @Nullable Bundle args, @Nullable EventId eventId) { + return PerformActionUtils.performAction( + getCompat(), action, args, eventId); // Use compat to perform action. + } + + public final boolean showOnScreen(@Nullable EventId eventId) { + return PerformActionUtils.showOnScreen(getCompat(), eventId); + } + + // TODO: Add more methods on demand. Keep alphabetic order. + + /////////////////////////////////////////////////////////////////////////////////////// + // Utility methods. Call AccessibilityNodeInfoUtils methods, do not duplicate them. + + public int findDepth() { + return AccessibilityNodeInfoUtils.findDepth(getCompat()); + } + + public boolean hasMatchingDescendantOrRoot(Filter filter) { + if (filter.accept(getCompat())) { + return true; + } + return AccessibilityNodeInfoUtils.hasMatchingDescendant(getCompat(), filter); + } + + /** Returns all descendants that match filter. */ + public List getMatchingDescendantsOrRoot( + Filter filter) { + List matchesCompat = + AccessibilityNodeInfoUtils.getMatchingDescendantsOrRoot(getCompat(), filter); + List matches = new ArrayList<>(matchesCompat.size()); + for (AccessibilityNodeInfoCompat matchCompat : matchesCompat) { + matches.add(AccessibilityNode.takeOwnership(matchCompat)); + } + return matches; + } + + /** + * Returns duplicated current AccessibilityNode if it matches the {@code filter}, or the first + * matching ancestor. Returns {@code null} if no nodes match. + */ + public AccessibilityNode getSelfOrMatchingAncestor(Filter filter) { + AccessibilityNodeInfoCompat matchCompat = + AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(getCompat(), filter); + return AccessibilityNode.takeOwnership(matchCompat); + } + + public final CharSequence getNodeText() { + return AccessibilityNodeInfoUtils.getNodeText(getCompat()); + } + + public final @Nullable String getViewIdText() { + return AccessibilityNodeInfoUtils.getViewIdText(getCompat()); + } + + public boolean hasAncestor(final AccessibilityNode targetAncestor) { + if (targetAncestor == null) { + return false; + } + + Filter filter = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return targetAncestor.equalTo(node); + } + }; + + return AccessibilityNodeInfoUtils.isOrHasMatchingAncestor(getCompat(), filter); + } + + public final boolean isAccessibilityFocusable() { + return AccessibilityNodeInfoUtils.isAccessibilityFocusable(getCompat()); + } + + /** + * Returns the result of applying a filter using breadth-first traversal from current node. + * + * @param filter The filter to satisfy. + * @return The first node reached via BFS traversal that satisfies the filter. + */ + public @Nullable AccessibilityNode searchFromBfs(Filter filter) { + AccessibilityNodeInfoCompat matchCompat = + AccessibilityNodeInfoUtils.searchFromBfs(getCompat(), filter); + return AccessibilityNode.takeOwnership(matchCompat); + } + + /** + * Returns true if two nodes share the same parent. + * + * @param node1 the node to check. + * @param node2 the node to check. + * @return {@code true} if node and comparedNode share the same parent. + */ + public static boolean shareParent( + @Nullable AccessibilityNode node1, @Nullable AccessibilityNode node2) { + if (node1 == null || node2 == null) { + return false; + } + AccessibilityNode node1Parent = node1.getParent(); + AccessibilityNode node2Parent = node2.getParent(); + return (node1Parent != null && node1Parent.equals(node2Parent)); + } + + // TODO: Add methods on demand. Keep alphabetic order. + + /////////////////////////////////////////////////////////////////////////////////////// + // AccessibilityWindowInfo methods + + /** Gets or creates window info, owned by AccessibilityNode. */ + private @Nullable AccessibilityWindow getWindow() { + if (window == null) { + window = + AccessibilityWindow.takeOwnership( + AccessibilityNodeInfoUtils.getWindow(getBare()), + AccessibilityNodeInfoUtils.getWindow(getCompat())); + } + return window; + } + + public final boolean windowIsInPictureInPictureMode() { + AccessibilityWindow window = getWindow(); + return (window != null) && window.isInPictureInPictureMode(); + } + + public final boolean windowIsActive() { + AccessibilityWindow window = getWindow(); + return (window != null) && window.isActive(); + } + + public final boolean windowIsFocused() { + AccessibilityWindow window = getWindow(); + return (window != null) && window.isFocused(); + } + + @AccessibilityWindow.WindowType + public final int windowGetType() { + AccessibilityWindow window = getWindow(); + return (window == null) ? AccessibilityWindow.TYPE_UNKNOWN : window.getType(); + } + + // TODO: Add methods on demand. Keep alphabetic order. + + /////////////////////////////////////////////////////////////////////////////////////// + // WebInterfaceUtils methods + + /** Check if this node is web container */ + public boolean isWebContainer() { + return WebInterfaceUtils.isWebContainer(getCompat()); + } + + /////////////////////////////////////////////////////////////////////////////////////// + // TraversalStrategyUtils methods + + public TraversalStrategy getTraversalStrategy( + FocusFinder focusFinder, @TraversalStrategy.SearchDirection int direction) { + return TraversalStrategyUtils.getTraversalStrategy(getCompat(), focusFinder, direction); + } + + public @Nullable AccessibilityNode findInitialFocusInNodeTree( + TraversalStrategy traversalStrategy, + @TraversalStrategy.SearchDirection int searchDirection, + Filter nodeFilter) { + + return AccessibilityNode.takeOwnership( + TraversalStrategyUtils.findInitialFocusInNodeTree( + traversalStrategy, getCompat(), searchDirection, nodeFilter)); + } + + /////////////////////////////////////////////////////////////////////////////////////// + // ImageNode methods + + public @Nullable ViewResourceName getPackageNameAndViewId() { + return ViewResourceName.create(getCompat()); + } + + public boolean isInCollection() { + return AccessibilityNodeInfoUtils.isInCollection(getCompat()); + } + + /////////////////////////////////////////////////////////////////////////////////////// + // Error methods + + @FormatMethod + private void logOrThrow( + IllegalStateException exception, @FormatString String format, Object... parameters) { + if (isDebug()) { + throw exception; + } else { + logError(format, parameters); + logError("%s", exception); + } + } + + protected void logError(String format, Object... parameters) { + LogUtils.e(TAG, format, parameters); + } + + @FormatMethod + protected void throwError(@FormatString String format, Object... parameters) { + throw new IllegalStateException(String.format(format, parameters)); + } + + /////////////////////////////////////////////////////////////////////////////////////// + // Display methods + + @Override + public String toString() { + return AccessibilityNodeInfoUtils.toStringShort(getCompat()); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.java new file mode 100644 index 0000000..c54a3fb --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.java @@ -0,0 +1,316 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * 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.google.android.accessibility.utils; + +import androidx.annotation.Nullable; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.traversal.ReorderedChildrenIterator; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; + +/** + * A class that simplifies traversal of node trees. + * + *

This class keeps track of an {@link AccessibilityNodeInfoCompat} object and can traverse to + * other nodes in the tree, or be reset to other nodes. The node can be owned. + * + *

Any node can be assigned to objects of this class, including nodes that are not visible to the + * user. The traversal methods, however, will only traverse to visible nodes. + * + * @see AccessibilityNodeInfoUtils#isVisible(AccessibilityNodeInfoCompat) + */ +public class AccessibilityNodeInfoRef { + private AccessibilityNodeInfoCompat mNode; + private boolean mOwned; + + /** Returns the current node. */ + public AccessibilityNodeInfoCompat get() { + return mNode; + } + + /** Clears this object. */ + public void clear() { + reset((AccessibilityNodeInfoCompat) null); + } + + /** Resets this object to contain a new node, taking ownership of the new node. */ + public void reset(AccessibilityNodeInfoCompat newNode) { + mNode = newNode; + mOwned = true; + } + + /** + * Resets this object with the node held by {@code newNode}. if {@code newNode} was owning the + * node, ownership is transfered to this object. + */ + public void reset(AccessibilityNodeInfoRef newNode) { + reset(newNode.get()); + mOwned = newNode.mOwned; + newNode.mOwned = false; + } + + /** Creates a new instance of this class containing a new copy of {@code node}. */ + public static AccessibilityNodeInfoRef obtain(AccessibilityNodeInfoCompat node) { + return new AccessibilityNodeInfoRef(AccessibilityNodeInfoCompat.obtain(node), true); + } + + /** Creates a new instance of this class without assuming ownership of {@code node}. */ + @Nullable + public static AccessibilityNodeInfoRef unOwned(AccessibilityNodeInfoCompat node) { + return node != null ? new AccessibilityNodeInfoRef(node, false) : null; + } + + /** Creates a new instance of this class taking ownership of {@code node}. */ + @Nullable + public static AccessibilityNodeInfoRef owned(AccessibilityNodeInfoCompat node) { + return node != null ? new AccessibilityNodeInfoRef(node, true) : null; + } + + /** + * Creates an {@link AccessibilityNodeInfoRef} with a refreshed copy of {@code node}, taking + * ownership of the copy. If {@code node} is {@code null}, {@code null} is returned. + */ + public static AccessibilityNodeInfoRef refreshed(AccessibilityNodeInfoCompat node) { + return owned(AccessibilityNodeInfoUtils.refreshNode(node)); + } + + /** + * Makes sure that this object owns its own copy of the node it holds by creating a new copy of + * the node if not already owned or doing nothing otherwise. + */ + public AccessibilityNodeInfoRef makeOwned() { + if (mNode != null && !mOwned) { + reset(AccessibilityNodeInfoCompat.obtain(mNode)); + } + return this; + } + + public AccessibilityNodeInfoRef() {} + + public static boolean isNull(AccessibilityNodeInfoRef ref) { + return ref == null || ref.get() == null; + } + + private AccessibilityNodeInfoRef(AccessibilityNodeInfoCompat node, boolean owned) { + mNode = node; + mOwned = owned; + } + + /** + * Releases the ownership of the underlying node if it was owned, returning the underlying node. + * This is typically chained with {@link #makeOwned} to have a copy that can be put in another + * container or {@link AccessibilityNodeInfoRef}. After this call, this object still refers to the + * underlying node so that any of the traversal methods can be used afterwards. + */ + public AccessibilityNodeInfoCompat release() { + mOwned = false; + return mNode; + } + + /** Traverses to the last child of this node, returning {@code true} on success. */ + boolean lastChild() { + if (mNode == null || mNode.getChildCount() < 1) { + return false; + } + + ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createDescendingIterator(mNode); + while (iterator.hasNext()) { + AccessibilityNodeInfoCompat newNode = iterator.next(); + if (newNode == null) { + return false; + } + + if (AccessibilityNodeInfoUtils.isVisible(newNode)) { + reset(newNode); + return true; + } + } + return false; + } + + /** + * Traverses to the previous sibling of this node within its parent, returning {@code true} on + * success. + */ + public boolean previousSibling() { + if (mNode == null) { + return false; + } + AccessibilityNodeInfoCompat parent = mNode.getParent(); + if (parent == null) { + return false; + } + ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createDescendingIterator(parent); + if (!moveIteratorAfterNode(iterator, mNode)) { + return false; + } + + while (iterator.hasNext()) { + AccessibilityNodeInfoCompat newNode = iterator.next(); + if (newNode == null) { + return false; + } + if (AccessibilityNodeInfoUtils.isVisible(newNode)) { + reset(newNode); + return true; + } + } + return false; + } + + /** Traverses to the first child of this node if any, returning {@code true} on success. */ + boolean firstChild() { + if (mNode == null) { + return false; + } + + ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createAscendingIterator(mNode); + while (iterator.hasNext()) { + AccessibilityNodeInfoCompat newNode = iterator.next(); + if (newNode == null) { + return false; + } + if (AccessibilityNodeInfoUtils.isVisible(newNode)) { + reset(newNode); + return true; + } + } + return false; + } + + /** + * Traverses to the next sibling of this node within its parent, returning {@code true} on + * success. + */ + public boolean nextSibling() { + if (mNode == null) { + return false; + } + AccessibilityNodeInfoCompat parent = mNode.getParent(); + if (parent == null) { + return false; + } + ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createAscendingIterator(parent); + if (!moveIteratorAfterNode(iterator, mNode)) { + return false; + } + + while (iterator.hasNext()) { + AccessibilityNodeInfoCompat newNode = iterator.next(); + if (newNode == null) { + return false; + } + if (AccessibilityNodeInfoUtils.isVisible(newNode)) { + reset(newNode); + return true; + } + } + return false; + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private boolean moveIteratorAfterNode( + Iterator iterator, AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + while (iterator.hasNext()) { + AccessibilityNodeInfoCompat nextNode = iterator.next(); + if (node.equals(nextNode)) { + return true; + } + } + + return false; + } + + /** + * Traverses to the parent of this node, returning {@code true} on success. On failure, returns + * {@code false} and does not move. + */ + public boolean parent() { + if (mNode == null) { + return false; + } + Set visitedNodes = new HashSet<>(); + visitedNodes.add(AccessibilityNodeInfoCompat.obtain(mNode)); + AccessibilityNodeInfoCompat parentNode = mNode.getParent(); + while (parentNode != null) { + if (visitedNodes.contains(parentNode)) { + return false; + } + + if (AccessibilityNodeInfoUtils.isVisible(parentNode)) { + reset(parentNode); + return true; + } + visitedNodes.add(parentNode); + parentNode = parentNode.getParent(); + } + return false; + } + + /** Traverses to the next node in depth-first order, returning {@code true} on success. */ + public boolean nextInOrder() { + if (mNode == null) { + return false; + } + if (firstChild()) { + return true; + } + if (nextSibling()) { + return true; + } + AccessibilityNodeInfoRef tmp = unOwned(mNode); + while (tmp.parent()) { + if (tmp.nextSibling()) { + reset(tmp); + return true; + } + } + tmp.clear(); + return false; + } + + /** Traverses to the previous node in depth-first order, returning {@code true} on success. */ + public boolean previousInOrder() { + if (mNode == null) { + return false; + } + if (previousSibling()) { + lastDescendant(); + return true; + } + return parent(); + } + + /** Traverses to the last descendant of this node, returning {@code true} on success. */ + public boolean lastDescendant() { + if (!lastChild()) { + return false; + } + Set visitedNodes = new HashSet<>(); + while (lastChild()) { + if (visitedNodes.contains(mNode)) { + return false; + } + visitedNodes.add(AccessibilityNodeInfoCompat.obtain(mNode)); + } + return true; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java new file mode 100644 index 0000000..4ea768e --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java @@ -0,0 +1,2933 @@ +/* + * Copyright (C) 2012 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.google.android.accessibility.utils; + +import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS; +import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_NOT_SPEAKABLE; +import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_NOT_VISIBLE; +import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN; +import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.NONE; + +import android.annotation.TargetApi; +import android.graphics.Rect; +import android.graphics.RectF; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcelable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.util.Pair; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; +import android.view.accessibility.AccessibilityWindowInfo; +import android.widget.GridView; +import android.widget.ListView; +import androidx.annotation.NonNull; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat; +import androidx.core.view.accessibility.AccessibilityWindowInfoCompat; +import com.google.android.accessibility.utils.DiagnosticOverlayUtils.DiagnosticType; +import com.google.android.accessibility.utils.Role.RoleName; +import com.google.android.accessibility.utils.compat.CompatUtils; +import com.google.android.accessibility.utils.traversal.SpannableTraversalUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.android.libraries.accessibility.utils.url.SpannableUrl; +import com.google.auto.value.AutoValue; +import com.google.common.base.Function; +import com.google.common.base.Strings; +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.checker.nullness.qual.PolyNull; + +/** + * Provides a series of utilities for interacting with AccessibilityNodeInfo objects. NOTE: This + * class only recycles unused nodes that were collected internally. Any node passed into or returned + * from a public method is retained and TalkBack should recycle it when appropriate. + */ +public class AccessibilityNodeInfoUtils { + + /** Internal AccessibilityNodeInfoCompat extras bundle key constants. */ + private static final String BOOLEAN_PROPERTY_KEY = + "androidx.view.accessibility.AccessibilityNodeInfoCompat.BOOLEAN_PROPERTY_KEY"; + + // TODO Remove them when androidx.core library is available. + // Add this constant because AccessibilityNodeInfoCompat.setTextEntryKey() is unavailable yet. + // Copy it from + // androidx.core.view.accessibility.AccessibilityNodeInfoCompat.BOOLEAN_PROPERTY_IS_TEXT_ENTRY_KEY + private static final int BOOLEAN_MASK_IS_TEXT_ENTRY_KEY = 8; + + // The minimum amount of pixels that must be visible for a view to be surfaced to the user as + // visible (i.e. for this node to be added to the tree). + public static final int MIN_VISIBLE_PIXELS = 15; + + private static final String CLASS_LISTVIEW = ListView.class.getName(); + private static final String CLASS_GRIDVIEW = GridView.class.getName(); + + private static final HashMap actionIdToName = initActionIds(); + + // TODO: When androidx support library is available, change all node.getText() to use + // AccessibilityNodeInfoCompat.getText() via this wrapper. + /** Returns text from an accessibility-node, including spans. */ + public static @Nullable CharSequence getText(@Nullable AccessibilityNodeInfoCompat node) { + return (node == null) ? null : node.getText(); + } + + @FormatMethod + private static void logError(String functionName, @FormatString String format, Object... args) { + LogUtils.e(TAG, functionName + "() " + String.format(format, args)); + } + + ////////////////////////////////////////////////////////////////////////////////////////// + // Constants + + private static final String TAG = "AccessibilityNodeInfoUtils"; + + /** + * Class for Samsung's TouchWiz implementation of AdapterView. May be {@code null} on non-Samsung + * devices. + */ + private static final Class CLASS_TOUCHWIZ_TWADAPTERVIEW = + CompatUtils.getClass("com.sec.android.touchwiz.widget.TwAdapterView"); + + /** Key to get accessibility web hints from the web */ + private static final String HINT_TEXT_KEY = "AccessibilityNodeInfo.hint"; + + private static final Pattern RESOURCE_NAME_SPLIT_PATTERN = Pattern.compile(":id/"); + + /** Class used to find clickable-spans in text. */ + public static final Class TARGET_SPAN_CLASS = ClickableSpan.class; + + // Used to identify keys from the pin password keyboard used to unlock the screen. + private static final Pattern PIN_KEY_PATTERN = + Pattern.compile("com.android.systemui:id/key\\d{0,9}"); + + private static final String VIEW_ID_RESOURCE_NAME_PIN_ENTRY = "com.android.systemui:id/pinEntry"; + + /** + * A wrapper over AccessibilityNodeInfoCompat constructor, so that we can add any desired error + * checking and memory management. + * + * @param nodeInfo The AccessibilityNodeInfo which will be wrapped. The caller retains the + * responsibility to recycle nodeInfo. + * @return Encapsulating AccessibilityNodeInfoCompat, or null if input is null. + */ + public static @PolyNull AccessibilityNodeInfoCompat toCompat( + @PolyNull AccessibilityNodeInfo nodeInfo) { + if (nodeInfo == null) { + return null; + } + return AccessibilityNodeInfoCompat.wrap(nodeInfo); + } + + private static final int SYSTEM_ACTION_MAX = 0x01FFFFFF; + + public static final int WINDOW_TYPE_NONE = -1; + public static final int WINDOW_TYPE_PICTURE_IN_PICTURE = 1000; + + /** + * Filter for scrollable items. One of the following must be true: + * + *

    + *
  • {@link AccessibilityNodeInfoCompat#isScrollable()} returns {@code true} + *
  • {@link AccessibilityNodeInfoCompat#getActions()} supports {@link + * AccessibilityNodeInfoCompat#ACTION_SCROLL_FORWARD} + *
  • {@link AccessibilityNodeInfoCompat#getActions()} supports {@link + * AccessibilityNodeInfoCompat#ACTION_SCROLL_BACKWARD} + *
+ */ + public static final Filter FILTER_SCROLLABLE = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return isScrollable(node); + } + }; + + /** Filter for items that could be scrolled forward. */ + public static final Filter FILTER_COULD_SCROLL_FORWARD = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return node != null + && supportsAction(node, AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD); + } + }; + + /** Filter for items that could be scrolled backward. */ + public static final Filter FILTER_COULD_SCROLL_BACKWARD = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return node != null + && supportsAction(node, AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD); + } + }; + + /** + * Filter for items that should receive accessibility focus. Equivalent to calling {@link + * #shouldFocusNode(AccessibilityNodeInfoCompat)}. + * + *

Note: Use {@link #FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW} has a filter for + * {@link AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} events. + */ + public static final Filter FILTER_SHOULD_FOCUS = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return node != null && shouldFocusNode(node); + } + }; + + /** + * Filter for items that should receive accessibility focus from {@link + * AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} events. WebView container node should not be focus + * for hover enter actions. + */ + public static final Filter FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW = + FILTER_SHOULD_FOCUS.and( + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return Role.getRole(node) != Role.ROLE_WEB_VIEW; + } + }); + + /** Filter for heading items in collections. */ + public static final Filter FILTER_HEADING = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return (node != null) && isHeading(node); + } + }; + + /** + * Filter that also checks for {@param node}'s non-focusable but visible children. Sometimes, a + * node that passes the filter can be embedded in a parent and might be not focusable by itself. + * In those cases it is important to focus the parent. Example would be for "Control" granularity, + * if a switch is not focusable but is embedded into a focusable parent, its parent should be + * focused. + */ + public static Filter getFilterIncludingChildren( + final Filter filter) { + return new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + boolean val = filter.accept(node); + // If the node does not pass the filter, check its non focusable, visible children. + if (!val) { + return hasMatchingDescendant(node, filter.and(FILTER_NON_FOCUSABLE_VISIBLE_NODE)); + } + return val; + } + }; + } + + /** Filter to identify nodes which are not focusable but visible. */ + public static final Filter FILTER_NON_FOCUSABLE_VISIBLE_NODE = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return isVisible(node) && !isAccessibilityFocusable(node); + } + }; + + /** Filter for controllable elements. */ + public static final Filter FILTER_CONTROL = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + @RoleName int role = Role.getRole(node); + return (role == Role.ROLE_BUTTON) + || (role == Role.ROLE_IMAGE_BUTTON) + || (role == Role.ROLE_EDIT_TEXT) + || (role == Role.ROLE_CHECK_BOX) + || (role == Role.ROLE_RADIO_BUTTON) + || (role == Role.ROLE_TOGGLE_BUTTON) + || (role == Role.ROLE_SWITCH) + || (role == Role.ROLE_DROP_DOWN_LIST) + || (role == Role.ROLE_SEEK_CONTROL); + } + }; + + /** Filter for Spannables with links. */ + public static final Filter FILTER_LINK = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return SpannableTraversalUtils.hasTargetSpanInNodeTreeDescription( + node, TARGET_SPAN_CLASS); + } + }; + + public static final Filter FILTER_CLICKABLE = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return AccessibilityNodeInfoUtils.isClickable(node); + } + }; + + public static final Filter FILTER_HAS_TEXT = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return !TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node)); + } + }; + + public static final Filter FILTER_ILLEGAL_TITLE_NODE_ANCESTOR = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + if (isClickable(node) || isLongClickable(node)) { + return true; + } + @RoleName int role = Role.getRole(node); + // A window title node should not be a descendant of AdapterView. + return (role == Role.ROLE_LIST) || (role == Role.ROLE_GRID); + } + }; + + /** + * This filter accepts scrollable views that break if we place accessibility focus on their child + * items. Instead, we should just place focus on the entire scrollable view. Note: Only include + * Android TV views that cannot be updated (i.e. part of a bundled app). + */ + private static final Filter FILTER_BROKEN_LISTS_TV_M = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + + String viewId = node.getViewIdResourceName(); + return "com.android.tv.settings:id/setup_scroll_list".equals(viewId) + || "com.google.android.gsf.notouch:id/setup_scroll_list".equals(viewId) + || "com.android.vending:id/setup_scroll_list".equals(viewId); + } + }; + + /** + * Filter that defines which types of views should be auto-scrolled. Generally speaking, only + * accepts views that are capable of showing partially-visible data. + * + *

Accepts the following classes (and sub-classes thereof): + * + *

    + *
  • {@link androidx.recyclerview.widget.RecyclerView} (Should be classified as a List or Grid.) + *
  • {@link android.widget.AbsListView} (including both ListView and GridView) + *
  • {@link android.widget.AbsSpinner} + *
  • {@link android.widget.ScrollView} + *
  • {@link android.widget.HorizontalScrollView} + *
  • {@code com.sec.android.touchwiz.widget.TwAbsListView} + *
+ * + *

Specifically excludes {@link android.widget.AdapterViewAnimator} and sub-classes, since they + * represent overlapping views. Also excludes {@link androidx.viewpager.widget.ViewPager} since it + * exclusively represents off-screen views. + */ + public static final Filter FILTER_AUTO_SCROLL = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + if (!isScrollable(node) || !isVisible(node)) { + return false; + } + @Role.RoleName int role = Role.getRole(node); + // TODO: Check if we should include ROLE_ADAPTER_VIEW as a target Role. + return role == Role.ROLE_DROP_DOWN_LIST + || role == Role.ROLE_LIST + || role == Role.ROLE_GRID + || role == Role.ROLE_SCROLL_VIEW + || role == Role.ROLE_HORIZONTAL_SCROLL_VIEW + || AccessibilityNodeInfoUtils.nodeMatchesAnyClassByType( + node, CLASS_TOUCHWIZ_TWADAPTERVIEW); + } + }; + + public static final Filter FILTER_COLLECTION = + new Filter.NodeCompat( + (node) -> { + int role = Role.getRole(node); + return (role == Role.ROLE_LIST) + || (role == Role.ROLE_GRID) + || (role == Role.ROLE_PAGER) + || (node != null && node.getCollectionInfo() != null); + }); + + private AccessibilityNodeInfoUtils() { + // This class is not instantiable. + } + + /** + * Gets the text of a node by returning the content description (if available) or by + * returning the text. + * + * @param node The node. + * @return The node text. + */ + public static @Nullable CharSequence getNodeText(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return null; + } + + // Prefer content description over text. + // TODO: Why are we checking the trimmed length? + final CharSequence contentDescription = node.getContentDescription(); + if (!TextUtils.isEmpty(contentDescription) + && (TextUtils.getTrimmedLength(contentDescription) > 0)) { + return contentDescription; + } + + final @Nullable CharSequence text = AccessibilityNodeInfoUtils.getText(node); + if (!TextUtils.isEmpty(text) && (TextUtils.getTrimmedLength(text) > 0)) { + return text; + } + + return null; + } + + /** + * Gets the state description of a node. + * + * @param node The node. + * @return The node state description. + */ + public static @Nullable CharSequence getState(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return null; + } + + final CharSequence state = node.getStateDescription(); + if (!TextUtils.isEmpty(state) && (TextUtils.getTrimmedLength(state) > 0)) { + return state; + } + + return null; + } + + /** + * Gets the Selected text of a node by returning the selected text. + * + * @param node The node. + * @return The selected node text. + */ + public static @Nullable CharSequence getSelectedNodeText( + @Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return null; + } + + CharSequence selectedText = + subsequenceSafe( + AccessibilityNodeInfoUtils.getText(node), + node.getTextSelectionStart(), + node.getTextSelectionEnd()); + if (!TextUtils.isEmpty(selectedText) && (TextUtils.getTrimmedLength(selectedText) > 0)) { + return selectedText; + } + + return null; + } + + /** Returns a sub-string or empty-string, without crashing on invalid subsequence range. */ + public static CharSequence subsequenceSafe( + @Nullable CharSequence text, int startIndex, int endIndex) { + if (text == null) { + return ""; + } + // Swap start and end. + if (endIndex < startIndex) { + int newStartIndex = endIndex; + endIndex = startIndex; + startIndex = newStartIndex; + } + // Enforce string bounds. + if (startIndex < 0) { + startIndex = 0; + } else if (startIndex > text.length()) { + startIndex = text.length(); + } + if (endIndex < 0) { + endIndex = 0; + } else if (endIndex > text.length()) { + endIndex = text.length(); + } + + return text.subSequence(startIndex, endIndex); + } + + /** + * Gets the text selection indexes safe by adjusting the checking the selection bounds. + * + * @param node The node + * @return the selection indexes + */ + public static Pair getSelectionIndexesSafe( + @NonNull AccessibilityNodeInfoCompat node) { + int selectionStart = node.getTextSelectionStart(); + int selectionEnd = node.getTextSelectionEnd(); + if (selectionStart < 0) { + selectionStart = 0; + } + if (selectionEnd < 0) { + selectionEnd = selectionStart; + } + if (selectionEnd < selectionStart) { + // Swap start and end to make sure they are in order. + int newStart = selectionEnd; + selectionEnd = selectionStart; + selectionStart = newStart; + } + return Pair.create(selectionStart, selectionEnd); + } + + /** + * Gets the textual representation of the view ID that can be used when no custom label is + * available. For better readability/listenability, the "_" characters are replaced with spaces. + * + * @param node The node + * @return Readable text of the view Id + */ + public static @Nullable String getViewIdText(AccessibilityNodeInfoCompat node) { + if (node == null) { + return null; + } + + String resourceName = node.getViewIdResourceName(); + if (resourceName == null) { + return null; + } + + String[] parsedResourceName = RESOURCE_NAME_SPLIT_PATTERN.split(resourceName, 2); + if (parsedResourceName.length != 2 + || TextUtils.isEmpty(parsedResourceName[0]) + || TextUtils.isEmpty(parsedResourceName[1])) { + return null; + } + + return parsedResourceName[1].replace('_', ' '); // readable View ID text + } + + public static @Nullable CharSequence getSelectedPageTitle(AccessibilityNodeInfoCompat viewPager) { + if ((viewPager == null) || (Role.getRole(viewPager) != Role.ROLE_PAGER)) { + return null; + } + + int numChildren = viewPager.getChildCount(); // Not the number of pages! + CharSequence title = null; + for (int i = 0; i < numChildren; ++i) { + AccessibilityNodeInfoCompat child = viewPager.getChild(i); + if (child != null) { + try { + if (child.isVisibleToUser()) { + if (title == null) { + // Try to roughly match RulePagerPage, which uses getNodeText + // (but completely matching all the time is not critical). + title = getNodeText(child); + } else { + // Multiple visible children, abort. + return null; + } + } + } finally { + recycleNodes(child); + } + } + } + + return title; + } + + public static List getCustomActions(AccessibilityNodeInfoCompat node) { + List customActions = new ArrayList<>(); + for (AccessibilityActionCompat action : node.getActionList()) { + if (isCustomAction(action)) { + // We don't use custom actions that doesn't have a label + if (!TextUtils.isEmpty(action.getLabel())) { + customActions.add(action); + } + } + } + + return customActions; + } + + public static boolean isCustomAction(AccessibilityActionCompat action) { + return action.getId() > SYSTEM_ACTION_MAX; + } + + /** Returns the root node of the tree containing {@code node}. */ + public static @Nullable AccessibilityNodeInfoCompat getRoot(AccessibilityNodeInfoCompat node) { + if (node == null) { + return null; + } + + AccessibilityWindowInfoCompat window = null; + try { + window = getWindow(node); + if (window != null) { + return AccessibilityWindowInfoUtils.getRoot(window); + } + } finally { + if (window != null) { + window.recycle(); + } + } + + Set visitedNodes = new HashSet<>(); + AccessibilityNodeInfoCompat current = null; + AccessibilityNodeInfoCompat parent = AccessibilityNodeInfoCompat.obtain(node); + + try { + do { + if (current != null) { + if (visitedNodes.contains(current)) { + current.recycle(); + parent.recycle(); + return null; + } + visitedNodes.add(current); + } + + current = parent; + parent = current.getParent(); + } while (parent != null); + } finally { + recycleNodes(visitedNodes); + } + + return current; + } + + /** Returns the type of the window containing {@code nodeCompat}. */ + public static int getWindowType(AccessibilityNodeInfoCompat nodeCompat) { + if (nodeCompat == null) { + return WINDOW_TYPE_NONE; + } + + AccessibilityWindowInfoCompat windowInfoCompat = getWindow(nodeCompat); + if (windowInfoCompat == null) { + return WINDOW_TYPE_NONE; + } + + if (isPictureInPicture(nodeCompat)) { + return WINDOW_TYPE_PICTURE_IN_PICTURE; + } + + int windowType = windowInfoCompat.getType(); + windowInfoCompat.recycle(); + return windowType; + } + + /** Wrapper for AccessibilityNodeInfoCompat.getWindow() that handles SecurityException. */ + public static @Nullable AccessibilityWindowInfoCompat getWindow( + AccessibilityNodeInfoCompat node) { + // This implementation is redundant with getWindow(AccessibilityNodeInfo) because there are no + // un/wrap() functions for AccessibilityWindowInfoCompat. + + if (node == null) { + return null; + } + + try { + return node.getWindow(); + } catch (SecurityException e) { + LogUtils.e(TAG, "SecurityException in AccessibilityWindowInfoCompat.getWindow()"); + return null; + } + } + + public static @Nullable AccessibilityWindowInfo getWindow(AccessibilityNodeInfo node) { + if (node == null) { + return null; + } + + try { + return node.getWindow(); + } catch (SecurityException e) { + LogUtils.e(TAG, "SecurityException in AccessibilityWindowInfo.getWindow()"); + return null; + } + } + + /** + * Returns whether a node can receive focus from focus traversal or touch exploration. One of the + * following must be true: + * + *

    + *
  • The node is actionable (see {@link #isFocusableOrClickable(AccessibilityNodeInfoCompat)}) + *
  • The node is a top-level list item (see {@link + * #isTopLevelScrollItem(AccessibilityNodeInfoCompat)} and is a speaking node + *
+ * + * @param node The node to check. + * @return {@code true} of the node is accessibility focusable. + */ + public static boolean isAccessibilityFocusable(AccessibilityNodeInfoCompat node) { + Set visitedNodes = new HashSet<>(); + try { + return isFocusableOrClickable(node) + || (isTopLevelScrollItem(node) && isSpeakingNode(node, null, visitedNodes)); + } finally { + AccessibilityNodeInfoUtils.recycleNodes(visitedNodes); + } + } + + /** + * Returns whether a node should receive accessibility focus from navigation. This method should + * never be called recursively, since it traverses up the parent hierarchy on every call. + * + * @see #findFocusFromHover(AccessibilityNodeInfoCompat) for touch exploration + * @see + * com.google.android.accessibility.talkback.focusmanagement.NavigationTarget#createNodeFilter(int, + * Map) for linear navigation + */ + public static boolean shouldFocusNode(AccessibilityNodeInfoCompat node) { + return shouldFocusNode(node, null, true); + } + + public static boolean shouldFocusNode( + final AccessibilityNodeInfoCompat node, + final Map speakingNodeCache) { + return shouldFocusNode(node, speakingNodeCache, true); + } + + public static boolean shouldFocusNode( + final AccessibilityNodeInfoCompat node, + final Map speakingNodeCache, + boolean checkChildren) { + if (node == null) { + LogUtils.v(TAG, "Don't focus, node=null"); + return false; + } + // Inside views that support web navigation, we delegate focus to the view itself and + // assume that it navigates to and focuses the correct elements. + if (WebInterfaceUtils.supportsWebActions(node)) { + // In history, we loosen the "visibility" check for web element: A web node can be focused + // even if it's not visibleToUser(). However we should hold the baseline that if the WebView + // container is not visible, we should not focus on its descendants. + AccessibilityNodeInfoCompat webViewContainer = + WebInterfaceUtils.ascendToWebViewContainer(node); + try { + return webViewContainer != null && webViewContainer.isVisibleToUser(); + } finally { + recycleNodes(webViewContainer); + } + } + + if (!isVisible(node)) { + logShouldFocusNode( + checkChildren, FOCUS_FAIL_NOT_VISIBLE, "Don't focus, is not visible: ", node); + return false; + } + + if (isPictureInPicture(node)) { + // For picture-in-picture, allow focusing the root node, and any app controls inside the + // pic-in-pic window. + return true; + } else { + // Reject all non-leaf nodes that are neither actionable nor focusable, and have the same + // bounds as the window. + if (areBoundsIdenticalToWindow(node) + && node.getChildCount() > 0 + && !isFocusableOrClickable(node)) { + logShouldFocusNode( + checkChildren, + FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN, + "Don't focus, bounds are same as window root node bounds, node has children and" + + " is neither actionable nor focusable: ", + node); + return false; + } + } + + HashSet visitedNodes = new HashSet<>(); + try { + // This checks if a node is clickable, focusable, screen reader focusable, or a direct + // spekaing child of a scrollable container. + boolean accessibilityFocusable = + isFocusableOrClickable(node) + || (isTopLevelScrollItem(node) && isSpeakingNode(node, null, visitedNodes)); + + if (!checkChildren) { + // End of the line. Don't check children and don't allow any recursion. + // checkChildren is only false in the shouldFocusNode call below. This is to avoid + // repetitive checks down the tree when looking up at the ancestors. + LogUtils.d( + TAG, "checkChildren=false and isAccessibilityFocusable=%s", accessibilityFocusable); + return accessibilityFocusable; + } + + // A node that is deemed accessibility focusable shouldn't actually get focus if it has + // nothing to speak. For example, a view may be focusable, but if it has no text and all of + // its children are clickable, focus should go on each child individually and not on this + // view. + // Note: This is redundant for nodes that pass isSpeakingNode above + // Note: A special case exists for unlabeled buttons which otherwise wouldn't get focus. + if (accessibilityFocusable) { + AccessibilityNodeInfoUtils.recycleNodes(visitedNodes); + visitedNodes.clear(); + // TODO: This may still result in focusing non-speaking nodes, but it + // won't prevent unlabeled buttons from receiving focus. + if (!hasVisibleChildren(node)) { + logShouldFocusNode( + checkChildren, NONE, "Focus, is focusable and has no visible children: ", node); + return true; + } else if (isSpeakingNode(node, speakingNodeCache, visitedNodes)) { + logShouldFocusNode( + checkChildren, NONE, "Focus, is focusable and has something to speak: ", node); + return true; + } else { + logShouldFocusNode( + checkChildren, + FOCUS_FAIL_NOT_SPEAKABLE, + "Don't focus, is focusable but has nothing to speak: ", + node); + return false; + } + } + } finally { + AccessibilityNodeInfoUtils.recycleNodes(visitedNodes); + } + + // At this point, the node is an unfocusable target. + // If it has no focusable ancestors, but it still has text, then it should receive focus and be + // read aloud. + Filter filter = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return shouldFocusNode(node, speakingNodeCache, false); + } + }; + + if (!hasMatchingAncestor(node, filter) && (hasText(node) || hasStateDescription(node))) { + logShouldFocusNode(checkChildren, NONE, "Focus, has text and no focusable ancestors: ", node); + return true; + } + + logShouldFocusNode( + checkChildren, + FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS, + "Don't focus, failed all focusability tests: ", + node); + return false; + } + + private static void logShouldFocusNode( + boolean checkChildren, + @DiagnosticType @Nullable Integer diagnosticType, + String message, + AccessibilityNodeInfoCompat node) { + // When shouldFocusNode calls itself, the logs get inundated by unnecessary info about the + // ancestors. So only log when checkChildren is true. + if (checkChildren) { + if (diagnosticType != NONE) { + DiagnosticOverlayUtils.appendLog(diagnosticType, node); + } + // Show debug logs for #shouldFocusNode. Verbose logs will show for #isSpeakingNode + LogUtils.v(TAG, "%s %s", message, node); + } + } + + public static boolean isPictureInPicture(AccessibilityNodeInfoCompat node) { + return isPictureInPicture(node.unwrap()); + } + + public static boolean isPictureInPicture(AccessibilityNodeInfo node) { + return node != null && AccessibilityWindowInfoUtils.isPictureInPicture(getWindow(node)); + } + + /** + * Returns the node that should receive focus from hover by starting from the touched node and + * calling {@link #shouldFocusNode} at each level of the view hierarchy and exclude WebView + * container node. + */ + public static AccessibilityNodeInfoCompat findFocusFromHover( + AccessibilityNodeInfoCompat touched) { + return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor( + touched, FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW); + } + + /** + * Returns whether a node can be spoken. + * + *

A node should be spoken if it has text, is checkable, or has children that should be spoken + * but can't be focused themselves. This method can call itself recursively through {@link + * #hasNonActionableSpeakingChildren}. + * + *

Note: This is called in the context of looking for a a11y focusable node through {@link + * #shouldFocusNode} and {@link #isAccessibilityFocusable} + * + * @param node the node to check + * @param speakingNodeCache the cache that holds the speaking results for visited nodes + * @param visitedNodes the set of nodes that have already been visited + * @return {@code true} if the node can be spoken + */ + private static boolean isSpeakingNode( + AccessibilityNodeInfoCompat node, + Map speakingNodeCache, + Set visitedNodes) { + if (speakingNodeCache != null && speakingNodeCache.containsKey(node)) { + return speakingNodeCache.get(node); + } + + boolean result = false; + if (hasText(node)) { + LogUtils.v(TAG, "Speaking, has text"); + result = true; + } else if (hasStateDescription(node)) { + LogUtils.v(TAG, "Speaking, has state description"); + result = true; + } else if (node.isCheckable()) { // Special case for check boxes. + LogUtils.v(TAG, "Speaking, is checkable"); + result = true; + } else if (hasNonActionableSpeakingChildren(node, speakingNodeCache, visitedNodes)) { + // Special case for containers with non-focusable content. In this case, the container should + // speak its non-focusable yet speakable content. + LogUtils.v(TAG, "Speaking, has non-actionable speaking children"); + result = true; + } + + if (speakingNodeCache != null) { + speakingNodeCache.put(node, result); + } + + return result; + } + + /** + * Returns whether a node has children that are not actionable/focusable but should be spoken. + * + *

This is done by ignoring any children nodes that are actionable/focusable, and checking the + * remaining for speaking ability. + * + * @param node the node to check + * @param speakingNodeCache the cache that holds the speaking results for visited nodes + * @param visitedNodes the set of nodes that have already been visited. Caller must recycle. + * @return {@code true} if the node has children that are speaking + */ + private static boolean hasNonActionableSpeakingChildren( + AccessibilityNodeInfoCompat node, + Map speakingNodeCache, + Set visitedNodes) { + final int childCount = node.getChildCount(); + + AccessibilityNodeInfoCompat child; + + for (int i = 0; i < childCount; i++) { + child = node.getChild(i); + + if (child == null) { + LogUtils.v(TAG, "Child %d is null, skipping it", i); + continue; + } + + if (!visitedNodes.add(child)) { + child.recycle(); + return false; + } + + // Ignore invisible nodes. + if (!isVisible(child)) { + LogUtils.v(TAG, "Child %d, %s is invisible, skipping it", i, printId(node)); + continue; + } + + // Ignore focusable nodes + if (isFocusableOrClickable(child)) { + LogUtils.v(TAG, "Child %d, %s is focusable or clickable, skipping it", i, printId(node)); + continue; + } + + // Ignore top level scroll items that 1) are speaking and 2) have non-clickable parents. This + // means that a scrollable container that is clickable should get focus before its children. + if ((isTopLevelScrollItem(child) && isSpeakingNode(child, speakingNodeCache, visitedNodes)) + && !(isClickable(node) || isLongClickable(node))) { + + LogUtils.v(TAG, "Child %d, %s is a top level scroll item, skipping it", i, printId(node)); + continue; + } + + // Recursively check non-focusable child nodes. + if (isSpeakingNode(child, speakingNodeCache, visitedNodes)) { + LogUtils.v(TAG, "Does have actionable speaking children (child %d, %s)", i, printId(node)); + return true; + } + } + + LogUtils.v(TAG, "Does not have non-actionable speaking children"); + return false; + } + + private static boolean hasVisibleChildren(AccessibilityNodeInfoCompat node) { + int childCount = node.getChildCount(); + for (int i = 0; i < childCount; ++i) { + AccessibilityNodeInfoCompat child = node.getChild(i); + if (child != null) { + try { + if (child.isVisibleToUser()) { + return true; + } + } finally { + child.recycle(); + } + } + } + + return false; + } + + public static int countVisibleChildren(AccessibilityNodeInfoCompat node) { + if (node == null) { + return 0; + } + int childCount = node.getChildCount(); + int childVisibleCount = 0; + for (int i = 0; i < childCount; ++i) { + AccessibilityNodeInfoCompat child = node.getChild(i); + if (child != null) { + try { + if (child.isVisibleToUser()) { + ++childVisibleCount; + } + } finally { + child.recycle(); + } + } + } + return childVisibleCount; + } + + /** + * Returns whether a node is actionable. That is, the node supports one of the following actions: + * + *

    + *
  • {@link AccessibilityNodeInfoCompat#isClickable()} + *
  • {@link AccessibilityNodeInfoCompat#isFocusable()} + *
  • {@link AccessibilityNodeInfoCompat#isLongClickable()} + *
+ * + * This parities the system method View#isActionableForAccessibility(), which was added in + * JellyBean. + * + * @param node The node to examine. + * @return {@code true} if node is actionable. + */ + public static boolean isActionableForAccessibility(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + + // Nodes that are clickable are always actionable. + if (isClickable(node) || isLongClickable(node)) { + return true; + } + + if (node.isFocusable()) { + return true; + } + + if (WebInterfaceUtils.hasNativeWebContent(node)) { + return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS); + } + + return supportsAnyAction( + node, + AccessibilityNodeInfoCompat.ACTION_FOCUS, + AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT, + AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT); + } + + public static boolean isSelfOrAncestorFocused(AccessibilityNodeInfoCompat node) { + return node != null + && (node.isAccessibilityFocused() + || hasMatchingAncestor( + node, + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return (node != null) && node.isAccessibilityFocused(); + } + })); + } + + /** + * Returns whether a node is clickable. That is, the node supports at least one of the following: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#isClickable()} + *
  • {@link AccessibilityNodeInfoCompat#ACTION_CLICK} + *
+ * + * @param node The node to examine. + * @return {@code true} if node is clickable. + */ + public static boolean isClickable(AccessibilityNodeInfoCompat node) { + return node != null + && (node.isClickable() + || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_CLICK)); + } + + /** + * Returns whether a node is long clickable. That is, the node supports at least one of the + * following: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#isLongClickable()} + *
  • {@link AccessibilityNodeInfoCompat#ACTION_LONG_CLICK} + *
+ * + * @param node The node to examine. + * @return {@code true} if node is long clickable. + */ + public static boolean isLongClickable(AccessibilityNodeInfoCompat node) { + return node != null + && (node.isLongClickable() + || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_LONG_CLICK)); + } + + /** + * Returns whether the node is focusable. That is, the node supports at least one of the + * following: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#isFocusable()} + *
  • {@link AccessibilityNodeInfoCompat#ACTION_FOCUS} + *
+ */ + public static boolean isFocusable(@Nullable AccessibilityNodeInfoCompat node) { + return node != null + && (node.isFocusable() + || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS)); + } + + /** + * Returns whether a node is expandable. That is, the node supports the following action: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#ACTION_EXPAND} + *
+ * + * @param node The node to examine. + * @return {@code true} if node is expandable. + */ + public static boolean isExpandable(AccessibilityNodeInfoCompat node) { + return node != null && supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_EXPAND); + } + + /** + * Returns whether a node is collapsible. That is, the node supports the following action: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#ACTION_COLLAPSE} + *
+ * + * @param node The node to examine. + * @return {@code true} if node is collapsible. + */ + public static boolean isCollapsible(AccessibilityNodeInfoCompat node) { + return node != null && supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_COLLAPSE); + } + + /** + * Returns whether a node can be dismissed by the user. the node supports the following action: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#ACTION_DISMISS} + *
+ * + * @param node The node to examine. + * @return {@code true} if node is dismissible. + */ + public static boolean isDismissible(AccessibilityNodeInfoCompat node) { + return node != null && supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_DISMISS); + } + + /** Caller retains ownership of source node. */ + public static boolean isKeyboard(AccessibilityEvent event, AccessibilityNodeInfoCompat source) { + return isKeyboard(event, source.unwrap()); + } + + /** Caller retains ownership of source node. */ + public static boolean isKeyboard(AccessibilityEvent event, AccessibilityNodeInfo source) { + + if (source == null) { + return false; + } + AccessibilityWindowInfo window = getWindow(source); + if (window == null) { + return false; + } + boolean isKeyboard = (window.getType() == AccessibilityWindowInfoCompat.TYPE_INPUT_METHOD); + window.recycle(); + return isKeyboard; + } + + /** + * Check whether a given node has a scrollable ancestor. + * + * @param node The node to examine. + * @return {@code true} if one of the node's ancestors is scrollable. + */ + public static boolean hasMatchingAncestor( + @Nullable AccessibilityNodeInfoCompat node, Filter filter) { + if (node == null) { + return false; + } + + final AccessibilityNodeInfoCompat result = getMatchingAncestor(node, filter); + if (result == null) { + return false; + } + + result.recycle(); + return true; + } + + /** + * Check whether a given node is a key from the Pin Password keyboard used to unlock the screen. + * + * @param node The node to examine. + * @return {@code true} if the node is a key from the Pin Password keyboard used to unlock the + * screen. + */ + // TODO: Find a better way to identify that its a key for PIN password. + public static boolean isPinKey(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + + String viewIdResourceName = node.getViewIdResourceName(); + return !TextUtils.isEmpty(viewIdResourceName) + && PIN_KEY_PATTERN.matcher(viewIdResourceName).matches(); + } + + /** Returns whether the node is the Pin edit field at unlock screen. */ + public static boolean isPinEntry(AccessibilityNodeInfo node) { + return (node != null) && VIEW_ID_RESOURCE_NAME_PIN_ENTRY.equals(node.getViewIdResourceName()); + } + + /** + * Check whether a given node or any of its ancestors matches the given filter. + * + * @param node The node to examine. + * @param filter The filter to match the nodes against. + * @return {@code true} if the node or one of its ancestors matches the filter. + */ + public static boolean isOrHasMatchingAncestor( + AccessibilityNodeInfoCompat node, Filter filter) { + if (node == null) { + return false; + } + + final AccessibilityNodeInfoCompat result = getSelfOrMatchingAncestor(node, filter); + if (result == null) { + return false; + } + + result.recycle(); + return true; + } + + /** Check whether a given node has any descendant matching a given filter. */ + public static boolean hasMatchingDescendant( + AccessibilityNodeInfoCompat node, Filter filter) { + if (node == null) { + return false; + } + + final AccessibilityNodeInfoCompat result = getMatchingDescendant(node, filter); + if (result == null) { + return false; + } + + result.recycle(); + return true; + } + + /** Returns depth of node in node-tree, where root has depth=0. */ + public static int findDepth(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return -1; + } + NodeCounter counter = new NodeCounter(); + processSelfAndAncestors(node, counter); + return counter.count - 1; + } + + private static class NodeCounter extends Filter { + public int count = 0; + + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + ++count; + return false; + } + } + + /** Applies filter to ancestor nodes. */ + public static void processSelfAndAncestors( + @Nullable AccessibilityNodeInfoCompat node, Filter filter) { + if (node != null) { + isOrHasMatchingAncestor(node, filter); + } + } + + /** + * Returns the {@code node} if it matches the {@code filter}, or the first matching ancestor. + * Returns {@code null} if no nodes match. + */ + public static @Nullable AccessibilityNodeInfoCompat getSelfOrMatchingAncestor( + AccessibilityNodeInfoCompat node, Filter filter) { + if (node == null) { + return null; + } + + if (filter.accept(node)) { + return AccessibilityNodeInfoCompat.obtain(node); + } + + return getMatchingAncestor(node, filter); + } + + /** + * Returns the {@code node} if it matches the {@code filter}, or the first matching ancestor, + * ending the ancestor search once it reaches {@code end}. The search is inclusive of {@code node} + * but exclusive of {@code end}. If {@code node} equals {@code end}, then {@code node} is an + * eligible match. Returns {@code null} if no nodes match. + */ + public static @Nullable AccessibilityNodeInfoCompat getSelfOrMatchingAncestor( + AccessibilityNodeInfoCompat node, + AccessibilityNodeInfoCompat end, + Filter filter) { + if (node == null) { + return null; + } + + if (filter.accept(node)) { + return AccessibilityNodeInfoCompat.obtain(node); + } + + return getMatchingAncestor(node, end, filter); + } + + /** + * Returns the {@code node} if it matches the {@code filter}, or the first matching descendant. + * Returns {@code null} if no nodes match. + */ + public static @Nullable AccessibilityNodeInfoCompat getSelfOrMatchingDescendant( + AccessibilityNodeInfoCompat node, Filter filter) { + if (node == null) { + return null; + } + + if (filter.accept(node)) { + return AccessibilityNodeInfoCompat.obtain(node); + } + + return getMatchingDescendant(node, filter); + } + + /** Processes subtree of root by {@code filter}. */ + public static void processSubtree( + AccessibilityNodeInfoCompat root, Filter filter) { + + AccessibilityNodeInfoUtils.getSelfOrMatchingDescendant( + root, + new Filter.NodeCompat( + (node) -> { + filter.accept(node); + return false; // Force search to traverse whole subtree. + })); + } + + /** + * Determines whether the two nodes are in the same branch; that is, they are equal or one is the + * ancestor of the other. + */ + public static boolean areInSameBranch( + final @Nullable AccessibilityNodeInfoCompat node1, + final @Nullable AccessibilityNodeInfoCompat node2) { + if (node1 != null && node2 != null) { + // Same node? + if (node1.equals(node2)) { + return true; + } + + // Is node1 an ancestor of node2? + Filter matchNode1 = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return node != null && node.equals(node1); + } + }; + if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node2, matchNode1)) { + return true; + } + + // Is node2 an ancestor of node1? + Filter matchNode2 = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return node != null && node.equals(node2); + } + }; + if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node1, matchNode2)) { + return true; + } + } + + return false; + } + + /** + * Returns the first ancestor of {@code node} that matches the {@code filter}. Returns {@code + * null} if no nodes match. + */ + public static @Nullable AccessibilityNodeInfoCompat getMatchingAncestor( + AccessibilityNodeInfoCompat node, Filter filter) { + return getMatchingAncestor(node, null, filter); + } + + /** + * Returns the first ancestor of {@code node} that matches the {@code filter}, terminating the + * search once it reaches {@code end}. The search is exclusive of both {@code node} and {@code + * end}. Returns {@code null} if no nodes match. + * + *

Note: Caller is responsible for recycling the returned node. + */ + private static @Nullable AccessibilityNodeInfoCompat getMatchingAncestor( + AccessibilityNodeInfoCompat node, + AccessibilityNodeInfoCompat end, + Filter filter) { + if (node == null) { + return null; + } + + final HashSet ancestors = new HashSet<>(); + + try { + ancestors.add(AccessibilityNodeInfoCompat.obtain(node)); + node = node.getParent(); + + while (node != null) { + if (!ancestors.add(node)) { + // Already seen this node, so abort! + + // Return null if node is same object with element inside ancestors. This will skip to + // recyce node to avoid crash. + for (AccessibilityNodeInfoCompat element : ancestors) { + if (node == element) { + return null; + } + } + + node.recycle(); + return null; + } + + if (end != null && node.equals(end)) { + // Reached the end node, so abort! + // Don't recycle the node here, it was added to ancestors and will be recycled. + return null; + } + + if (filter.accept(node)) { + // Send a copy since node gets recycled. + return AccessibilityNodeInfoCompat.obtain(node); + } + + node = node.getParent(); + } + } finally { + recycleNodes(ancestors); + } + + return null; + } + + /** + * Returns the number of ancestors matching the given filter. Does not include the current node in + * the count, even if it matches the filter. If there is a cycle in the ancestor hierarchy, then + * this method will return 0. + */ + public static int countMatchingAncestors( + AccessibilityNodeInfoCompat node, Filter filter) { + if (node == null) { + return 0; + } + + final HashSet ancestors = new HashSet<>(); + int matchingAncestors = 0; + + try { + ancestors.add(AccessibilityNodeInfoCompat.obtain(node)); + node = node.getParent(); + + while (node != null) { + if (!ancestors.add(node)) { + // Already seen this node, so abort! + node.recycle(); + return 0; + } + + if (filter.accept(node)) { + matchingAncestors++; + } + + node = node.getParent(); + } + } finally { + recycleNodes(ancestors); + } + + return matchingAncestors; + } + + /** + * Returns the first child (by depth-first search) of {@code node} that matches the {@code + * filter}. Returns {@code null} if no nodes match. The caller is responsible for recycling all + * nodes in {@code visitedNodes} and the node returned by this method, if non-{@code null}. + */ + private static @Nullable AccessibilityNodeInfoCompat getMatchingDescendant( + AccessibilityNodeInfoCompat node, + Filter filter, + HashSet visitedNodes) { + if (node == null) { + return null; + } + + if (visitedNodes.contains(node)) { + return null; + } else { + visitedNodes.add(AccessibilityNodeInfoCompat.obtain(node)); + } + + int childCount = node.getChildCount(); + for (int i = 0; i < childCount; ++i) { + AccessibilityNodeInfoCompat child = node.getChild(i); + + if (child == null) { + continue; + } + + if (filter.accept(child)) { + return child; // child was already obtained by node.getChild(). + } + + try { + AccessibilityNodeInfoCompat childMatch = getMatchingDescendant(child, filter, visitedNodes); + if (childMatch != null) { + return childMatch; + } + } finally { + child.recycle(); + } + } + + return null; + } + + public static @Nullable AccessibilityNodeInfoCompat getMatchingDescendant( + AccessibilityNodeInfoCompat node, Filter filter) { + final HashSet visitedNodes = new HashSet<>(); + try { + return getMatchingDescendant(node, filter, visitedNodes); + } finally { + recycleNodes(visitedNodes); + } + } + + /** Returns all descendants that match filter. Caller must recycle returned nodes. */ + public static @Nullable List getMatchingDescendantsOrRoot( + @Nullable AccessibilityNodeInfoCompat node, Filter filter) { + if (node == null) { + return null; + } + HashSet visitedNodes = new HashSet<>(); + List matches = new ArrayList<>(); + try { + getMatchingDescendants(node, filter, /* matchRoot= */ true, visitedNodes, matches); + return matches; + } finally { + recycleNodes(visitedNodes); + } + } + + /** + * Collects all descendants that match filter, into matches. + * + * @param node The root node to start searching. + * @param filter The filter to match the nodes against. + * @param matchRoot Flag that allows match with root node. + * @param visitedNodes The set of nodes already visited, for protection against loops. This will + * be modified. Caller is responsible to recycle the nodes. + * @param matches The list of nodes matching filter. This will be appended to. Caller is + * responsible to recycle this. + */ + private static void getMatchingDescendants( + @Nullable AccessibilityNodeInfoCompat node, + Filter filter, + boolean matchRoot, + Set visitedNodes, + List matches) { + + if (node == null) { + return; + } + + // Update visited nodes. + if (visitedNodes.contains(node)) { + return; + } else { + visitedNodes.add(AccessibilityNodeInfoCompat.obtain(node)); // Caller must recycle + } + + // If node matches filter... collect node. + if (matchRoot && filter.accept(node)) { + matches.add(AccessibilityNodeInfoCompat.obtain(node)); // Caller must recycle + } + + // For each child of node... + int childCount = node.getChildCount(); + for (int i = 0; i < childCount; ++i) { + AccessibilityNodeInfoCompat child = node.getChild(i); // Must recycle + if (child == null) { + continue; + } + try { + // Recurse on child. + getMatchingDescendants(child, filter, /* matchRoot= */ true, visitedNodes, matches); + } finally { + child.recycle(); + } + } + } + + /** + * Check whether a given node is scrollable. + * + * @param node The node to examine. + * @return {@code true} if the node is scrollable. + */ + public static boolean isScrollable(AccessibilityNodeInfoCompat node) { + // In some cases node#isScrollable lies. (Notably, some nodes that correspond to WebViews claim + // to be scrollable, but do not support any scroll actions. This seems to stem from a bug in the + // translation from the DOM to the AccessibilityNodeInfo.) To avoid labeling views that don't + // support scrolling (e.g. REFERTO), check for the explicit presence of + // AccessibilityActions. + if (BuildVersionUtils.isM() || BuildVersionUtils.isAtLeastN()) { + return supportsAnyAction( + node, + AccessibilityAction.ACTION_SCROLL_FORWARD, + AccessibilityAction.ACTION_SCROLL_BACKWARD, + AccessibilityAction.ACTION_SCROLL_DOWN, + AccessibilityAction.ACTION_SCROLL_UP, + AccessibilityAction.ACTION_SCROLL_RIGHT, + AccessibilityAction.ACTION_SCROLL_LEFT); + } else { + // Directional scrolling is not available pre-M. + return supportsAnyAction( + node, + AccessibilityAction.ACTION_SCROLL_FORWARD, + AccessibilityAction.ACTION_SCROLL_BACKWARD); + } + } + + /** + * Returns whether the specified node has text. For the purposes of this check, any node with a + * CollectionInfo is considered to not have text since its text and content description are used + * only for collection transitions. + * + * @param node The node to check. + * @return {@code true} if the node has text. + */ + private static boolean hasText(AccessibilityNodeInfoCompat node) { + return node != null + && node.getCollectionInfo() == null + && (!TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node)) + || !TextUtils.isEmpty(node.getContentDescription()) + || !TextUtils.isEmpty(node.getHintText())); + } + + /** + * Returns whether the specified node has state description. + * + * @param node The node to check. + * @return {@code true} if the node has state description. + */ + private static boolean hasStateDescription(@Nullable AccessibilityNodeInfoCompat node) { + return node != null + && (!TextUtils.isEmpty(node.getStateDescription()) + || node.isCheckable() + || hasValidRangeInfo(node)); + } + + /** + * Returns if a node is focusable or clickable. + * + *

This is used in {@link #shouldFocusNode} and {@link #isAccessibilityFocusable} + * + * @param node the node to check + * @return {@code true} if the node is focusable or clickable + */ + private static boolean isFocusableOrClickable(AccessibilityNodeInfoCompat node) { + return (node != null) + && isVisible(node) + && (node.isScreenReaderFocusable() || isActionableForAccessibility(node)); + } + + /** + * Determines whether a node is a top-level item in a scrollable container. + * + * @param node The node to test. + * @return {@code true} if {@code node} is a top-level item in a scrollable container. + */ + public static boolean isTopLevelScrollItem(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + + if (!isVisible(node)) { + return false; + } + + AccessibilityNodeInfoCompat parent = null; + AccessibilityNodeInfoCompat grandparent = null; + + try { + parent = node.getParent(); + if (parent == null) { + // Not a child node of anything. + return false; + } + + // Certain scrollable views in M's Android TV SetupWraith are permanently broken and + // won't ever be fixed because the setup wizard is bundled. This affects <= M only. + if (!BuildVersionUtils.isAtLeastN() && FILTER_BROKEN_LISTS_TV_M.accept(parent)) { + return false; + } + + // Drop down lists (spinners) are not included to retain the old behavior of focusing on + // the spinner itself rather than on the single visible item. + // A spinner being scrollable is disingenuous since the scrollable list inside isn't exposed + // without interaction. + // TODO: Remove this check? + if (Role.getRole(parent) == Role.ROLE_DROP_DOWN_LIST) { + return false; + } + + // A node with a scrollable parent is a top level scroll item. + if (isScrollable(parent)) { + return true; + } + + @Role.RoleName int parentRole = Role.getRole(parent); + // Note that ROLE_DROP_DOWN_LIST(Spinner) is not accepted. + // RecyclerView is classified as a list or grid based on its CollectionInfo. + // These parents may not be scrollable in some cases, like if the list is too short to be + // scrolled, but their children should still be considered top level scroll items. + return parentRole == Role.ROLE_LIST + || parentRole == Role.ROLE_GRID + || parentRole == Role.ROLE_SCROLL_VIEW + || parentRole == Role.ROLE_HORIZONTAL_SCROLL_VIEW + || nodeMatchesAnyClassByType(parent, CLASS_TOUCHWIZ_TWADAPTERVIEW); + } finally { + recycleNodes(parent, grandparent); + } + } + + public static boolean hasAncestor( + AccessibilityNodeInfoCompat node, final AccessibilityNodeInfoCompat targetAncestor) { + if (node == null || targetAncestor == null) { + return false; + } + + Filter filter = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return targetAncestor.equals(node); + } + }; + + AccessibilityNodeInfoCompat foundAncestor = getMatchingAncestor(node, filter); + if (foundAncestor != null) { + foundAncestor.recycle(); + return true; + } + + return false; + } + + public static boolean hasDescendant( + AccessibilityNodeInfoCompat node, final AccessibilityNodeInfoCompat targetDescendant) { + if (node == null || targetDescendant == null) { + return false; + } + + Filter filter = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return targetDescendant.equals(node); + } + }; + + AccessibilityNodeInfoCompat foundAncestor = getMatchingDescendant(node, filter); + if (foundAncestor != null) { + foundAncestor.recycle(); + return true; + } + + return false; + } + + /** + * Determines if the generating class of an {@link AccessibilityNodeInfoCompat} matches a given + * {@link Class} by type. + * + * @param node A sealed {@link AccessibilityNodeInfoCompat} dispatched by the accessibility + * framework. + * @param referenceClass A {@link Class} to match by type or inherited type. + * @return {@code true} if the {@link AccessibilityNodeInfoCompat} object matches the {@link + * Class} by type or inherited type, {@code false} otherwise. + */ + public static boolean nodeMatchesClassByType( + AccessibilityNodeInfoCompat node, Class referenceClass) { + if ((node == null) || (referenceClass == null)) { + return false; + } + + // Attempt to take a shortcut. + final CharSequence nodeClassName = node.getClassName(); + if (TextUtils.equals(nodeClassName, referenceClass.getName())) { + return true; + } + + return ClassLoadingCache.checkInstanceOf(nodeClassName, referenceClass); + } + + /** + * Determines if the generating class of an {@link AccessibilityNodeInfoCompat} matches any of the + * given {@link Class}es by type. + * + * @param node A sealed {@link AccessibilityNodeInfoCompat} dispatched by the accessibility + * framework. + * @return {@code true} if the {@link AccessibilityNodeInfoCompat} object matches the {@link + * Class} by type or inherited type, {@code false} otherwise. + * @param referenceClasses A variable-length list of {@link Class} objects to match by type or + * inherited type. + */ + public static boolean nodeMatchesAnyClassByType( + AccessibilityNodeInfoCompat node, Class... referenceClasses) { + if (node == null) { + return false; + } + + for (Class referenceClass : referenceClasses) { + if (ClassLoadingCache.checkInstanceOf(node.getClassName(), referenceClass)) { + return true; + } + } + + return false; + } + + /** + * Determines if the class of an {@link AccessibilityNodeInfoCompat} matches a given {@link Class} + * by package and name. + * + * @param node A sealed {@link AccessibilityNodeInfoCompat} dispatched by the accessibility + * framework. + * @param referenceClassName A class name to match. + * @return {@code true} if the {@link AccessibilityNodeInfoCompat} matches the class name. + */ + public static boolean nodeMatchesClassByName( + AccessibilityNodeInfoCompat node, CharSequence referenceClassName) { + return node != null + && ClassLoadingCache.checkInstanceOf(node.getClassName(), referenceClassName); + } + + /** + * Recycles the given nodes. + * + * @param nodes The nodes to recycle. + */ + public static void recycleNodes(Collection nodes) { + if (nodes == null) { + return; + } + + for (AccessibilityNodeInfoCompat node : nodes) { + if (node != null) { + node.recycle(); + } + } + + nodes.clear(); + } + + /** + * Recycles the given nodes. + * + * @param nodes The nodes to recycle. + */ + public static void recycleNodes(@Nullable AccessibilityNodeInfo... nodes) { + if (nodes == null) { + return; + } + + for (AccessibilityNodeInfo node : nodes) { + if (node != null) { + node.recycle(); + } + } + } + + /** + * Recycles the given nodes. + * + * @param nodes The nodes to recycle. + */ + public static void recycleNodes(@Nullable AccessibilityNodeInfoCompat... nodes) { + if (nodes == null) { + return; + } + + for (AccessibilityNodeInfoCompat node : nodes) { + if (node != null) { + node.recycle(); + } + } + } + + /** + * Returns {@code true} if the node supports at least one of the specified actions. This method + * supports actions introduced in API level 21 and later. However, it does not support bitmasks. + * + * @param node The node to check + * @param actions The actions to check + * @return {@code true} if at least one action is supported + */ + // TODO: Use A11yActionCompat once AccessibilityActionCompat#equals is overridden + public static boolean supportsAnyAction( + AccessibilityNodeInfoCompat node, AccessibilityAction... actions) { + if (node == null) { + return false; + } + // Unwrap the node and compare AccessibilityActions because AccessibilityActions, unlike + // AccessibilityActionCompats, are static (so checks for equality work correctly). + final List supportedActions = node.unwrap().getActionList(); + + for (AccessibilityAction action : actions) { + if (supportedActions.contains(action)) { + return true; + } + } + + return false; + } + + /** + * Returns {@code true} if the node supports at least one of the specified actions. To check + * whether a node supports multiple actions, combine them using the {@code |} (logical OR) + * operator. + * + *

Note: this method will check against the getActions() method of AccessibilityNodeInfo, which + * will not contain information for actions introduced in API level 21 or later. + * + * @param node The node to check. + * @param actions The actions to check. + * @return {@code true} if at least one action is supported. + */ + // TODO: Remove this method once AccessibilityActionCompat#equals is overridden + public static boolean supportsAnyAction(AccessibilityNodeInfoCompat node, int... actions) { + if (node != null) { + final int supportedActions = node.getActions(); + + for (int action : actions) { + if ((supportedActions & action) == action) { + return true; + } + } + } + + return false; + } + + /** + * Returns {@code true} if the node supports the specified action. This method supports actions + * introduced in API level 21 and later. However, it does not support bitmasks. + */ + public static boolean supportsAction(AccessibilityNodeInfoCompat node, int action) { + // New actions in >= API 21 won't appear in getActions() but in getActionList(). + // On Lollipop+ devices, pre-API 21 actions will also appear in getActionList(). + List actions = node.getActionList(); + int size = actions.size(); + for (int i = 0; i < size; ++i) { + AccessibilityActionCompat actionCompat = actions.get(i); + if (actionCompat.getId() == action) { + return true; + } + } + return false; + } + + /** + * Returns the result of applying a filter using breadth-first traversal. + * + * @param node The root node to traverse from. + * @param filter The filter to satisfy. + * @return The first node reached via BFS traversal that satisfies the filter. + */ + public static AccessibilityNodeInfoCompat searchFromBfs( + AccessibilityNodeInfoCompat node, Filter filter) { + return searchFromBfs(node, filter, /* filterToSkip= */ null); + } + + /** + * Returns the result of applying a filter using breadth-first traversal. It allows skip nodes to + * speed up the BFS traversal. + * + * @param node The root node to traverse from. + * @param filter The filter to satisfy. + * @param filterToSkip The filter for skipping nodes, all childs under the node will be skipped. + * @return The first node reached via BFS traversal that satisfies the filter. + */ + public static @Nullable AccessibilityNodeInfoCompat searchFromBfs( + AccessibilityNodeInfoCompat node, + Filter filter, + @Nullable Filter filterToSkip) { + if (node == null) { + return null; + } + + final ArrayDeque queue = new ArrayDeque<>(); + Set visitedNodes = new HashSet<>(); + + queue.add(AccessibilityNodeInfoCompat.obtain(node)); + + try { + while (!queue.isEmpty()) { + final AccessibilityNodeInfoCompat item = queue.removeFirst(); + visitedNodes.add(item); + + if (filterToSkip != null && filterToSkip.accept(item)) { + item.recycle(); + continue; + } + + if (filter.accept(item)) { + return item; + } + + final int childCount = item.getChildCount(); + + for (int i = 0; i < childCount; i++) { + final AccessibilityNodeInfoCompat child = item.getChild(i); + + if (child != null && !visitedNodes.contains(child)) { + queue.addLast(child); + } + } + item.recycle(); + } + } finally { + while (!queue.isEmpty()) { + queue.removeFirst().recycle(); + } + } + + return null; + } + + /** Safely obtains a copy of node. Caller must recycle returned node info. */ + public static @Nullable AccessibilityNodeInfoCompat obtain(AccessibilityNodeInfoCompat node) { + return (node == null) ? null : AccessibilityNodeInfoCompat.obtain(node); + } + + /** Safely obtains a copy of node. Caller must recycle returned node info. */ + public static @Nullable AccessibilityNodeInfo obtain(AccessibilityNodeInfo node) { + return (node == null) ? null : AccessibilityNodeInfo.obtain(node); + } + + /** + * Replaces a node with a refreshed node. + * + * @param node A source node which may be stale, and which will be recycled. + * @return A refreshed node, which the caller must recycle. + */ + public static AccessibilityNodeInfoCompat replaceWithFreshNode(AccessibilityNodeInfoCompat node) { + try { + return AccessibilityNodeInfoUtils.refreshNode(node); + } finally { + AccessibilityNodeInfoUtils.recycleNodes(node); + } + } + + /** + * Returns a fresh copy of {@code node} with properties that are less likely to be stale. Returns + * {@code null} if the node can't be found anymore. + */ + public static @Nullable AccessibilityNodeInfoCompat refreshNode( + AccessibilityNodeInfoCompat node) { + if (node == null) { + return null; + } + + AccessibilityNodeInfoCompat nodeCopy = AccessibilityNodeInfoCompat.obtain(node); + if (nodeCopy.refresh()) { + return nodeCopy; + } else { + nodeCopy.recycle(); + return null; + } + } + + /** + * Returns a fresh copy of node by traversing the given window for a similar node. For example, + * the node that you want might be in a popup window that has closed and re-opened, causing the + * accessibility IDs of its views to be different. Note: you must recycle the node that is + * returned from this method. + */ + public static @Nullable AccessibilityNodeInfoCompat refreshNodeFuzzy( + final AccessibilityNodeInfoCompat node, AccessibilityWindowInfo window) { + if (window == null || node == null) { + return null; + } + + AccessibilityNodeInfo root = AccessibilityWindowInfoUtils.getRoot(window); + if (root == null) { + return null; + } + + Filter similarFilter = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat other) { + return other != null + && TextUtils.equals( + AccessibilityNodeInfoUtils.getText(node), + AccessibilityNodeInfoUtils.getText(other)); + } + }; + + AccessibilityNodeInfoCompat rootCompat = AccessibilityNodeInfoUtils.toCompat(root); + try { + return getMatchingDescendant(rootCompat, similarFilter); + } finally { + rootCompat.recycle(); + } + } + + /** + * Gets the location of specific range of node text. It returns null if the node doesn't support + * text location data or the index is incorrect. + * + * @param node The node being queried. + * @param fromCharIndex start index of the queried text range. + * @param toCharIndex end index of the queried text range. + */ + @TargetApi(Build.VERSION_CODES.O) + public static @Nullable List getTextLocations( + AccessibilityNodeInfoCompat node, int fromCharIndex, int toCharIndex) { + return getTextLocations( + node, AccessibilityNodeInfoUtils.getText(node), fromCharIndex, toCharIndex); + } + + /** + * Gets the location of specific range of node {@code text}. It returns null if the node doesn't + * support text location data or the index is incorrect. + * + * @param node The node being queried. + * @param text The node's text. This is typically the text, but can also be the content + * description if the node was not properly created. If the content description is used, its + * text location will only be returned if it's visible on the screen. + * @param fromCharIndex start index of the queried text range. + * @param toCharIndex end index of the queried text range. + */ + @TargetApi(Build.VERSION_CODES.O) + public static @Nullable List getTextLocations( + AccessibilityNodeInfoCompat node, CharSequence text, int fromCharIndex, int toCharIndex) { + if (node == null || !BuildVersionUtils.isAtLeastO()) { + return null; + } + + if (fromCharIndex < 0 + || TextUtils.isEmpty(text) + || !PrimitiveUtils.isInInterval(toCharIndex, fromCharIndex, text.length(), true)) { + return null; + } + AccessibilityNodeInfo info = node.unwrap(); + if (info == null) { + return null; + } + Bundle args = new Bundle(); + args.putInt( + AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX, fromCharIndex); + args.putInt( + AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH, + toCharIndex - fromCharIndex); + if (!info.refreshWithExtraData( + AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY, args)) { + return null; + } + + Bundle extras = info.getExtras(); + Parcelable[] data = + extras.getParcelableArray(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY); + if (data == null) { + return null; + } + List result = new ArrayList<>(data.length); + for (Parcelable item : data) { + if (item == null) { + continue; + } + RectF rectF = (RectF) item; + result.add( + new Rect((int) rectF.left, (int) rectF.top, (int) rectF.right, (int) rectF.bottom)); + } + return result; + } + + /** Returns true if the node supports text location data. */ + @TargetApi(Build.VERSION_CODES.O) + public static boolean supportsTextLocation(AccessibilityNodeInfoCompat node) { + if (!BuildVersionUtils.isAtLeastO() || node == null) { + return false; + } + AccessibilityNodeInfo info = node.unwrap(); + if (info == null) { + return false; + } + List extraData = info.getAvailableExtraData(); + return extraData != null + && extraData.contains(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY); + } + + /** Helper method that returns {@code true} if the specified node is visible to the user */ + public static boolean isVisible(AccessibilityNodeInfoCompat node) { + // We need to move focus to invisible node in WebView to scroll it but we don't want to + // move focus if WebView itself is invisible. + return node != null + && (node.isVisibleToUser() + || (WebInterfaceUtils.isWebContainer(node) + && Role.getRole(node) != Role.ROLE_WEB_VIEW)); + } + + /** Determines whether the specified node has bounds identical to the bounds of its window. */ + private static boolean areBoundsIdenticalToWindow(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + + AccessibilityWindowInfoCompat window = getWindow(node); + if (window == null) { + return false; + } + + Rect windowBounds = new Rect(); + window.getBoundsInScreen(windowBounds); + + Rect nodeBounds = new Rect(); + node.getBoundsInScreen(nodeBounds); + + return windowBounds.equals(nodeBounds); + } + + /** + * Returns the node to which the given node's window is anchored, if there is an anchor. Note: you + * must recycle the node that is returned from this method. + */ + public static @Nullable AccessibilityNodeInfoCompat getAnchor( + @Nullable AccessibilityNodeInfoCompat node) { + if (!BuildVersionUtils.isAtLeastN()) { + return null; + } + + if (node == null) { + return null; + } + + AccessibilityNodeInfo nativeNode = node.unwrap(); + if (nativeNode == null) { + return null; + } + + AccessibilityWindowInfo nativeWindow = getWindow(nativeNode); + if (nativeWindow == null) { + return null; + } + + AccessibilityNodeInfo nativeAnchor = nativeWindow.getAnchor(); + if (nativeAnchor == null) { + return null; + } + + return AccessibilityNodeInfoUtils.toCompat(nativeAnchor); + } + + /** + * Analyses if the edit text has no text. + * + *

If there is a text field with hint text and no text, {@link + * AccessibilityNodeInfoUtils.getText()} returns hint text. Hence this method checks for {@link + * AccessibilityNodeInfo#ACTION_SET_SELECTION} to disregard the hint text. + */ + public static boolean isEmptyEditTextRegardlessOfHint( + @Nullable AccessibilityNodeInfoCompat node) { + if (node == null || !(node.isEditable())) { + return false; + } + + if (TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node))) { + return true; + } + return !supportsAction(node, AccessibilityNodeInfo.ACTION_SET_SELECTION); + } + + /** + * Gets a list of URLs contained within an {@link AccessibilityNodeInfoCompat}. + * + * @param node The node that will be searched for links + * @return A list of {@link SpannableUrl}s from the URLs found within the Node + */ + public static List getNodeUrls(AccessibilityNodeInfoCompat node) { + return getNodeClickableElements( + node, URLSpan.class, input -> SpannableUrl.create(input.first, (URLSpan) input.second)); + } + + /** + * Gets a list of ClickableSpans paired with the String they span within a node's text. + * + * @param node The node that will be searched for spans + * @return A list of Clickable elements found within the Node. + */ + public static List getNodeClickableStrings(AccessibilityNodeInfoCompat node) { + return getNodeClickableElements( + node, ClickableSpan.class, input -> ClickableString.create(input.first, input.second)); + } + + /** + * Gets a list of the clickable elements within a node. + * + * @param node The node to get the elements from + * @param clickableType What type of clickable thing to look for within the node + * @param clickableElementFn A function taking the visual string representation and the clickable + * portion of the clickable element that produces the desired format that will be displayable + * to the user + * @param The displayable format representation of the clickable element + * @return A list of clickable elements. Empty if there are none. + */ + private static List getNodeClickableElements( + AccessibilityNodeInfoCompat node, + Class clickableType, + Function, E> clickableElementFn) { + List spannableStrings = new ArrayList<>(); + SpannableTraversalUtils.collectSpannableStringsWithTargetSpanInNodeDescriptionTree( + node, // Root node of description tree + clickableType, // Target span class + spannableStrings // List to collect spannable strings + ); + + List clickables = new ArrayList<>(1); + for (SpannableString spannable : spannableStrings) { + for (ClickableSpan span : spannable.getSpans(0, spannable.length(), clickableType)) { + // Child classes may not use #getUrl, so just check that the class is a URLSpan, instead of + // a child class with "instanceof". + if ((span.getClass() == URLSpan.class) + && Strings.isNullOrEmpty(((URLSpan) span).getURL())) { + continue; + } + int start = spannable.getSpanStart(span); + int end = spannable.getSpanEnd(span); + if (end > start) { + char[] chars = new char[end - start]; + spannable.getChars(start, end, chars, 0); + clickables.add(clickableElementFn.apply(Pair.create(new String(chars), span))); + } + } + } + return clickables; + } + + public static int getMovementGranularity(AccessibilityNodeInfoCompat node) { + // Some nodes in Webview have movement granularities even its content description/text is + // empty. + if (WebInterfaceUtils.supportsWebActions(node) + && TextUtils.isEmpty(node.getContentDescription()) + && TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node))) { + return 0; + } + + return node.getMovementGranularities(); + } + + @TargetApi(Build.VERSION_CODES.O) + public static CharSequence getHintText(AccessibilityNodeInfoCompat node) { + CharSequence hintText = node.getHintText(); + if (TextUtils.isEmpty(hintText)) { + Bundle bundle = node.getExtras(); + if (bundle != null) { + // Hint text for WebView. + hintText = bundle.getCharSequence(HINT_TEXT_KEY); + } + } + + return hintText; + } + + /** + * To setup a hashmap for AccessibilityAction id and the display string. We only build into the + * hash map with identifiers which are supported in the running platform. + */ + private static HashMap initActionIds() { + HashMap actionIdHashMap = new HashMap<>(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + actionIdHashMap.put( + AccessibilityAction.ACTION_SHOW_ON_SCREEN.getId(), "ACTION_SHOW_ON_SCREEN"); + actionIdHashMap.put( + AccessibilityAction.ACTION_SCROLL_TO_POSITION.getId(), "ACTION_SCROLL_TO_POSITION"); + actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_UP.getId(), "ACTION_SCROLL_UP"); + actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_LEFT.getId(), "ACTION_SCROLL_LEFT"); + actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_DOWN.getId(), "ACTION_SCROLL_DOWN"); + actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_RIGHT.getId(), "ACTION_SCROLL_RIGHT"); + actionIdHashMap.put(AccessibilityAction.ACTION_CONTEXT_CLICK.getId(), "ACTION_CONTEXT_CLICK"); + } + if (BuildVersionUtils.isAtLeastN()) { + actionIdHashMap.put(AccessibilityAction.ACTION_SET_PROGRESS.getId(), "ACTION_SET_PROGRESS"); + } + if (BuildVersionUtils.isAtLeastO()) { + actionIdHashMap.put(AccessibilityAction.ACTION_MOVE_WINDOW.getId(), "ACTION_MOVE_WINDOW"); + } + if (BuildVersionUtils.isAtLeastP()) { + actionIdHashMap.put(AccessibilityAction.ACTION_SHOW_TOOLTIP.getId(), "ACTION_SHOW_TOOLTIP"); + actionIdHashMap.put(AccessibilityAction.ACTION_HIDE_TOOLTIP.getId(), "ACTION_HIDE_TOOLTIP"); + } + if (BuildVersionUtils.isAtLeastQ()) { + actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_RIGHT.getId(), "ACTION_PAGE_RIGHT"); + actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_LEFT.getId(), "ACTION_PAGE_LEFT"); + actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_DOWN.getId(), "ACTION_PAGE_DOWN"); + actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_UP.getId(), "ACTION_PAGE_UP"); + } + if (BuildVersionUtils.isAtLeastR()) { + actionIdHashMap.put( + AccessibilityAction.ACTION_PRESS_AND_HOLD.getId(), "ACTION_PRESS_AND_HOLD"); + actionIdHashMap.put(AccessibilityAction.ACTION_IME_ENTER.getId(), "ACTION_IME_ENTER"); + } + return actionIdHashMap; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Methods for displaying node data + + public static String actionToString(int action) { + switch (action) { + case AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS: + return "ACTION_ACCESSIBILITY_FOCUS"; + case AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS: + return "ACTION_CLEAR_ACCESSIBILITY_FOCUS"; + case AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS: + return "ACTION_CLEAR_FOCUS"; + case AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION: + return "ACTION_CLEAR_SELECTION"; + case AccessibilityNodeInfoCompat.ACTION_CLICK: + return "ACTION_CLICK"; + case AccessibilityNodeInfoCompat.ACTION_COLLAPSE: + return "ACTION_COLLAPSE"; + case AccessibilityNodeInfoCompat.ACTION_COPY: + return "ACTION_COPY"; + case AccessibilityNodeInfoCompat.ACTION_CUT: + return "ACTION_CUT"; + case AccessibilityNodeInfoCompat.ACTION_DISMISS: + return "ACTION_DISMISS"; + case AccessibilityNodeInfoCompat.ACTION_EXPAND: + return "ACTION_EXPAND"; + case AccessibilityNodeInfoCompat.ACTION_FOCUS: + return "ACTION_FOCUS"; + case AccessibilityNodeInfoCompat.ACTION_LONG_CLICK: + return "ACTION_LONG_CLICK"; + case AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: + return "ACTION_NEXT_AT_MOVEMENT_GRANULARITY"; + case AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT: + return "ACTION_NEXT_HTML_ELEMENT"; + case AccessibilityNodeInfoCompat.ACTION_PASTE: + return "ACTION_PASTE"; + case AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: + return "ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY"; + case AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT: + return "ACTION_PREVIOUS_HTML_ELEMENT"; + case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD: + return "ACTION_SCROLL_BACKWARD"; + case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD: + return "ACTION_SCROLL_FORWARD"; + case AccessibilityNodeInfoCompat.ACTION_SELECT: + return "ACTION_SELECT"; + case AccessibilityNodeInfoCompat.ACTION_SET_SELECTION: + return "ACTION_SET_SELECTION"; + case AccessibilityNodeInfoCompat.ACTION_SET_TEXT: + return "ACTION_SET_TEXT"; + default: + break; + } + @Nullable String actionName = actionIdToName.get(action); + return actionName == null ? "(unhandled action:" + action + ")" : actionName; + } + + /** Caller keeps ownership of node. */ + public static String toStringShort(@Nullable AccessibilityNodeInfo node) { + return toStringShort(toCompat(node)); + } + + /** Caller keeps ownership of node. */ + public static String toStringShort(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return "null"; + } + return StringBuilderUtils.joinFields( + "AccessibilityNodeInfoCompat", + StringBuilderUtils.optionalInt("id", node.hashCode(), -1), + StringBuilderUtils.optionalText("class", node.getClassName()), + StringBuilderUtils.optionalText("package", node.getPackageName()), + // TODO: Uses hash value in production build + StringBuilderUtils.optionalText( + "text", + (AccessibilityNodeInfoUtils.getText(node) == null) + ? null + : FeatureSupport.logcatIncludePsi() + // Logs for DEBUG build or user had opt-in + ? AccessibilityNodeInfoUtils.getText(node) + : "***"), + StringBuilderUtils.optionalText("description", node.getContentDescription()), + StringBuilderUtils.optionalText("viewIdResName", node.getViewIdResourceName()), + StringBuilderUtils.optionalText("hint", node.getHintText()), + StringBuilderUtils.optionalTag("enabled", node.isEnabled()), + StringBuilderUtils.optionalTag("checkable", node.isCheckable()), + StringBuilderUtils.optionalTag("checked", node.isChecked()), + StringBuilderUtils.optionalTag("accessibilityFocused", node.isAccessibilityFocused()), + StringBuilderUtils.optionalTag("focusable", isFocusable(node)), + StringBuilderUtils.optionalTag("screenReaderFocusable", node.isScreenReaderFocusable()), + StringBuilderUtils.optionalTag("focused", node.isFocused()), + StringBuilderUtils.optionalTag("selected", node.isSelected()), + StringBuilderUtils.optionalTag("clickable", isClickable(node)), + StringBuilderUtils.optionalTag("longClickable", isLongClickable(node)), + StringBuilderUtils.optionalTag("password", node.isPassword()), + StringBuilderUtils.optionalTag("textEntryKey", isTextEntryKey(node)), + StringBuilderUtils.optionalTag("scrollable", isScrollable(node)), + StringBuilderUtils.optionalTag( + "heading", FeatureSupport.isHeadingWorks() && node.isHeading()), + StringBuilderUtils.optionalTag("collapsible", isCollapsible(node)), + StringBuilderUtils.optionalTag("expandable", isExpandable(node)), + StringBuilderUtils.optionalTag("dismissable", isDismissible(node)), + StringBuilderUtils.optionalTag("pinKey", isPinKey(node)), + StringBuilderUtils.optionalTag("pinEntry", isPinEntry(node.unwrap())), + StringBuilderUtils.optionalTag("visible", node.isVisibleToUser())); + } + + /** Copied from AccessibilityNodeInfo.java */ + public static @Nullable String getMovementGranularitySymbolicName(int granularity) { + if (granularity == 0) { + return null; + } + switch (granularity) { + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER: + return "MOVEMENT_GRANULARITY_CHARACTER"; + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD: + return "MOVEMENT_GRANULARITY_WORD"; + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE: + return "MOVEMENT_GRANULARITY_LINE"; + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH: + return "MOVEMENT_GRANULARITY_PARAGRAPH"; + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE: + return "MOVEMENT_GRANULARITY_PAGE"; + default: + return Integer.toHexString(granularity); + } + } + + /** + * Given a double value, get the int percentage (0 to 100, both inclusive). Only return 0 or 100 + * when percentage is exactly 0 or 100 percent. + */ + public static int roundForProgressPercent(double percent) { + if (percent < 0.0f) { + return 0; + } else if (percent > 0.0f && percent < 1.0f) { + return 1; + } else if (percent > 99.0f && percent < 100.0f) { + return 99; + } else if (percent > 100.0f) { + return 100; + } + return (int) Math.round(percent); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Methods for node properties + + /** + * Returns {@code true} if the height and width of the {@link AccessibilityNodeInfoCompat}'s + * visible bounds on the screen are greater than a specified number of minimum pixels. This can be + * used to prune tiny elements or elements off the screen. + * + *

{@link AccessibilityNodeInfo#isVisibleToUser()} sometimes returns {@code true} for {@link + * android.webkit.WebView} items off the screen, so this method allows us to better ignore WebView + * content off the screen. + * + * @param node The node that will be checked for a minimum number of pixels on the screen + * @return {@code true} if the node has at least the number of minimum visible pixels in both + * width and height on the screen + */ + public static boolean hasMinimumPixelsVisibleOnScreen(AccessibilityNodeInfoCompat node) { + Rect visibleBounds = new Rect(); + node.getBoundsInScreen(visibleBounds); + return ((Math.abs(visibleBounds.height()) >= MIN_VISIBLE_PIXELS) + && (Math.abs(visibleBounds.width()) >= MIN_VISIBLE_PIXELS)); + } + + // TODO Remove them when androidx.core library is available. + /** + * Returns whether node represents a text entry key that is part of a keyboard or keypad. + * + * @param node The node being checked. + * @return {@code true} if the node is text entry key. library is available. + */ + public static boolean isTextEntryKey(AccessibilityNodeInfoCompat node) { + + return BuildVersionUtils.isAtLeastQ() + ? node.unwrap().isTextEntryKey() + : getBooleanProperty(node, BOOLEAN_MASK_IS_TEXT_ENTRY_KEY); + } + + /** + * @param node The node being checked. + * @param property the property sets in {@code node} + * @return true if set it successfully. + */ + private static boolean getBooleanProperty(AccessibilityNodeInfoCompat node, int property) { + Bundle extras = node.getExtras(); + if (extras == null) { + return false; + } else { + return (extras.getInt(BOOLEAN_PROPERTY_KEY, 0) & property) == property; + } + } + + // TODO Remove them when androidx.core library is available. + /** + * Sets whether the node represents a text entry key that is part of a keyboard or keypad. We add + * this method because {@code androidx.core.view.accessibility} is not available in g3.It is only + * for testing. + * + *

Note: Cannot be called from an {@link + * android.accessibilityservice.AccessibilityService}. This class is made immutable before being + * delivered to an AccessibilityService. + * + * @param node The node being checked. + * @param isTextEntryKey {@code true} if the node is a text entry key, {@code false} otherwise. + */ + public static void setTextEntryKey(AccessibilityNodeInfoCompat node, boolean isTextEntryKey) { + if (BuildVersionUtils.isAtLeastQ()) { + node.unwrap().setTextEntryKey(isTextEntryKey); + } else { + setBooleanProperty(node, BOOLEAN_MASK_IS_TEXT_ENTRY_KEY, isTextEntryKey); + } + } + + private static void setBooleanProperty( + AccessibilityNodeInfoCompat node, int property, boolean value) { + Bundle extras = node.getExtras(); + if (extras != null) { + int booleanProperties = extras.getInt(BOOLEAN_PROPERTY_KEY, 0); + booleanProperties &= ~property; + booleanProperties |= value ? property : 0; + extras.putInt(BOOLEAN_PROPERTY_KEY, booleanProperties); + } + } + + /** + * Returns the progress percentage from the node. The value will be in the range [0, 100]. + * + * @param node The node from which to obtain the progress percentage. + * @return The progress percentage. + */ + public static float getProgressPercent(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return 0.0f; + } + + final @Nullable RangeInfoCompat rangeInfo = node.getRangeInfo(); + if (rangeInfo == null) { + return 0.0f; + } + + final float maxProgress = rangeInfo.getMax(); + final float minProgress = rangeInfo.getMin(); + final float currentProgress = rangeInfo.getCurrent(); + final float diffProgress = maxProgress - minProgress; + if (diffProgress <= 0.0f) { + logError("getProgressPercent", "Range is invalid. [%f, %f]", minProgress, maxProgress); + return 0.0f; + } + + if (currentProgress < minProgress) { + logError( + "getProgressPercent", + "Current percent is out of range. Current: %f Range: [%f, %f]", + currentProgress, + minProgress, + maxProgress); + return 0.0f; + } + + if (currentProgress > maxProgress) { + logError( + "getProgressPercent", + "Current percent is out of range. Current: %f Range: [%f, %f]", + currentProgress, + minProgress, + maxProgress); + return 100.0f; + } + + final float percent = (currentProgress - minProgress) / diffProgress; + return (100.0f * Math.max(0.0f, Math.min(1.0f, percent))); + } + + /** + * Returns whether the node has valid RangeInfo. + * + * @param node The node to check. + * @return Whether the node has valid RangeInfo. + */ + public static boolean hasValidRangeInfo(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + + final @Nullable RangeInfoCompat rangeInfo = node.getRangeInfo(); + if (rangeInfo == null) { + return false; + } + + final float maxProgress = rangeInfo.getMax(); + final float minProgress = rangeInfo.getMin(); + final float currentProgress = rangeInfo.getCurrent(); + final float diffProgress = maxProgress - minProgress; + return (diffProgress > 0.0f) + && (currentProgress >= minProgress) + && (currentProgress <= maxProgress); + } + + /** Checks whether the given node is still in the window. */ + public static boolean isInWindow( + AccessibilityNodeInfoCompat checkingNode, + @Nullable AccessibilityWindowInfoCompat windowInfoCompat) { + if (windowInfoCompat == null) { + return false; + } + + AccessibilityNodeInfoCompat root = windowInfoCompat.getRoot(); + try { + return hasDescendant(root, checkingNode); + } finally { + recycleNodes(root); + } + } + + /** Checks whether the given node is still in the window. */ + public static boolean isInWindow( + AccessibilityNodeInfoCompat checkingNode, @Nullable AccessibilityWindowInfo windowInfo) { + if (windowInfo == null) { + return false; + } + + AccessibilityNodeInfoCompat root = AccessibilityNodeInfoCompat.wrap(windowInfo.getRoot()); + try { + return hasDescendant(root, checkingNode); + } finally { + recycleNodes(root); + } + } + + /** + * Checks whether the given node is a header. + * + *

On M devices, the return value is always false if the node is an item in ListView or + * GridView but not in WebView. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + // TODO On pre-N devices, the framework ListView/GridView will mark non-headers + // as headers. The workaround should be removed when TalkBack doesn't support android M. + public static boolean isHeading(AccessibilityNodeInfoCompat node) { + if (!FeatureSupport.isHeadingWorks()) { + AccessibilityNodeInfoCompat collectionRoot = getCollectionRoot(node); + try { + if (nodeIsListOrGrid(collectionRoot) && !WebInterfaceUtils.isWebContainer(collectionRoot)) { + return false; + } + } finally { + AccessibilityNodeInfoUtils.recycleNodes(collectionRoot); + } + } + return node.isHeading(); + } + + /** + * Returns a collection root. + * + *

Note: Caller is responsible for recycling the returned node. + */ + public static @Nullable AccessibilityNodeInfoCompat getCollectionRoot( + AccessibilityNodeInfoCompat node) { + return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(node, FILTER_COLLECTION); + } + + /** + * Checks if given node is ListView or GirdView. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public static boolean nodeIsListOrGrid(@Nullable AccessibilityNodeInfoCompat node) { + return nodeMatchesAnyClassName(node, CLASS_LISTVIEW, CLASS_GRIDVIEW); + } + + /** Returns true if the {@code node} is in a collection. */ + public static boolean isInCollection(AccessibilityNodeInfoCompat node) { + return AccessibilityNodeInfoUtils.hasMatchingAncestor( + node, + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat ancestor) { + @RoleName int role = Role.getRole(ancestor); + return role == Role.ROLE_LIST + || role == Role.ROLE_GRID + || (ancestor != null && ancestor.getCollectionInfo() != null); + } + }); + } + + private static boolean nodeMatchesAnyClassName( + @Nullable AccessibilityNodeInfoCompat node, CharSequence... classNames) { + if (node == null || node.getClassName() == null || classNames == null) { + return false; + } + + for (CharSequence name : classNames) { + if (TextUtils.equals(node.getClassName(), name)) { + return true; + } + } + + return false; + } + + /** + * Splits a fully-qualified resource identifier name into its package and ID name. For example, + * "com.android.deskclock:id/analog_appwidget" which provides by {@link + * AccessibilityNodeInfoCompat#getViewIdResourceName()} + */ + @AutoValue + public abstract static class ViewResourceName { + public abstract String packageName(); + + public abstract String viewIdName(); + + /** + * Creates a ViewResourceName instance by {@link AccessibilityNodeInfoCompat}. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public static @Nullable ViewResourceName create(AccessibilityNodeInfoCompat node) { + String resourceName = node.getViewIdResourceName(); + if (TextUtils.isEmpty(resourceName)) { + return null; + } + + final String[] splitId = RESOURCE_NAME_SPLIT_PATTERN.split(resourceName, 2); + if (splitId.length != 2 || TextUtils.isEmpty(splitId[0]) || TextUtils.isEmpty(splitId[1])) { + // Invalid view resource name. + LogUtils.w(TAG, "Failed to parse resource: %s", resourceName); + return null; + } + + return new AutoValue_AccessibilityNodeInfoUtils_ViewResourceName(splitId[0], splitId[1]); + } + + @Override + public final String toString() { + return "ViewResourceName= " + + StringBuilderUtils.joinFields( + StringBuilderUtils.optionalText("packageName", packageName()), + StringBuilderUtils.optionalText("viewIdName", viewIdName())); + } + } + + /** + * Represents a {@link ClickableSpan} and the string it spans to reduce the effort of downstream + * consumers; getting the spanned string is non-trivial. + */ + @AutoValue + public abstract static class ClickableString { + public static ClickableString create(String string, ClickableSpan clickableSpan) { + return new AutoValue_AccessibilityNodeInfoUtils_ClickableString(string, clickableSpan); + } + + public abstract String string(); + + public abstract ClickableSpan clickableSpan(); + + // ClickableSpan.onClick is actually fine with a null param. + public void onClick() { + clickableSpan().onClick(null); + } + } + + private static CharSequence printId(AccessibilityNodeInfoCompat node) { + return String.format("Node(id=%s class=%s)", node.hashCode(), node.getClassName()); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityServiceCompatUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityServiceCompatUtils.java new file mode 100644 index 0000000..b944bfd --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityServiceCompatUtils.java @@ -0,0 +1,291 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.accessibilityservice.AccessibilityButtonController; +import android.accessibilityservice.AccessibilityService; +import android.accessibilityservice.AccessibilityServiceInfo; +import android.accessibilityservice.FingerprintGestureController; +import android.annotation.TargetApi; +import android.content.ComponentName; +import android.content.Context; +import android.os.Build; +import android.util.SparseArray; +import android.view.Display; +import android.view.accessibility.AccessibilityManager; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityWindowInfo; +import androidx.annotation.NonNull; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.Collections; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +public class AccessibilityServiceCompatUtils { + + private static final String TAG = "A11yServiceCompatUtils"; + + /** Holds constants in support of BrailleIme. */ + public static class Constants { + + private Constants() {} + + /** The package name for the Messages app. */ + public static final String ANDROID_MESSAGES_PACKAGE_NAME = "com.google.android.apps.messaging"; + + /** The package name for the Gboard app. */ + public static final String GBOARD_PACKAGE_NAME = "com.google.android.inputmethod.latin"; + + private static final String ACCESSIBILITY_SUITE_PACKAGE_NAME = + PackageManagerUtils.TALBACK_PACKAGE; + + /** The name of the TalkBack Settings Activity. */ + public static final ComponentName SETTINGS_ACTIVITY = + new ComponentName( + ACCESSIBILITY_SUITE_PACKAGE_NAME, "com.android.talkback.TalkBackPreferencesActivity"); + + /** The name of the TalkBack service. */ + public static final ComponentName TALKBACK_SERVICE = + new ComponentName( + ACCESSIBILITY_SUITE_PACKAGE_NAME, PackageManagerUtils.TALKBACK_SERVICE_NAME); + + /** The name of the Braille Ime. */ + public static final ComponentName BRAILLE_KEYBOARD = + new ComponentName( + ACCESSIBILITY_SUITE_PACKAGE_NAME, + "com.google.android.accessibility.brailleime.BrailleIme"); + + /** The name of the Braille display settings activity. */ + public static final ComponentName BRAILLE_DISPLAY_SETTINGS = + new ComponentName( + ACCESSIBILITY_SUITE_PACKAGE_NAME, + "com.google.android.accessibility.braille.brailledisplay.BrailleDisplaySettingsActivity"); + } + + /** @return root node of the Application window */ + public static @Nullable AccessibilityNodeInfoCompat getRootInActiveWindow( + AccessibilityService service) { + if (service == null) { + return null; + } + + AccessibilityNodeInfo root = service.getRootInActiveWindow(); + if (root == null) { + return null; + } + return AccessibilityNodeInfoUtils.toCompat(root); + } + + public static @Nullable String getActiveWindowPackageName(AccessibilityService service) { + @Nullable AccessibilityNodeInfoCompat rootNode = getRootInActiveWindow(service); + try { + return ((rootNode == null) || (rootNode.getPackageName() == null)) + ? null + : rootNode.getPackageName().toString(); + } finally { + AccessibilityNodeInfoUtils.recycleNodes(rootNode); + } + } + + /** + * Gets the windows on the screen of the default display. + * + * @see AccessibilityService#getWindows() + */ + public static List getWindows(AccessibilityService service) { + if (BuildVersionUtils.isAtLeastN()) { + // Use try/catch to fix REFERTO + try { + return service.getWindows(); + } catch (SecurityException e) { + LogUtils.e(TAG, "SecurityException occurred at AccessibilityService#getWindows(): %s", e); + return Collections.emptyList(); + } + } + // If build version is not isAtLeastN(), there is a chance of ClassCastException or + // NullPointerException. + try { + return service.getWindows(); + } catch (Exception e) { + LogUtils.e(TAG, "Exception occurred at AccessibilityService#getWindows(): %s", e); + return Collections.emptyList(); + } + } + + /** + * Gets the windows on the screen of all displays. + * + * @see AccessibilityService#getWindowsOnAllDisplays() + */ + @NonNull + public static SparseArray> getWindowsOnAllDisplays( + AccessibilityService service) { + if (FeatureSupport.supportMultiDisplay()) { + try { + return service.getWindowsOnAllDisplays(); + } catch (SecurityException e) { + LogUtils.e( + TAG, + "SecurityException occurred at AccessibilityService#getWindowsOnAllDisplays(): %s", + e); + return new SparseArray<>(); + } + } else { + SparseArray> windows = new SparseArray<>(); + windows.put(Display.DEFAULT_DISPLAY, getWindows(service)); + return windows; + } + } + + /** + * Iterate through all window info list on all displays and operate the task on all of the window + * info list. + * + * @param service The parent service + * @param task The task to be performed for each element + */ + public static void forEachWindowInfoListOnAllDisplays( + AccessibilityService service, @NonNull Consumer> task) { + SparseArray> windowsOnAllDisplays = + AccessibilityServiceCompatUtils.getWindowsOnAllDisplays(service); + final int displaySize = windowsOnAllDisplays.size(); + for (int i = 0; i < displaySize; i++) { + task.accept(windowsOnAllDisplays.valueAt(i)); + } + } + + public static @Nullable AccessibilityWindowInfo getActiveWidow(AccessibilityService service) { + if (service == null) { + return null; + } + + AccessibilityNodeInfo rootInActiveWindow = service.getRootInActiveWindow(); + if (rootInActiveWindow == null) { + return null; + } + AccessibilityWindowInfo window = AccessibilityNodeInfoUtils.getWindow(rootInActiveWindow); + rootInActiveWindow.recycle(); + return window; + } + + /** Returns whether input method window is on the screen. */ + public static boolean isInputWindowOnScreen(AccessibilityService service) { + List windows = getWindows(service); + for (AccessibilityWindowInfo window : windows) { + if (window != null && window.getType() == AccessibilityWindowInfo.TYPE_INPUT_METHOD) { + return true; + } + } + return false; + } + + public static String gestureIdToString(int gestureId) { + switch (gestureId) { + case AccessibilityService.GESTURE_SWIPE_DOWN: + return "GESTURE_SWIPE_DOWN"; + case AccessibilityService.GESTURE_SWIPE_DOWN_AND_LEFT: + return "GESTURE_SWIPE_DOWN_AND_LEFT"; + case AccessibilityService.GESTURE_SWIPE_DOWN_AND_RIGHT: + return "GESTURE_SWIPE_DOWN_AND_RIGHT"; + case AccessibilityService.GESTURE_SWIPE_DOWN_AND_UP: + return "GESTURE_SWIPE_DOWN_AND_UP"; + case AccessibilityService.GESTURE_SWIPE_LEFT: + return "GESTURE_SWIPE_LEFT"; + case AccessibilityService.GESTURE_SWIPE_LEFT_AND_DOWN: + return "GESTURE_SWIPE_LEFT_AND_DOWN"; + case AccessibilityService.GESTURE_SWIPE_LEFT_AND_RIGHT: + return "GESTURE_SWIPE_LEFT_AND_RIGHT"; + case AccessibilityService.GESTURE_SWIPE_LEFT_AND_UP: + return "GESTURE_SWIPE_LEFT_AND_UP"; + case AccessibilityService.GESTURE_SWIPE_RIGHT: + return "GESTURE_SWIPE_RIGHT"; + case AccessibilityService.GESTURE_SWIPE_RIGHT_AND_DOWN: + return "GESTURE_SWIPE_RIGHT_AND_DOWN"; + case AccessibilityService.GESTURE_SWIPE_RIGHT_AND_LEFT: + return "GESTURE_SWIPE_RIGHT_AND_LEFT"; + case AccessibilityService.GESTURE_SWIPE_RIGHT_AND_UP: + return "GESTURE_SWIPE_RIGHT_AND_UP"; + case AccessibilityService.GESTURE_SWIPE_UP: + return "GESTURE_SWIPE_UP"; + case AccessibilityService.GESTURE_SWIPE_UP_AND_DOWN: + return "GESTURE_SWIPE_UP_AND_DOWN"; + case AccessibilityService.GESTURE_SWIPE_UP_AND_LEFT: + return "GESTURE_SWIPE_UP_AND_LEFT"; + case AccessibilityService.GESTURE_SWIPE_UP_AND_RIGHT: + return "GESTURE_SWIPE_UP_AND_RIGHT"; + default: + return "(unhandled " + gestureId + ")"; + } + } + + /** + * Gets string representative of a fingerprint gesture. + * + * @param fingerprintGestureId The fingerprint gesture Id + * @return The string representative of the fingeprint gesture + */ + @TargetApi(Build.VERSION_CODES.O) + public static String fingerprintGestureIdToString(int fingerprintGestureId) { + switch (fingerprintGestureId) { + case FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_LEFT: + return "FINGERPRINT_GESTURE_SWIPE_LEFT"; + case FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_RIGHT: + return "FINGERPRINT_GESTURE_SWIPE_RIGHT"; + case FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_UP: + return "FINGERPRINT_GESTURE_SWIPE_UP"; + case FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_DOWN: + return "FINGERPRINT_GESTURE_SWIPE_DOWN"; + default: + return "(unhandled " + fingerprintGestureId + ")"; + } + } + + /** + * Returns {@code true} if a11y button is currently available. + * + *

REFERTO. Works around NPE on some Moto devices running O. + */ + public static boolean isAccessibilityButtonAvailableCompat( + AccessibilityButtonController controller) { + try { + return controller.isAccessibilityButtonAvailable(); + } catch (NullPointerException e) { + LogUtils.e(TAG, e.toString()); + return false; + } + } + + /** Returns if accessibility service is enabled. */ + public static boolean isAccessibilityServiceEnabled(Context context, String packageName) { + @Nullable AccessibilityManager manager = + (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + if (manager == null) { + return false; + } + List list = + manager.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK); + if (list != null) { + for (AccessibilityServiceInfo serviceInfo : list) { + if (serviceInfo.getId().contains(packageName)) { + return true; + } + } + } + return false; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityWindow.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityWindow.java new file mode 100644 index 0000000..f8a5519 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityWindow.java @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2018 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.annotation.TargetApi; +import android.os.Build; +import android.view.accessibility.AccessibilityWindowInfo; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.core.view.accessibility.AccessibilityWindowInfoCompat; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collection; + +/** + * A wrapper around AccessibilityWindowInfo/Compat, to help with: + * + *

    + *
  • handling null windows + *
  • recycling + *
  • using compat vs bare methods + *
  • using correct methods for various android versions + *
+ * + *

Currently, AccessibilityWindowInfo is not always recycled from + * AccessibilityNodeInfo.getWindow(), and never recycled from AccessibilityService.getWindows() + */ +public class AccessibilityWindow { + + private static final String TAG = "AccessibilityWindow"; + + /////////////////////////////////////////////////////////////////////////////////////// + // Constants + + /** Window types, including both bare and compat values. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + TYPE_ACCESSIBILITY_OVERLAY, + TYPE_APPLICATION, + TYPE_INPUT_METHOD, + TYPE_SPLIT_SCREEN_DIVIDER, + TYPE_SYSTEM, + TYPE_UNKNOWN + }) + public @interface WindowType {} + + public static final int TYPE_ACCESSIBILITY_OVERLAY = + AccessibilityWindowInfoCompat.TYPE_ACCESSIBILITY_OVERLAY; + public static final int TYPE_APPLICATION = AccessibilityWindowInfoCompat.TYPE_APPLICATION; + public static final int TYPE_INPUT_METHOD = AccessibilityWindowInfoCompat.TYPE_INPUT_METHOD; + public static final int TYPE_SPLIT_SCREEN_DIVIDER = + AccessibilityWindowInfoCompat.TYPE_SPLIT_SCREEN_DIVIDER; + public static final int TYPE_SYSTEM = AccessibilityWindowInfoCompat.TYPE_SYSTEM; + public static final int TYPE_UNKNOWN = -1; + + public static final int WINDOW_ID_UNKNOWN = -1; + + /////////////////////////////////////////////////////////////////////////////////////// + // Member data + + /** + * The wrapped window info. Both bare and compat objects are currently required, because + * AccessibilityWindowInfoCompat has no un/wrap() methods. Do not expose this object. + */ + private AccessibilityWindowInfo windowBare; + + private AccessibilityWindowInfoCompat windowCompat; + + /** Name of calling method that recycled this window. */ + private String recycledBy; + + /////////////////////////////////////////////////////////////////////////////////////// + // Construction + + /** + * Takes ownership of window*Arg. Does not allow all-null arguments, because call chaining is + * already impossible, because intermediate objects have to be recycled. Caller must recycle + * returned AccessibilityWindow. + */ + @Nullable + public static AccessibilityWindow takeOwnership( + @Nullable AccessibilityWindowInfo windowBareArg, + @Nullable AccessibilityWindowInfoCompat windowCompatArg) { + return construct(windowBareArg, windowCompatArg, FACTORY); + } + + /** + * Returns a node instance, or null. Should only be called by this class and sub-classes. Uses + * factory argument to create sub-class instances, without creating unnecessary instances when + * result should be null. Method is protected so that it can be called by sub-classes without + * duplicating null-checking logic. + * + * @param windowBareArg The wrapped window info. Caller may retain responsibility to recycle. + * @param windowCompatArg The wrapped window info. Caller may retain responsibility to recycle. + * @param factory Creates instances of AccessibilityWindow or sub-classes. + * @return AccessibilityWindow instance, that caller must recycle. + */ + @Nullable + protected static T construct( + @Nullable AccessibilityWindowInfo windowBareArg, + @Nullable AccessibilityWindowInfoCompat windowCompatArg, + Factory factory) { + // Check inputs. + if (windowBareArg == null && windowCompatArg == null) { + return null; + } + + // Construct window wrapper. + T instance = factory.create(); + AccessibilityWindow windowBase = instance; + windowBase.windowBare = windowBareArg; + windowBase.windowCompat = windowCompatArg; + return instance; + } + + protected AccessibilityWindow() {} + + /** A factory that can create instances of AccessibilityWindow or sub-classes. */ + protected interface Factory { + T create(); + } + + private static final Factory FACTORY = + new Factory() { + @Override + public AccessibilityWindow create() { + return new AccessibilityWindow(); + } + }; + + /////////////////////////////////////////////////////////////////////////////////////// + // Recycling + + public final synchronized boolean isRecycled() { + return (recycledBy != null); + } + + /** Recycles non-null windows. */ + public static void recycle(String caller, @Nullable AccessibilityWindow... windows) { + if (windows == null) { + return; + } + + for (AccessibilityWindow window : windows) { + if (window != null) { + window.recycle(caller); + } + } + } + + /** Recycles non-null windows and empties collection. */ + public static void recycle(String caller, @Nullable Collection windows) { + if (windows == null) { + return; + } + + for (AccessibilityWindow window : windows) { + if (window != null) { + window.recycle(caller); + } + } + + windows.clear(); + } + + /** + * Recycles window, or errors if already recycled. Cannot run at the same time as isRecycled(), + * and caller should not try to run recycle() at the same time as any other member function. + */ + public final synchronized void recycle(String caller) { + + // Check for double-recycling. + if (recycledBy == null) { + recycledBy = caller; + } else { + logOrThrow("AccessibilityWindow is already recycled by %s then by %s", recycledBy, caller); + } + + // Recycle window infos. + if (windowCompat != null) { + recycle(windowCompat, caller); + } + if (windowBare != null) { + recycle(windowBare, caller); + } + } + + private final void recycle(AccessibilityWindowInfo window, String caller) { + try { + window.recycle(); + } catch (IllegalStateException e) { + logOrThrow( + e, + "Caught IllegalStateException from accessibility framework with %s trying to recycle" + + " window %s", + caller, + window); + } + } + + private final void recycle(AccessibilityWindowInfoCompat window, String caller) { + try { + window.recycle(); + } catch (IllegalStateException e) { + logOrThrow( + e, + "Caught IllegalStateException from accessibility framework with %s trying to recycle" + + " window %s", + caller, + window); + } + } + + /** Overridable for testing. */ + protected boolean isDebug() { + return BuildConfig.DEBUG; + } + + /////////////////////////////////////////////////////////////////////////////////////// + // AccessibilityWindowInfo/Compat pass-through methods. Prefers compat methods. Also see + // https://developer.android.com/reference/android/view/accessibility/AccessibilityWindowInfo + + private AccessibilityWindowInfo getBare() { + if (isRecycled()) { + throwError("getBare() called on window already recycled by %s", recycledBy); + } + return windowBare; + } + + private AccessibilityWindowInfoCompat getCompat() { + if (isRecycled()) { + throwError("getCompat() called on window already recycled by %s", recycledBy); + } + return windowCompat; + } + + public final boolean isActive() { + // TODO: If window already recycled, throw name of recycler. + AccessibilityWindowInfoCompat compat = getCompat(); + return (compat == null) ? getBare().isActive() : compat.isActive(); + } + + public final boolean isFocused() { + AccessibilityWindowInfoCompat compat = getCompat(); + return (compat == null) ? getBare().isFocused() : compat.isFocused(); + } + + /** Returns flag whether window is picture-in-picture, or null if flag not available. */ + @TargetApi(Build.VERSION_CODES.O) + @Nullable + public final Boolean isInPictureInPictureMode() { + AccessibilityWindowInfo bare = getBare(); + if (bare == null) { + return null; + } + if (BuildVersionUtils.isAtLeastO()) { + return bare.isInPictureInPictureMode(); + } else { + return false; + } + } + + /** Returns the window id if available, otherwise returns {@code WINDOW_ID_UNKNOWN}. */ + public final int getId() { + AccessibilityWindowInfoCompat compat = getCompat(); + if (compat != null) { + return compat.getId(); + } + AccessibilityWindowInfo bare = getBare(); + if (bare != null) { + return bare.getId(); + } + return WINDOW_ID_UNKNOWN; + } + + @Nullable + public final CharSequence getTitle() { + AccessibilityWindowInfoCompat compat = getCompat(); + return (compat == null) ? null : compat.getTitle(); + } + + @AccessibilityWindow.WindowType + public final int getType() { + AccessibilityWindowInfoCompat compat = getCompat(); + return (compat == null) ? TYPE_UNKNOWN : compat.getType(); + } + + /** Returns root node info, which caller must recycle. */ + @Nullable + public final AccessibilityNode getRoot() { + AccessibilityWindowInfoCompat compat = getCompat(); + if (compat != null) { + return AccessibilityNode.takeOwnership(compat.getRoot()); + } + AccessibilityWindowInfo bare = getBare(); + if (bare != null) { + return AccessibilityNode.takeOwnership(bare.getRoot()); + } + return null; + } + + // TODO: Add more pass-through methods on demand. Keep alphabetic order. Prefer compat + // methods. + + /////////////////////////////////////////////////////////////////////////////////////// + // AccessibilityWindowInfoUtils pass-through methods. + + @Nullable + public final Boolean isWindowContentVisible() { + AccessibilityWindowInfo bare = getBare(); + return (bare == null) ? null : AccessibilityWindowInfoUtils.isWindowContentVisible(bare); + } + + // TODO: Add more pass-through methods on demand. Keep alphabetic order. + + /////////////////////////////////////////////////////////////////////////////////////// + // Error methods + + @FormatMethod + private void logOrThrow(@FormatString String format, Object... parameters) { + if (isDebug()) { + throwError(format, parameters); + } else { + logError(format, parameters); + } + } + + private void logOrThrow(IllegalStateException exception, String format, Object... parameters) { + if (isDebug()) { + throw exception; + } else { + logError(format, parameters); + logError("%s", exception); + } + } + + protected void logError(String format, Object... parameters) { + LogUtils.e(TAG, format, parameters); + } + + @FormatMethod + protected void throwError(@FormatString String format, Object... parameters) { + throw new IllegalStateException(String.format(format, parameters)); + } + + public static String typeToString(@WindowType int windowType) { + switch (windowType) { + case TYPE_ACCESSIBILITY_OVERLAY: + return "TYPE_ACCESSIBILITY_OVERLAY"; + case TYPE_APPLICATION: + return "TYPE_APPLICATION"; + case TYPE_INPUT_METHOD: + return "TYPE_INPUT_METHOD"; + case TYPE_SPLIT_SCREEN_DIVIDER: + return "TYPE_SPLIT_SCREEN_DIVIDER"; + case TYPE_SYSTEM: + return "TYPE_SYSTEM"; + case TYPE_UNKNOWN: + return "TYPE_UNKNOWN"; + default: + return "(unhandled)"; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityWindowInfoUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityWindowInfoUtils.java new file mode 100644 index 0000000..c8d2863 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityWindowInfoUtils.java @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +import android.annotation.TargetApi; +import android.graphics.Rect; +import android.os.Build.VERSION_CODES; +import android.view.Display; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityWindowInfo; +import androidx.annotation.NonNull; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityWindowInfoCompat; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Provides a series of utilities for interacting with {@link AccessibilityWindowInfo} objects. */ +public class AccessibilityWindowInfoUtils { + + private static final String TAG = "AccessibilityWindowInfoUtils"; + + /** + * A {@link Comparator} to order window top to bottom then left to right (right to left if the + * screen layout direction is RTL). + */ + public static class WindowPositionComparator implements Comparator { + + private final boolean mIsInRTL; + private final Rect mRectA = new Rect(); + private final Rect mRectB = new Rect(); + + public WindowPositionComparator(boolean isInRTL) { + mIsInRTL = isInRTL; + } + + @Override + public int compare(AccessibilityWindowInfo windowA, AccessibilityWindowInfo windowB) { + windowA.getBoundsInScreen(mRectA); + windowB.getBoundsInScreen(mRectB); + + if (mRectA.top != mRectB.top) { + return mRectA.top - mRectB.top; + } else { + return mIsInRTL ? mRectB.right - mRectA.right : mRectA.left - mRectB.left; + } + } + } + + /** Returns window bounds. */ + public static @Nullable Rect getBounds(AccessibilityWindowInfo window) { + if (BuildVersionUtils.isAtLeastO()) { + // Rely on window bounds in newer android. Root view may be larger than window, particularly + // for the split-screen window with launcher in window-2 position. + Rect bounds = new Rect(); + window.getBoundsInScreen(bounds); + return bounds; + } else { + // Get bounds from root view, because some window bounds may be inaccurate on older android. + AccessibilityNodeInfo root = getRoot(window); + try { + if (root == null) { + return null; + } + Rect bounds = new Rect(); + root.getBoundsInScreen(bounds); + return bounds; + } finally { + AccessibilityNodeInfoUtils.recycleNodes(root); + } + } + } + + /** + * Reorders the list of {@link AccessibilityWindowInfo} objects based on window positions on the + * screen. Removes the {@link AccessibilityWindowInfo} for the split screen divider in + * multi-window mode. + */ + public static void sortAndFilterWindows(List windows, boolean isInRTL) { + if (windows == null) { + return; + } + + Collections.sort(windows, new WindowPositionComparator(isInRTL)); + for (AccessibilityWindowInfo window : windows) { + if (window.getType() == AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER) { + windows.remove(window); + return; + } + } + } + + @TargetApi(VERSION_CODES.O) + public static boolean isPictureInPicture(@Nullable AccessibilityWindowInfo window) { + return BuildVersionUtils.isAtLeastO() && (window != null) && window.isInPictureInPictureMode(); + } + + public static boolean equals(AccessibilityWindowInfo window1, AccessibilityWindowInfo window2) { + if (window1 == null) { + return window2 == null; + } else { + return window1.equals(window2); + } + } + + public static boolean isWindowContentVisible(AccessibilityWindowInfo window) { + if (window == null) { + return false; + } + AccessibilityNodeInfo root = getRoot(window); + boolean isVisible = (root != null) && root.isVisibleToUser(); + if (root != null) { + root.recycle(); + } + return isVisible; + } + + /** Returns root node-info that caller must recycle. */ + public static @Nullable AccessibilityNodeInfoCompat getRootCompat(AccessibilityWindowInfo w) { + return AccessibilityNodeInfoUtils.toCompat(getRoot(w)); + } + + /** Returns the root node of the tree of {@code windowInfo}. */ + public static @Nullable AccessibilityNodeInfo getRoot(AccessibilityWindowInfo windowInfo) { + AccessibilityNodeInfo nodeInfo = null; + if (windowInfo == null) { + return null; + } + + try { + nodeInfo = windowInfo.getRoot(); + } catch (SecurityException e) { + LogUtils.e( + TAG, "SecurityException occurred at AccessibilityWindowInfoUtils#getRoot(): %s", e); + } + return nodeInfo; + } + + /** Returns the root node of the tree of {@code windowInfoCompat}. */ + public static @Nullable AccessibilityNodeInfoCompat getRoot( + AccessibilityWindowInfoCompat windowInfoCompat) { + AccessibilityNodeInfoCompat nodeInfoCompat = null; + if (windowInfoCompat == null) { + return null; + } + + try { + nodeInfoCompat = windowInfoCompat.getRoot(); + } catch (SecurityException e) { + LogUtils.e( + TAG, "SecurityException occurred at AccessibilityWindowInfoUtils#getRoot(): %s", e); + } + + return nodeInfoCompat; + } + + /** + * Returns the window title from {@link AccessibilityWindowInfo}. + * + *

Before android-N, even {@link AccessibilityWindowInfoCompat#getTitle} will always return + * null. + */ + @TargetApi(VERSION_CODES.N) + public static @Nullable CharSequence getTitle(AccessibilityWindowInfo windowInfo) { + if (windowInfo != null && FeatureSupport.supportGetTitleFromWindows()) { + return windowInfo.getTitle(); + } + return null; + } + + /** + * Returns the ID of the display this window is on. If the platform doesn't support multi-display, + * it returns {@link Display.DEFAULT_DISPLAY} + */ + @TargetApi(VERSION_CODES.R) + public static int getDisplayId(@NonNull AccessibilityWindowInfo windowInfo) { + if (FeatureSupport.supportMultiDisplay()) { + return windowInfo.getDisplayId(); + } + return Display.DEFAULT_DISPLAY; + } + + /** + * Gets the node that anchors this window to another. + * + * @param window the target window to check + * @return The anchor node, or {@code null} if none exists. + */ + @TargetApi(VERSION_CODES.N) + public static @Nullable AccessibilityNodeInfoCompat getAnchor(AccessibilityWindowInfo window) { + AccessibilityNodeInfoCompat nodeInfo = null; + if (window == null || !BuildVersionUtils.isAtLeastN()) { + return null; + } + + try { + nodeInfo = AccessibilityNodeInfoUtils.toCompat(window.getAnchor()); + } catch (SecurityException e) { + LogUtils.e( + TAG, "SecurityException occurred at AccessibilityWindowInfoUtils#getAnchor(): %s", e); + } + return nodeInfo; + } + + /** + * Returns the window anchored by the given node. + * + * @param anchor the anchor node + * @return windowInfo of the anchored window + */ + public static @Nullable AccessibilityWindowInfo getAnchoredWindow( + @Nullable AccessibilityNodeInfoCompat anchor) { + return (anchor == null) ? null : getAnchoredWindow(anchor.unwrap()); + } + + /** + * Returns the window anchored by the given node. + * + * @param anchor the anchor node + * @return windowInfo of the anchored window + */ + public static @Nullable AccessibilityWindowInfo getAnchoredWindow( + @Nullable AccessibilityNodeInfo anchor) { + @Nullable AccessibilityNodeInfoCompat node; + AccessibilityWindowInfo window = AccessibilityNodeInfoUtils.getWindow(anchor); + if (anchor == null || window == null || !BuildVersionUtils.isAtLeastN()) { + return null; + } + for (int i = 0; i < window.getChildCount(); i++) { + AccessibilityWindowInfo windowInfo = window.getChild(i); + node = AccessibilityWindowInfoUtils.getAnchor(windowInfo); + try { + if (node != null && anchor.equals(node.unwrap())) { + return windowInfo; + } + } finally { + AccessibilityNodeInfoUtils.recycleNodes(node); + } + } + return null; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AlertDialogAdaptiveContrastUtil.java b/utils/src/main/java/com/google/android/accessibility/utils/AlertDialogAdaptiveContrastUtil.java new file mode 100644 index 0000000..c29596d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AlertDialogAdaptiveContrastUtil.java @@ -0,0 +1,128 @@ +package com.google.android.accessibility.utils; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.view.View; +import android.widget.Button; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +/** + * This class customizes the foreground text color to adapt the button background change when + * getting input focus. + */ +class AlertDialogAdaptiveContrastUtil { + + /** + * Creates AlertDialogAdaptiveContrast for {@link android.app.AlertDialog.Builder} to customize + * the dialog buttons can adapt the color of foreground text, when the input focus changed, to + * comply the contrast criteria. + * + * @param context The current context + * @param theme The theme applies for the alert dialog + * @return {@code android.app.AlertDialog.Builder} + */ + static AlertDialog.Builder appAlertDialogAdaptiveContrastBuilder(Context context, int theme) { + return new AlertDialogAdaptiveContrastBuilder(context, theme); + } + + /** + * Creates AlertDialogAdaptiveContrast for {@link androidx.appcompat.app.AlertDialog.Builder} to + * customize the dialog buttons can adapt the color of foreground text, when the input focus + * changed, to comply the contrast criteria. + * + * @param context The current context + * @param theme The theme applies for the alert dialog + * @return {@code androidx.appcompat.app.AlertDialog.Builder} + */ + static androidx.appcompat.app.AlertDialog.Builder v7AlertDialogAdaptiveContrastBuilder( + Context context, int theme) { + return new V7AlertDialogAdaptiveContrastBuilder(context, theme); + } + + /** + * This class supports {@link android.app.AlertDialog} and customizes the foreground text color to + * adapt the button background change when getting input focus. + */ + private static class AlertDialogAdaptiveContrastBuilder extends AlertDialog.Builder { + + private Context context; + + public AlertDialogAdaptiveContrastBuilder(Context context, int theme) { + super(context, theme); + this.context = context; + } + + /** + * To set focus change listener, the buttons are visible only after the container dialog's + * created/shown. + */ + @Override + public AlertDialog create() { + AlertDialog dialog = super.create(); + dialog.create(); + adjustTextColorViaFocus( + context, + dialog.getButton(DialogInterface.BUTTON_POSITIVE), + dialog.getButton(DialogInterface.BUTTON_NEGATIVE)); + return dialog; + } + } + + /** + * This class supports {@link androidx.appcompat.app.AlertDialog} and customizes the foreground + * text color to adapt the button background change when getting input focus. + */ + private static class V7AlertDialogAdaptiveContrastBuilder + extends androidx.appcompat.app.AlertDialog.Builder { + + private Context context; + + public V7AlertDialogAdaptiveContrastBuilder(Context context, int theme) { + super(context, theme); + this.context = context; + } + + /** + * To set focus change listener, the buttons are visible only after the container dialog's + * created/shown. + */ + @Override + public androidx.appcompat.app.AlertDialog create() { + androidx.appcompat.app.AlertDialog dialog = super.create(); + dialog.create(); + adjustTextColorViaFocus( + context, + dialog.getButton(DialogInterface.BUTTON_POSITIVE), + dialog.getButton(DialogInterface.BUTTON_NEGATIVE)); + return dialog; + } + } + + /** + * The background color of dialog button changes when it got input focus. That would affect the + * contrast of display text and fail the GAR criteria. This method adjusts the color the + * foreground text according to the applied theme (day/night). + */ + private static void adjustTextColorViaFocus( + Context context, @Nullable Button buttonPositive, @Nullable Button buttonNegative) { + View.OnFocusChangeListener focusChangeListener = + (v, hasFocus) -> + ((Button) v) + .setTextColor( + ContextCompat.getColor( + context, + hasFocus + ? R.color.a11y_alert_dialog_button_focused_color + : R.color.a11y_alert_dialog_button_color)); + + if (buttonPositive != null) { + buttonPositive.setOnFocusChangeListener(focusChangeListener); + } + + if (buttonNegative != null) { + buttonNegative.setOnFocusChangeListener(focusChangeListener); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AlertDialogUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/AlertDialogUtils.java new file mode 100644 index 0000000..2521ef7 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AlertDialogUtils.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2020 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.app.AlertDialog; +import android.content.Context; + +/** Utility class for AlertDialog */ +public class AlertDialogUtils { + + /** + * Create {@link AlertDialog.Builder} and apply theme. Also customize the Builder so that, the + * dialog buttons can adapt the color of foreground text, when the input focus changed, to comply + * the contrast criteria. + */ + // TODO: The Dialogs in the Settings keep old style before T. + public static AlertDialog.Builder builder(Context context) { + return AlertDialogAdaptiveContrastUtil.appAlertDialogAdaptiveContrastBuilder( + context, R.style.A11yAlertDialogTheme); + } + + /** + * Create {@link androidx.appcompat.app.AlertDialog.Builder} and apply theme. Also customize the + * Builder so that, the dialog buttons can adapt the color of foreground text, when the input + * focus changed, to comply the contrast criteria. + */ + // TODO: The Dialogs in the Settings keep old style before T. + public static androidx.appcompat.app.AlertDialog.Builder v7Builder(Context context) { + return AlertDialogAdaptiveContrastUtil.v7AlertDialogAdaptiveContrastBuilder( + context, R.style.A11yAlertDialogTheme); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ArrayUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/ArrayUtils.java new file mode 100644 index 0000000..fdfb3dc --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ArrayUtils.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +import java.util.Arrays; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Utility class containing operations on arrays. */ +public final class ArrayUtils { + private ArrayUtils() {} + + /** + * Concatenates a set of objects to the end of array. + * + * @param the class of the objects in the array + * @param array the array of object. + * @param rest objects to be concatenated a the end of array. + * @return an array containing all the elements. + */ + public static T[] concat(T[] array, T... rest) { + @Nullable T[] result = Arrays.copyOf(array, array.length + rest.length); + int offset = array.length; + for (T item : rest) { + result[offset++] = item; + } + return (T[]) result; + } + + /** + * Searches the specified array of floats for the specified value using the binary search + * algorithm. The array must be sorted in ascending order prior to making this call. + * + * @return The index of the search value if it's contained in the array. Otherwise returns the + * index of the closest value in the array. + */ + public static int binarySearchClosestIndex(float[] array, float target) { + if (target < array[0]) { + return 0; + } + if (target > array[array.length - 1]) { + return array.length - 1; + } + + int lo = 0; + int hi = array.length - 1; + while (lo + 1 < hi) { + // Avoid integer overflow on midpoint calculation. hi + lo can result in overflow if + // array.length > INT_MAX / 2 + int mid = lo + ((hi - lo) / 2); + if (target < array[mid]) { + hi = mid; + } else if (target > array[mid]) { + lo = mid; + } else { + return mid; + } + } + + // lo==hi or lo+1==hi + return ((target - array[lo]) < (array[hi] - target)) ? lo : hi; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/BasePreferencesActivity.java b/utils/src/main/java/com/google/android/accessibility/utils/BasePreferencesActivity.java new file mode 100644 index 0000000..6292fb5 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/BasePreferencesActivity.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2021 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.google.android.accessibility.utils; + +import android.graphics.drawable.Drawable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Preference activity used. This base class is separate from PreferencesActivity to allow partner + * code (for open-source) to inherit from AppCompatActivity. + */ +public abstract class BasePreferencesActivity extends AppCompatActivity { + private static final int DEFAULT_CONTAINER_ID = android.R.id.content; + + /** Disables action bar */ + protected void disableActionBar() { + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.hide(); + } + } + + /** + * Prepares action bar + * + * @param icon Icon shows on action bar. + */ + protected void prepareActionBar(@Nullable Drawable icon) { + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + if (icon != null) { + actionBar.setIcon(icon); + } + actionBar.setDisplayHomeAsUpEnabled(true); + } + } + + /** Disables to expand action bar */ + protected void disableExpandActionBar() {} + + /** + * Gets Identifier of the container whose fragment(s) at the activity should use. + * + * @return The fragments container in the activity. + */ + protected int getContainerId() { + return DEFAULT_CONTAINER_ID; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/BasePreferencesFragment.java b/utils/src/main/java/com/google/android/accessibility/utils/BasePreferencesFragment.java new file mode 100644 index 0000000..a25dd3b --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/BasePreferencesFragment.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2021 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.google.android.accessibility.utils; + +import android.app.ActionBar; +import androidx.fragment.app.FragmentActivity; +import androidx.preference.PreferenceFragmentCompat; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Fragment that holds the preference user interface controls. */ +public abstract class BasePreferencesFragment extends PreferenceFragmentCompat { + + /** + * Gets the title which the fragment likes to show on app bar. The child class must implement this + * function. + * + * @return The title of the fragment will show on app bar. + */ + public abstract CharSequence getTitle(); + + /** + * This function is used to get the sub title which the fragment likes to show on app bar. The + * child class implements this function and will show sub title on app bar. If the child class + * doesn't implement this function, sub title will not show anything. + * + * @return The sub title of the fragment will show on app bar. + */ + public @Nullable CharSequence getSubTitle() { + return null; + } + + @Override + public void onResume() { + super.onResume(); + + FragmentActivity activity = getActivity(); + activity.setTitle(getTitle()); + + @Nullable CharSequence subTitle = getSubTitle(); + if (subTitle != null) { + ActionBar actionBar = activity.getActionBar(); + if (actionBar != null) { + actionBar.setSubtitle(subTitle); + } + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/BuildVersionUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/BuildVersionUtils.java new file mode 100644 index 0000000..ad68523 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/BuildVersionUtils.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +import android.os.Build; +import androidx.annotation.ChecksSdkIntAtLeast; + +/** + * This file provides a wrapper for the Build versions. Everytime an android version number gets + * fixed, this file should be updated. Generally, BuildCompat.isAtLeast*() works before android + * release is finalized, Build.VERSION_CODES.* works after. + */ +public class BuildVersionUtils { + + private BuildVersionUtils() {} + + public static boolean isM() { + return Build.VERSION.SDK_INT == Build.VERSION_CODES.M; + } + + public static boolean isAtLeastN() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N; + } + + public static boolean isAtLeastNMR1() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1; + } + + public static boolean isAtLeastO() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; + } + + public static boolean isAtLeastOMR1() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1; + } + + public static boolean isAtLeastP() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P; + } + + public static boolean isAtLeastQ() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + } + + public static boolean isAtLeastR() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R; + } + + public static boolean isAtLeastS() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S; + } + + @ChecksSdkIntAtLeast(api = 32) + public static boolean isAtLeastT() { + // Build.VERSION_CODES.TIRAMISU is not open-sourced yet. + return Build.VERSION.SDK_INT > Build.VERSION_CODES.S; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ClassLoadingCache.java b/utils/src/main/java/com/google/android/accessibility/utils/ClassLoadingCache.java new file mode 100755 index 0000000..627d117 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ClassLoadingCache.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2010 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.google.android.accessibility.utils; + +import android.text.TextUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.HashMap; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** This class manages efficient loading of classes. */ +public class ClassLoadingCache { + + private static final String TAG = "ClassLoadingCache"; + + // TODO: Use a LRU map instead? + private static final HashMap> mCachedClasses = new HashMap<>(); + + /** + * Returns a class by given className. It tries to load from the current class loader + * and caches them. + * + * @param className The name of the class to load. + * @return The class if loaded successfully, null otherwise. + */ + public static @Nullable Class loadOrGetCachedClass(String className) { + if (TextUtils.isEmpty(className)) { + LogUtils.d(TAG, "Missing class name. Failed to load class."); + return null; + } + + if (mCachedClasses.containsKey(className)) { + return mCachedClasses.get(className); + } + + Class insideClazz = null; + try { + ClassLoader classLoader = ClassLoadingCache.class.getClassLoader(); + if (classLoader != null) { + insideClazz = classLoader.loadClass(className); + } + if (insideClazz == null) { + LogUtils.d(TAG, "Failed to load class: %s", className); + } + } catch (ClassNotFoundException e) { + LogUtils.d(TAG, "Failed to load class: %s", className); + } + + mCachedClasses.put(className, insideClazz); + return insideClazz; + } + + /** Returns whether a target class is an instance of a reference class. */ + public static boolean checkInstanceOf( + CharSequence targetClassName, CharSequence referenceClassName) { + if ((targetClassName == null) || (referenceClassName == null)) return false; + if (TextUtils.equals(targetClassName, referenceClassName)) return true; + + final Class referenceClass = loadOrGetCachedClass(referenceClassName.toString()); + final Class targetClass = loadOrGetCachedClass(targetClassName.toString()); + return referenceClass != null + && targetClass != null + && referenceClass.isAssignableFrom(targetClass); + } + + /** Returns whether a target class is an instance of a reference class. */ + public static boolean checkInstanceOf(CharSequence targetClassName, Class referenceClass) { + if ((targetClassName == null) || (referenceClass == null)) return false; + if (TextUtils.equals(targetClassName, referenceClass.getName())) return true; + + final Class targetClass = loadOrGetCachedClass(targetClassName.toString()); + return targetClass != null && referenceClass.isAssignableFrom(targetClass); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/CollectionState.java b/utils/src/main/java/com/google/android/accessibility/utils/CollectionState.java new file mode 100644 index 0000000..78ae5c3 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/CollectionState.java @@ -0,0 +1,959 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils; + +import android.os.Build; +import android.util.SparseArray; +import android.view.accessibility.AccessibilityEvent; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.HashSet; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Manages the contextual collection state when the user is navigating between elements or touch + * exploring. This class implements a state machine for determining what transition feedback should + * be provided for collections. + */ +public class CollectionState { + + /** The possible collection transitions that can occur when moving from node to node. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({NAVIGATE_NONE, NAVIGATE_ENTER, NAVIGATE_EXIT, NAVIGATE_INTERIOR}) + public @interface CollectionTransition {} + + /** Bitmask used when we need to identify a row transition, column transition, or both. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {TYPE_NONE, TYPE_ROW, TYPE_COLUMN}) + public @interface RowColumnTransition {} + + /** The possible heading types for a table heading. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_NONE, TYPE_ROW, TYPE_COLUMN, TYPE_INDETERMINATE}) + public @interface TableHeadingType {} + + /** Whether the collection is horizontal or vertical. A square collection is vertical. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ALIGNMENT_VERTICAL, ALIGNMENT_HORIZONTAL}) + public @interface CollectionAlignment {} + + /** Transition to a node outside any collection from a node also outside any collection. */ + public static final int NAVIGATE_NONE = 0; + /** Transition to a node inside a collection from a node that is not in that collection. */ + public static final int NAVIGATE_ENTER = 1; + /** Transition to a node outside any collection from a node that is within a collection. */ + public static final int NAVIGATE_EXIT = 2; + /** Transition between two nodes in the same collection. */ + public static final int NAVIGATE_INTERIOR = 3; + + public static final int TYPE_NONE = 0; + public static final int TYPE_ROW = 1 << 0; + public static final int TYPE_COLUMN = 1 << 1; + public static final int TYPE_INDETERMINATE = 1 << 2; + public static final int ALIGNMENT_VERTICAL = 0; + public static final int ALIGNMENT_HORIZONTAL = 1; + + static final String EVENT_ROW = "AccessibilityNodeInfo.CollectionItemInfo.rowIndex"; + static final String EVENT_COLUMN = "AccessibilityNodeInfo.CollectionItemInfo.columnIndex"; + static final String EVENT_HEADING = "AccessibilityNodeInfo.CollectionItemInfo.heading"; + + @CollectionTransition private int mCollectionTransition = NAVIGATE_NONE; + @RowColumnTransition private int mRowColumnTransition = TYPE_NONE; + private @Nullable AccessibilityNodeInfoCompat mCollectionRoot; + private @Nullable AccessibilityNodeInfoCompat mLastAnnouncedNode; + private @Nullable ItemState mItemState; + private SparseArray mRowHeaders = new SparseArray<>(); + private SparseArray mColumnHeaders = new SparseArray<>(); + private int mCollectionLevel = -1; + private boolean mShouldComputeHeaders = false; + private boolean mShouldComputeNumbering = false; + + private static final Filter FILTER_HIERARCHICAL_COLLECTION = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return AccessibilityNodeInfoUtils.FILTER_COLLECTION.accept(node) + && node.getCollectionInfo() != null + && node.getCollectionInfo().isHierarchical(); + } + }; + + private static final Filter FILTER_FLAT_COLLECTION = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return AccessibilityNodeInfoUtils.FILTER_COLLECTION.accept(node) + && (node.getCollectionInfo() == null || !node.getCollectionInfo().isHierarchical()); + } + }; + + private static final Filter FILTER_COLLECTION_ITEM = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return node != null && node.getCollectionItemInfo() != null; + } + }; + + private static final Filter FILTER_WEBVIEW = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return node != null && Role.getRole(node) == Role.ROLE_WEB_VIEW; + } + }; + + /** + * Base interface for internal collection item state. It should be kept private because clients of + * the CollectionState should not need polymorphism for ListItemState/TableItemState. On the other + * hand, polymorphic behavior is quite useful for simplifying some of our internal logic. + */ + private interface ItemState { + /** + * @return The row-column transition from the {@code from} state to the current state. If the + * {@code from} state and the current state are of incompatible types, should return {@code + * TYPE_ROW | TYPE_COLUMN}. + */ + @RowColumnTransition + public int getTransition(@NonNull ItemState from); + } + + public static class ListItemState implements ItemState { + /** Whether the list item is a heading. */ + private final boolean mHeading; + /** The index of the list item. */ + private final int mIndex; + /** Whether the index should be displayed; used to work around a framework bug pre-N. */ + private final boolean mDisplayIndex; + + public ListItemState(boolean heading, int index, boolean displayIndex) { + mHeading = heading; + mIndex = index; + mDisplayIndex = displayIndex; + } + + @Override + @RowColumnTransition + public int getTransition(@NonNull ItemState from) { + if (!(from instanceof ListItemState)) { + return TYPE_ROW | TYPE_COLUMN; + } + + ListItemState otherListItemState = (ListItemState) from; + if (mIndex != otherListItemState.mIndex) { + return TYPE_ROW | TYPE_COLUMN; + } + + return TYPE_NONE; + } + + public boolean isHeading() { + return mHeading; + } + + public int getIndex() { + if (mDisplayIndex) { + return mIndex; + } else { + return -1; + } + } + } + + /** A holder for current page info for when the user transitions between collection states */ + public static class PagerItemState implements ItemState { + private final boolean heading; + private final int rowIndex; + private final int columnIndex; + + /** + * Constructs a PagerItemState, which holds the current page info when transitioning between + * collection states. + */ + public PagerItemState(boolean heading, int rowIndex, int columnIndex) { + this.heading = heading; + this.rowIndex = rowIndex; + this.columnIndex = columnIndex; + } + + @Override + @RowColumnTransition + public int getTransition(@NonNull ItemState other) { + if (!(other instanceof PagerItemState)) { + return TYPE_ROW | TYPE_COLUMN; + } + + PagerItemState otherPagerItemState = (PagerItemState) other; + int transition = TYPE_NONE; + if (rowIndex != otherPagerItemState.rowIndex) { + transition |= TYPE_ROW; + } + if (columnIndex != otherPagerItemState.columnIndex) { + transition |= TYPE_COLUMN; + } + + return transition; + } + + /** Returns {@code true} if this item represents a heading page. */ + public boolean isHeading() { + return heading; + } + + /** Returns the row index of the item in a grid or vertical list pager. */ + public int getRowIndex() { + return rowIndex; + } + + /** Returns the column index of the item in a grid or horizontal list pager. */ + public int getColumnIndex() { + return columnIndex; + } + } + + public static class TableItemState implements ItemState { + /** Indicates whether the table cell is a row, column, or indeterminate heading. */ + @TableHeadingType private final int mHeading; + /** The row name, or {@code null} if the row is unnamed. */ + private final @Nullable CharSequence mRowName; + /** The column name, or {@code null} if the column is unnamed. */ + private final @Nullable CharSequence mColumnName; + /** The row index. */ + private final int mRowIndex; + /** The column index. */ + private final int mColumnIndex; + /** Whether the indices should be displayed; used to work around a framework bug pre-N. */ + private final boolean mDisplayIndices; + + public TableItemState( + @TableHeadingType int heading, + @Nullable CharSequence rowName, + @Nullable CharSequence columnName, + int rowIndex, + int columnIndex, + boolean displayIndices) { + mHeading = heading; + mRowName = rowName; + mColumnName = columnName; + mRowIndex = rowIndex; + mColumnIndex = columnIndex; + mDisplayIndices = displayIndices; + } + + @Override + @RowColumnTransition + public int getTransition(@NonNull ItemState other) { + if (!(other instanceof TableItemState)) { + return TYPE_ROW | TYPE_COLUMN; + } + + TableItemState otherTableItemState = (TableItemState) other; + int transition = TYPE_NONE; + if (mRowIndex != otherTableItemState.mRowIndex) { + transition |= TYPE_ROW; + } + if (mColumnIndex != otherTableItemState.mColumnIndex) { + transition |= TYPE_COLUMN; + } + + return transition; + } + + @TableHeadingType + public int getHeadingType() { + return mHeading; + } + + public @Nullable CharSequence getRowName() { + return mRowName; + } + + public @Nullable CharSequence getColumnName() { + return mColumnName; + } + + public int getRowIndex() { + if (mDisplayIndices) { + return mRowIndex; + } else { + return -1; + } + } + + public int getColumnIndex() { + if (mDisplayIndices) { + return mColumnIndex; + } else { + return -1; + } + } + } + + public CollectionState() {} + + @CollectionTransition + public int getCollectionTransition() { + return mCollectionTransition; + } + + @RowColumnTransition + public int getRowColumnTransition() { + return mRowColumnTransition; + } + + public @Nullable CharSequence getCollectionName() { + return AccessibilityNodeInfoUtils.getNodeText(mCollectionRoot); + } + + /** + * @return Either {@link com.google.android.accessibility.utils.Role#ROLE_LIST} or {@link + * com.google.android.accessibility.utils.Role#ROLE_GRID} if there is a collection, or {@link + * com.google.android.accessibility.utils.Role#ROLE_NONE} if there isn't one. + */ + @Role.RoleName + public int getCollectionRole() { + return Role.getRole(mCollectionRoot); + } + + public CharSequence getCollectionRoleDescription() { + return mCollectionRoot.getRoleDescription(); + } + + public int getCollectionRowCount() { + if (mCollectionRoot == null + || mCollectionRoot.getCollectionInfo() == null + || !mShouldComputeNumbering) { + return -1; + } + + return mCollectionRoot.getCollectionInfo().getRowCount(); + } + + public int getCollectionColumnCount() { + if (mCollectionRoot == null + || mCollectionRoot.getCollectionInfo() == null + || !mShouldComputeNumbering) { + return -1; + } + + return mCollectionRoot.getCollectionInfo().getColumnCount(); + } + + @CollectionAlignment + public int getCollectionAlignment() { + if (mCollectionRoot == null || !mShouldComputeNumbering) { + return ALIGNMENT_VERTICAL; + } else { + return getCollectionAlignmentInternal(mCollectionRoot.getCollectionInfo()); + } + } + + @CollectionAlignment + public static int getCollectionAlignmentInternal(@Nullable CollectionInfoCompat collection) { + if (collection == null || collection.getRowCount() >= collection.getColumnCount()) { + return ALIGNMENT_VERTICAL; + } else { + return ALIGNMENT_HORIZONTAL; + } + } + + public boolean doesCollectionExist() { + if (mCollectionRoot == null) { + return false; + } + + // If collection can be refresh successfully, it still exists. + return mCollectionRoot.refresh(); + } + + /** + * Guaranteed to return a non-{@code null} ListItemState if {@link #getRowColumnTransition()} is + * not {@link #TYPE_NONE} and {@link #getCollectionRole()} is {@link + * com.google.android.accessibility.utils.Role#ROLE_LIST}. + */ + public @Nullable ListItemState getListItemState() { + if (mItemState != null && mItemState instanceof ListItemState) { + return (ListItemState) mItemState; + } + + return null; + } + + private static @Nullable ListItemState getListItemState( + AccessibilityNodeInfoCompat collectionRoot, + AccessibilityNodeInfoCompat announcedNode, + boolean computeNumbering) { + if (collectionRoot == null || collectionRoot.getCollectionInfo() == null) { + return null; + } + + // Checking the ancestors should incur zero performance penalty in the typical case + // where list items are direct descendants. Assuming list items are not deeply + // nested, any performance penalty would be minimal. + AccessibilityNodeInfoCompat collectionItem = + AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor( + announcedNode, collectionRoot, FILTER_COLLECTION_ITEM); + + if (collectionItem == null) { + return null; + } + + CollectionInfoCompat collection = collectionRoot.getCollectionInfo(); + CollectionItemInfoCompat item = collectionItem.getCollectionItemInfo(); + + boolean heading = AccessibilityNodeInfoUtils.isHeading(collectionItem); + int index; + if (getCollectionAlignmentInternal(collection) == ALIGNMENT_VERTICAL) { + index = getRowIndex(item, collection); + } else { + index = getColumnIndex(item, collection); + } + + return new ListItemState(heading, index, computeNumbering); + } + + /** + * Returns a non-{@code null} PagerItemState if {@link #getRowColumnTransition()} is not {@link + * #TYPE_NONE} and {@link #getCollectionRole()} is {@link + * com.google.android.accessibility.utils.Role#ROLE_PAGER}. + */ + public @Nullable PagerItemState getPagerItemState() { + if (mItemState instanceof PagerItemState) { + return (PagerItemState) mItemState; + } + return null; + } + + /** + * Returns a non-{@code null} PagerItemState if {@code collectionRoot} and {@code announcedNode} + * are not null. + * + * @param collectionRoot the node with role {@link + * com.google.android.accessibility.utils.Role#ROLE_PAGER}, representing a collection of + * pages. Its descendants include {@code announcedNode} + * @param announcedNode the node that was given accessibility focus. It is or is a child of a page + * item that belongs to the pager defined by {@code collectionRoot} + * @param computeHeaders is {@code true} if {@link + * #shouldComputeHeaders(AccessibilityNodeInfoCompat)} returns {@code true} + * @return + */ + private static @Nullable PagerItemState extractPagerItemState( + AccessibilityNodeInfoCompat collectionRoot, + AccessibilityNodeInfoCompat announcedNode, + boolean computeHeaders) { + if ((collectionRoot == null) || (collectionRoot.getCollectionInfo() == null)) { + return null; + } + + // Checking the ancestors should incur zero performance penalty in the typical case + // where list items are direct descendants. Assuming list items are not deeply + // nested, any performance penalty would be minimal. + + AccessibilityNode collectionItem = + AccessibilityNode.takeOwnership( + AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor( + announcedNode, collectionRoot, FILTER_COLLECTION_ITEM)); + + if (collectionItem == null) { + return null; + } + + try { + CollectionInfoCompat collection = collectionRoot.getCollectionInfo(); + CollectionItemInfoCompat item = collectionItem.getCollectionItemInfo(); + + boolean heading = computeHeaders && collectionItem.isHeading(); + int rowIndex = getRowIndex(item, collection); + int columnIndex = getColumnIndex(item, collection); + + return new PagerItemState(heading, rowIndex, columnIndex); + } finally { + } + } + + /** + * Guaranteed to return a non-{@code null} TableItemState if {@link #getRowColumnTransition()} is + * not {@link #TYPE_NONE} and {@link #getCollectionRole()} is {@link + * com.google.android.accessibility.utils.Role#ROLE_GRID}. + */ + public @Nullable TableItemState getTableItemState() { + if (mItemState != null && mItemState instanceof TableItemState) { + return (TableItemState) mItemState; + } + + return null; + } + + private static @Nullable TableItemState getTableItemState( + AccessibilityNodeInfoCompat collectionRoot, + AccessibilityNodeInfoCompat announcedNode, + SparseArray rowHeaders, + SparseArray columnHeaders, + boolean computeHeaders, + boolean computeNumbering) { + if (collectionRoot == null || collectionRoot.getCollectionInfo() == null) { + return null; + } + + // Checking the ancestors should incur zero performance penalty in the typical case + // where list items are direct descendants. Assuming list items are not deeply + // nested, any performance penalty would be minimal. + AccessibilityNodeInfoCompat collectionItem = + AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor( + announcedNode, collectionRoot, FILTER_COLLECTION_ITEM); + + if (collectionItem == null) { + return null; + } + + CollectionInfoCompat collection = collectionRoot.getCollectionInfo(); + CollectionItemInfoCompat item = collectionItem.getCollectionItemInfo(); + + int heading = computeHeaders ? getTableHeading(collectionItem, item, collection) : TYPE_NONE; + int rowIndex = getRowIndex(item, collection); + int columnIndex = getColumnIndex(item, collection); + CharSequence rowName = rowIndex != -1 ? rowHeaders.get(rowIndex) : null; + CharSequence columnName = columnIndex != -1 ? columnHeaders.get(columnIndex) : null; + + return new TableItemState( + heading, rowName, columnName, rowIndex, columnIndex, computeNumbering); + } + + /** + * If the collection is part of a hierarchy of collections (e.g. a tree or outlined list), returns + * the nesting level of the collection, with 0 being the outermost list, 1 being the list nested + * within the outermost list, and so forth. If the collection is not part of a hierarchy, returns + * -1. + */ + public int getCollectionLevel() { + return mCollectionLevel; + } + + private static int getCollectionLevelInternal(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return -1; + } + + if (!FILTER_HIERARCHICAL_COLLECTION.accept(node)) { + return -1; + } + + return AccessibilityNodeInfoUtils.countMatchingAncestors(node, FILTER_HIERARCHICAL_COLLECTION); + } + + /** + * This method updates the collection state based on the new item focused by the user. Internally, + * it advances the state machine and then acts on the new state, + */ + public void updateCollectionInformation( + AccessibilityNodeInfoCompat announcedNode, AccessibilityEvent event) { + if (announcedNode == null) { + return; + } + + AccessibilityNodeInfoCompat newCollectionRoot; + if (announcedNode.equals(mCollectionRoot)) { + newCollectionRoot = AccessibilityNodeInfoCompat.obtain(mCollectionRoot); + } else { + AccessibilityNodeInfoCompat announcedNodeParent = announcedNode.getParent(); + try { + newCollectionRoot = AccessibilityNodeInfoUtils.getCollectionRoot(announcedNodeParent); + } finally { + } + } + + // STATE DIAGRAM: + // (None*)--------->(Enter*)--------->(Interior*)--------->(Exit) + // ^ ^ | ^ | | + // | | +---------------------------------> + | | + // | + <--------------------------------------+ | + // + <--------------------------------------------------------+ + // * = can self loop. + + // Perform the state transition. + switch (mCollectionTransition) { + case NAVIGATE_ENTER: + case NAVIGATE_INTERIOR: + if (newCollectionRoot != null && newCollectionRoot.equals(mCollectionRoot)) { + mCollectionTransition = NAVIGATE_INTERIOR; + } else if (newCollectionRoot != null && shouldEnter(newCollectionRoot)) { + mCollectionTransition = NAVIGATE_ENTER; + } else { + mCollectionTransition = NAVIGATE_EXIT; + } + break; + case NAVIGATE_EXIT: + case NAVIGATE_NONE: + default: + if (newCollectionRoot != null && shouldEnter(newCollectionRoot)) { + mCollectionTransition = NAVIGATE_ENTER; + } else { + mCollectionTransition = NAVIGATE_NONE; + } + break; + } + + // Act on the new state. + switch (mCollectionTransition) { + case NAVIGATE_ENTER: + { + // Only recompute workarounds once per collection. + mShouldComputeHeaders = shouldComputeHeaders(newCollectionRoot); + mShouldComputeNumbering = shouldComputeNumbering(newCollectionRoot); + mCollectionLevel = getCollectionLevelInternal(newCollectionRoot); + + ItemState newItemState = null; + if (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) { + // Cache the row and column headers. + updateTableHeaderInfo( + newCollectionRoot, mRowHeaders, mColumnHeaders, mShouldComputeHeaders); + + newItemState = + getTableItemState( + newCollectionRoot, + announcedNode, + mRowHeaders, + mColumnHeaders, + mShouldComputeHeaders, + mShouldComputeNumbering); + } else if (Role.getRole(newCollectionRoot) == Role.ROLE_LIST) { + newItemState = + getListItemState(newCollectionRoot, announcedNode, mShouldComputeNumbering); + } else if (Role.getRole(newCollectionRoot) == Role.ROLE_PAGER) { + newItemState = + extractPagerItemState(newCollectionRoot, announcedNode, mShouldComputeHeaders); + } + + // Row and column change only if we enter and there is collection item information. + if (newItemState == null) { + mRowColumnTransition = TYPE_NONE; + } else { + mRowColumnTransition = TYPE_ROW | TYPE_COLUMN; + } + + mCollectionRoot = newCollectionRoot; + mLastAnnouncedNode = AccessibilityNodeInfoCompat.obtain(announcedNode); + mItemState = newItemState; + break; + } + case NAVIGATE_INTERIOR: + { + ItemState newItemState = null; + if (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) { + newItemState = + getTableItemState( + newCollectionRoot, + announcedNode, + mRowHeaders, + mColumnHeaders, + mShouldComputeHeaders, + mShouldComputeNumbering); + } else if (Role.getRole(newCollectionRoot) == Role.ROLE_LIST) { + newItemState = + getListItemState(newCollectionRoot, announcedNode, mShouldComputeNumbering); + } else if (Role.getRole(newCollectionRoot) == Role.ROLE_PAGER) { + newItemState = + extractPagerItemState(newCollectionRoot, announcedNode, mShouldComputeHeaders); + } + + // Determine if the row and/or column has changed. + if (newItemState == null) { + mRowColumnTransition = TYPE_NONE; + } else if (mItemState == null + || mLastAnnouncedNode == null + || mLastAnnouncedNode.equals(announcedNode)) { + // We want to repeat row/column feedback on refocus *of the exact same node*. + mRowColumnTransition = TYPE_ROW | TYPE_COLUMN; + } else { + mRowColumnTransition = newItemState.getTransition(mItemState); + } + + mCollectionRoot = newCollectionRoot; + mLastAnnouncedNode = AccessibilityNodeInfoCompat.obtain(announcedNode); + mItemState = newItemState; + break; + } + case NAVIGATE_EXIT: + { + // We can clear the item state, but we need to keep the collection root. + mRowColumnTransition = 0; + mLastAnnouncedNode = null; + mItemState = null; + break; + } + case NAVIGATE_NONE: + default: + { + // Safe to clear everything. + mRowColumnTransition = 0; + mCollectionRoot = null; + mLastAnnouncedNode = null; + mItemState = null; + break; + } + } + } + + private static void updateTableHeaderInfo( + AccessibilityNodeInfoCompat collectionRoot, + SparseArray rowHeaders, + SparseArray columnHeaders, + boolean computeHeaders) { + rowHeaders.clear(); + columnHeaders.clear(); + + if (!computeHeaders) { + return; + } + + if (collectionRoot == null || collectionRoot.getCollectionInfo() == null) { + return; + } + + // Limit search to children and grandchildren of the root node for performance reasons. + // We want to search grandchildren because web pages put table headers inside table + // rows so they are nested two levels down. + CollectionInfoCompat collectionInfo = collectionRoot.getCollectionInfo(); + int numChildren = collectionRoot.getChildCount(); + for (int i = 0; i < numChildren; ++i) { + AccessibilityNodeInfoCompat child = collectionRoot.getChild(i); + if (child == null) { + continue; + } + if (!updateSingleTableHeader(child, collectionInfo, rowHeaders, columnHeaders)) { + int numGrandchildren = child.getChildCount(); + for (int j = 0; j < numGrandchildren; ++j) { + AccessibilityNodeInfoCompat grandchild = child.getChild(j); + if (grandchild == null) { + continue; + } + updateSingleTableHeader(grandchild, collectionInfo, rowHeaders, columnHeaders); + } + } + } + } + + private static boolean updateSingleTableHeader( + @Nullable AccessibilityNodeInfoCompat node, + CollectionInfoCompat collectionInfo, + SparseArray rowHeaders, + SparseArray columnHeaders) { + if (node == null) { + return false; + } + + CharSequence headingName = getHeaderText(node); + CollectionItemInfoCompat itemInfo = node.getCollectionItemInfo(); + if (itemInfo != null && headingName != null) { + @RowColumnTransition int headingType = getTableHeading(node, itemInfo, collectionInfo); + if ((headingType & TYPE_ROW) != 0) { + rowHeaders.put(itemInfo.getRowIndex(), headingName); + } + if ((headingType & TYPE_COLUMN) != 0) { + columnHeaders.put(itemInfo.getColumnIndex(), headingName); + } + + return headingType != TYPE_NONE; + } + + return false; + } + + /** + * For finding the name of the header, we want to use a simpler strategy than the + * NodeSpeechRuleProcessor. We don't want to include the role description of items within the + * header, because it will add confusion when the header name is appended to collection items. But + * we do want to search down the tree in case the immediate root element doesn't have text. + * + *

We traverse single children of single children until we find a node with text. If we hit any + * node that has multiple children, we simply stop the search and return {@code null}. + */ + public static @Nullable CharSequence getHeaderText(AccessibilityNodeInfoCompat node) { + if (node == null) { + return null; + } + + Set visitedNodes = new HashSet<>(); + try { + AccessibilityNodeInfoCompat currentNode = AccessibilityNodeInfoCompat.obtain(node); + while (currentNode != null) { + if (!visitedNodes.add(currentNode)) { + // Cycle in traversal. + return null; + } + + CharSequence nodeText = AccessibilityNodeInfoUtils.getNodeText(currentNode); + if (nodeText != null) { + return nodeText; + } + + if (currentNode.getChildCount() != 1) { + return null; + } + + currentNode = currentNode.getChild(0); + } + } finally { + } + + return null; + } + + /** + * In this method, only one cell per row and per column can be the row or column header. + * Additionally, a cell can be a row or column header but not both. + * + * @return {@code TYPE_ROW} or {@ocde TYPE_COLUMN} for row or column headers; {@code + * TYPE_INDETERMINATE} for cells marked as headers that are neither row nor column headers; + * {@code TYPE_NONE} for all other cells. + */ + @TableHeadingType + private static int getTableHeading( + @NonNull AccessibilityNodeInfoCompat node, + @NonNull CollectionItemInfoCompat item, + @NonNull CollectionInfoCompat collection) { + if (AccessibilityNodeInfoUtils.isHeading(node)) { + if (item.getRowSpan() == 1 && item.getColumnSpan() == 1) { + if (getRowIndex(item, collection) == 0) { + return TYPE_COLUMN; + } + if (getColumnIndex(item, collection) == 0) { + return TYPE_ROW; + } + } + return TYPE_INDETERMINATE; + } + + return TYPE_NONE; + } + + /** @return -1 if there is no valid row index for the item; otherwise the item's row index */ + private static int getRowIndex( + @NonNull CollectionItemInfoCompat item, @NonNull CollectionInfoCompat collection) { + if (item.getRowSpan() == collection.getRowCount()) { + return -1; + } else if (item.getRowIndex() < 0) { + return -1; + } else { + return item.getRowIndex(); + } + } + + /** + * @return -1 if there is no valid column index for the item; otherwise the item's column index + */ + private static int getColumnIndex( + @NonNull CollectionItemInfoCompat item, @NonNull CollectionInfoCompat collection) { + if (item.getColumnSpan() == collection.getColumnCount()) { + return -1; + } else if (item.getColumnIndex() < 0) { + return -1; + } else { + return item.getColumnIndex(); + } + } + + private static boolean shouldEnter(@NonNull AccessibilityNodeInfoCompat collectionRoot) { + if (collectionRoot.getCollectionInfo() != null) { + CollectionInfoCompat collectionInfo = collectionRoot.getCollectionInfo(); + if (!hasMultipleItems(collectionInfo.getRowCount(), collectionInfo.getColumnCount())) { + return false; + } + } else if (collectionRoot.getChildCount() <= 1) { + // If we don't have collection info, use the child count as an approximation. + return false; + } + + // If the collection is flat and contains other flat collections, then we discard it. + // We only announce hierarchies of collections if they are explicitly marked hierarchical. + // Otherwise we announce only the innermost collection. + if (FILTER_FLAT_COLLECTION.accept(collectionRoot) + && AccessibilityNodeInfoUtils.hasMatchingDescendant( + collectionRoot, FILTER_FLAT_COLLECTION)) { + return false; + } + + return true; + } + + private static boolean hasMultipleItems(int rows, int columns) { + // Collection size is unknown, this is a valid collection. + if (rows == -1 && columns == -1) { + return true; + } + int numberOfItems = rows * columns; + // Collection size is zero or 1, not a valid a collection + if (numberOfItems == 0 || numberOfItems == 1) { + return false; + } + return true; + } + + /** + * Don't compute headers if: (1) API level is pre-N, and (2) the collection root is not a + * descendant of a WebView, and (3) the collection root is itself a ListView or GridView. + * + *

Under these circumstances, the framework ListView/GridView will mark headers as non-headers + * and vice-versa. + */ + private static boolean shouldComputeHeaders(@NonNull AccessibilityNodeInfoCompat collectionRoot) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + if (!AccessibilityNodeInfoUtils.hasMatchingAncestor(collectionRoot, FILTER_WEBVIEW)) { + // TODO: Convert to use Role. + // Bugs exist in specific classes, so check class names and not roles. + if (AccessibilityNodeInfoUtils.nodeIsListOrGrid(collectionRoot)) { + return false; + } + } + } + + return true; + } + + /** + * Don't compute indices or row/column counts if: (1) API level is pre-N, (2) the collection root + * is not a descendant of a WebView, and (3) the collection root is not a pager. + * + *

Item indices are broken in some major first-party apps that use "spacer" items in + * collections; this check makes sure no apps in the wild are affected. TODO: Re-evaluate + * this check before N release to see if it needs to be extended to N. + * + *

Always compute for pagers. The ability to have pagers with collections was introduced after + * these bugs, and visually pagers should not have "spacer" items. + */ + private static boolean shouldComputeNumbering( + @NonNull AccessibilityNodeInfoCompat collectionRoot) { + if (Role.getRole(collectionRoot) == Role.ROLE_PAGER) { + return true; + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + if (!AccessibilityNodeInfoUtils.hasMatchingAncestor(collectionRoot, FILTER_WEBVIEW)) { + return false; + } + } + + return true; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/Consumer.java b/utils/src/main/java/com/google/android/accessibility/utils/Consumer.java new file mode 100644 index 0000000..bc293cf --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/Consumer.java @@ -0,0 +1,16 @@ +package com.google.android.accessibility.utils; + +/** + * Compat version of {@link java.util.function.Consumer} + * + * @param the type of the input to the operation + */ +public interface Consumer { + + /** + * Performs this operation on the given argument. + * + * @param t the input argument + */ + void accept(T t); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/DarkModeUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/DarkModeUtils.java new file mode 100644 index 0000000..dc6a8cb --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/DarkModeUtils.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2021 Google Inc. + * + * 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.google.android.accessibility.utils; + +import static android.content.res.Configuration.UI_MODE_NIGHT_MASK; +import static android.content.res.Configuration.UI_MODE_NIGHT_YES; + +import android.content.Context; + +/** Utils to use for the related functions of the dark mode. */ +public final class DarkModeUtils { + + public DarkModeUtils() {} + + /** + * Returns whether the ui mode in the context is in dark mode. + * + * @param context The current context. + * @return return true if the device is at dark mode. Always returns false before Q. + */ + public static boolean isDarkModeEnabledInContext(Context context) { + if (!FeatureSupport.supportDarkTheme()) { + return false; + } + + return (context.getResources().getConfiguration().uiMode & UI_MODE_NIGHT_MASK) + == UI_MODE_NIGHT_YES; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/DelayHandler.java b/utils/src/main/java/com/google/android/accessibility/utils/DelayHandler.java new file mode 100644 index 0000000..ae66196 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/DelayHandler.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +import android.os.Handler; +import android.os.Message; + +/** + * A base class to conveniently delay calling a method. + * + *

Example: + * + *


+ * private Delay mDelayMyFunc = new DelayHandler() {
+ *     {@literal @}Override
+ *     handle(MyArgClass argInstance) {
+ *       // Do something
+ *     }
+ *   };
+ * mDelayMyFunc.delay(delayMs, argInstance);
+ * 
+ */ +public abstract class DelayHandler extends Handler { + + private static final int MESSAGE_ID = 1; + + /** Make a delayed call to handle(handlerArg). handlerArg may be null. */ + public void delay(long delayMs, T handlerArg) { + Message message = obtainMessage(MESSAGE_ID, handlerArg); + sendMessageDelayed(message, delayMs); + } + + public void removeMessages() { + removeMessages(MESSAGE_ID); + } + + @Override + public void handleMessage(Message message) { + if (message.what == MESSAGE_ID) { + @SuppressWarnings("unchecked") // message.obj type T enforced by delay(long, T) + T messageObj = (T) message.obj; + handle(messageObj); + } + } + + /** Method that will be called after delay. */ + public abstract void handle(T arg); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/DiagnosticOverlayController.java b/utils/src/main/java/com/google/android/accessibility/utils/DiagnosticOverlayController.java new file mode 100644 index 0000000..8cac6f3 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/DiagnosticOverlayController.java @@ -0,0 +1,25 @@ +package com.google.android.accessibility.utils; + +import com.google.android.accessibility.utils.DiagnosticOverlayUtils.DiagnosticType; +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; + +/** Interface for appending logs to controller */ +public interface DiagnosticOverlayController { + + /** + * Receives and appends log to controller + * + * @param format The format of incoming log + * @param args The log and other debugging objects + */ + // TODO - need to create new data structure for log records + @FormatMethod + void appendLog(@FormatString String format, Object... args); + + /** + * Receives and appends the category of {@link DiagnosticType} and related debugging objects + * {@code args} + */ + void appendLog(@DiagnosticType Integer diagnosticInfo, Object... args); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/DiagnosticOverlayUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/DiagnosticOverlayUtils.java new file mode 100644 index 0000000..dbb9309 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/DiagnosticOverlayUtils.java @@ -0,0 +1,52 @@ +package com.google.android.accessibility.utils; + +import androidx.annotation.IntDef; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Forwards diagnostic information to the set DiagnosticOverlayController */ +public class DiagnosticOverlayUtils { + + public static final int NONE = -1; + public static final int FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS = 0; + public static final int FOCUS_FAIL_NOT_SPEAKABLE = 1; + public static final int FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN = 2; + public static final int FOCUS_FAIL_NOT_VISIBLE = 3; + public static final int SEARCH_FOCUS_FAIL = 4; + + /** + * Types defining what category of debugging controller needs to handle based on information sent + * from {@link AccessibilityNodeInfoUtils#shouldFocusNode} + */ + @IntDef({ + NONE, + FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS, + FOCUS_FAIL_NOT_SPEAKABLE, + FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN, + FOCUS_FAIL_NOT_VISIBLE, + SEARCH_FOCUS_FAIL + }) + @Retention(RetentionPolicy.SOURCE) + public @interface DiagnosticType {}; + + private DiagnosticOverlayUtils() {} + + private static @Nullable DiagnosticOverlayController diagnosticOverlayController = null; + + /** Sets controller for shared utils class */ + public static void setDiagnosticOverlayController( + @Nullable DiagnosticOverlayController controller) { + diagnosticOverlayController = controller; + } + + /** + * Receives and forwards the category of {@link DiagnosticType} and related debugging objects + * {@code args} to the controller. + */ + public static void appendLog(@DiagnosticType Integer diagnosticInfo, Object... args) { + if (diagnosticOverlayController != null) { + diagnosticOverlayController.appendLog(diagnosticInfo, args); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/DisplayUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/DisplayUtils.java new file mode 100644 index 0000000..3e335cd --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/DisplayUtils.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.Point; +import android.util.DisplayMetrics; +import android.view.WindowManager; + +/** View display related utility methods. */ +public final class DisplayUtils { + private DisplayUtils() {} + + /** + * Converts DP to Pixel. + * + * @param context Context instance + * @param dp DP value + * @return equivalent size in pixel + */ + public static int dpToPx(Context context, int dp) { + DisplayMetrics dm = context.getResources().getDisplayMetrics(); + return Math.round(dp * dm.density); + } + + /** Returns screen pixel size excludes navigation bar and status bar area. */ + public static Point getScreenPixelSizeWithoutWindowDecor(Context context) { + Configuration configuration = context.getResources().getConfiguration(); + return new Point( + dpToPx(context, configuration.screenWidthDp), + dpToPx(context, configuration.screenHeightDp)); + } + + /** Returns status bar height in pixel. */ + // TODO: We need to better way to get status bar height. + public static int getStatusBarHeightInPixel(Context context) { + int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android"); + int statusBarHeight = 0; + if (resourceId > 0) { + statusBarHeight = context.getResources().getDimensionPixelSize(resourceId); + } + + return statusBarHeight; + } + + /** + * Returns context with default screen densityDpi.This context is used to keep the layout size as + * per the default screen densityDpi and not based on the display size setting changed by the + * user. + */ + public static Context getDefaultScreenDensityContext(Context context) { + Resources res = context.getResources(); + Configuration configuration = new Configuration(res.getConfiguration()); + + /* get default display density */ + if (BuildVersionUtils.isAtLeastN()) { + configuration.densityDpi = DisplayMetrics.DENSITY_DEVICE_STABLE; + } else { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics dm = new DisplayMetrics(); + wm.getDefaultDisplay().getRealMetrics(dm); + configuration.densityDpi = dm.densityDpi; + } + configuration.setTo(configuration); + + return context.createConfigurationContext(configuration); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/EditTextActionHistory.java b/utils/src/main/java/com/google/android/accessibility/utils/EditTextActionHistory.java new file mode 100644 index 0000000..8748027 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/EditTextActionHistory.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils; + +import android.os.SystemClock; + +/** Maintains a history of when EditText actions occurred. */ +public class EditTextActionHistory { + + /** Read-only interface, for use by event-interpreters. */ + public interface Provider { + public boolean hasPasteActionAtTime(long eventTime); + + public boolean hasCutActionAtTime(long eventTime); + + public boolean hasSelectAllActionAtTime(long eventTime); + } + + /** Instance of read-only interface. */ + public final Provider provider = + new Provider() { + @Override + public boolean hasPasteActionAtTime(long eventTime) { + return EditTextActionHistory.this.hasPasteActionAtTime(eventTime); + } + + @Override + public boolean hasCutActionAtTime(long eventTime) { + return EditTextActionHistory.this.hasCutActionAtTime(eventTime); + } + + @Override + public boolean hasSelectAllActionAtTime(long eventTime) { + return EditTextActionHistory.this.hasSelectAllActionAtTime(eventTime); + } + }; + + /** Map of action identifiers to start times. */ + private long mCutStartTime = -1; + + private long mPasteStartTime = -1; + private long mSelectAllStartTime = -1; + + /** Map of action identifiers to finish times. */ + private long mCutFinishTime = -1; + + private long mPasteFinishTime = -1; + private long mSelectAllFinishTime = -1; + + /** + * Stores the start time for a cut action. This should be called immediately before {@link + * AccessibilityNodeInfoCompat#performAction}. + */ + public void beforeCut() { + mCutStartTime = SystemClock.uptimeMillis(); + } + + /** + * Stores the finish time for a cut action. This should be called immediately after {@link + * AccessibilityNodeInfoCompat#performAction}. + */ + public void afterCut() { + mCutFinishTime = SystemClock.uptimeMillis(); + } + + /** + * Stores the start time for a paste action. This should be called immediately before {@link + * AccessibilityNodeInfoCompat#performAction}. + */ + public void beforePaste() { + mPasteStartTime = SystemClock.uptimeMillis(); + } + + /** + * Stores the finish time for a paste action. This should be called immediately after {@link + * AccessibilityNodeInfoCompat#performAction}. + */ + public void afterPaste() { + mPasteFinishTime = SystemClock.uptimeMillis(); + } + + public void beforeSelectAll() { + mSelectAllStartTime = SystemClock.uptimeMillis(); + } + + public void afterSelectAll() { + mSelectAllFinishTime = SystemClock.uptimeMillis(); + } + + /** + * Returns whether the specified event time falls between the start and finish times of the last + * cut action. + * + * @param eventTime The event time to check. + * @return {@code true} if the event time falls between the start and finish times of the + * specified action. + */ + public boolean hasCutActionAtTime(long eventTime) { + return !((mCutStartTime == -1) || (mCutStartTime > eventTime)) + && !((mCutFinishTime >= mCutStartTime) && (mCutFinishTime < eventTime)); + } + + /** + * Returns whether the specified event time falls between the start and finish times of the last + * paste action. + * + * @param eventTime The event time to check. + * @return {@code true} if the event time falls between the start and finish times of the + * specified action. + */ + public boolean hasPasteActionAtTime(long eventTime) { + return !((mPasteStartTime == -1) || (mPasteStartTime > eventTime)) + && !((mPasteFinishTime >= mPasteStartTime) && (mPasteFinishTime < eventTime)); + } + + public boolean hasSelectAllActionAtTime(long eventTime) { + return !((mSelectAllFinishTime == -1) || (mSelectAllStartTime > eventTime)) + && !((mSelectAllFinishTime >= mSelectAllStartTime) && (mSelectAllFinishTime < eventTime)); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/Event.java b/utils/src/main/java/com/google/android/accessibility/utils/Event.java new file mode 100644 index 0000000..89905a5 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/Event.java @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2018 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import androidx.core.view.accessibility.AccessibilityEventCompat; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * A wrapper around AccessibilityEvent, to help with: + * + *
    + *
  • handling null events + *
  • using compat vs bare methods + *
  • using correct methods for various android versions + *
+ * + *

Also wraps a single instance of AccessibilityNodeInfo/Compat, to help with: + * + *

    + *
  • reduce duplication of source node + *
+ * + *

The event-wrapper contains both an event, and a source node. This way, we don't generate so + * many copies of source node. And similarly, node contains window info. There are a lot of + * pass-through functions which handle null-checking intermediate objects. As a result, there is + * little reason for callers directly use a node or window-info. Just call top-level event + * functions. + */ +public class Event { + + private static final String TAG = "Event"; + + /** + * The wrapped event. There is no compat object, because AccessibilityEventCompat only contains + * static methods. Do not expose this object. + */ + private AccessibilityEvent eventBare; + + private @Nullable AccessibilityNode source; + + /////////////////////////////////////////////////////////////////////////////////////// + // Construction + + /** Takes ownership of eventArg. */ + public static @Nullable Event takeOwnership(@Nullable AccessibilityEvent eventArg) { + return construct(eventArg, /* copy= */ false, FACTORY); + } + + /** Caller keeps ownership of eventArg. */ + public static @Nullable Event obtainCopy(@Nullable AccessibilityEvent eventArg) { + return construct(eventArg, /* copy= */ true, FACTORY); + } + + /** Caller keeps ownership of eventArg. */ + public static @Nullable Event reference(@Nullable AccessibilityEvent eventArg) { + return construct(eventArg, /* copy= */ false, FACTORY); + } + + /** + * Returns an Event instance, or null. Should only be called by this class and sub-classes. + * + *

Uses factory argument to create sub-class instances, without creating unnecessary instances + * when result should be null. Method is protected so that it can be called by sub-classes without + * duplicating null-checking logic. + * + * @param eventArg wrapped event info + * @param copy flag whether to wrap a copy of eventArg + * @param factory creates instances of Event or sub-classes + */ + protected static @Nullable T construct( + @Nullable AccessibilityEvent eventArg, boolean copy, Factory factory) { + if (eventArg == null) { + return null; + } + + T instance = factory.create(); + Event instanceBase = instance; + instanceBase.eventBare = copy ? AccessibilityEvent.obtain(eventArg) : eventArg; + return instance; + } + + protected Event() {} + + /** A factory that can create instances of Event or sub-classes. */ + protected interface Factory { + T create(); + } + + private static final Factory FACTORY = + new Factory() { + @Override + public Event create() { + return new Event(); + } + }; + + /////////////////////////////////////////////////////////////////////////////////////// + // Recycling + + /** + * Returns whether the wrapped event is already recycled. + * + *

TODO: Remove once all dependencies have been removed. + * + * @deprecated Accessibility is discontinuing recycling. Function will return false. + */ + @Deprecated + public final synchronized boolean isRecycled() { + return false; + } + + /** + * Recycles the wrapped node & window. Errors if called more than once. + * + * @deprecated Accessibility is discontinuing recycling. + */ + @Deprecated + public final synchronized void recycle(String caller) {} + + /////////////////////////////////////////////////////////////////////////////////////// + // AccessibilityEvent/Compat methods. Also see: + // https://developer.android.com/reference/android/view/accessibility/AccessibilityEvent + + /** Returns a bitmap of custom actions. See public documentation of AccessibilityEvent. */ + public final int getAction() { + return eventBare.getAction(); + } + + /** Returns a bitmap of content changes. See public documentation of AccessibilityEvent. */ + public final int getContentChangeTypes() { + return AccessibilityEventCompat.getContentChangeTypes(eventBare); + } + + /** Returns an enum of event type. See public documentation of AccessibilityEvent. */ + public final int getEventType() { + return eventBare.getEventType(); + } + + // TODO: Add more methods on demand. Keep alphabetic order. + + /////////////////////////////////////////////////////////////////////////////////////// + // AccessibilityNodeInfo methods on source node. + + /** Returns an instance of source node, kept inside this event wrapper. */ + private final @Nullable AccessibilityNode getSource() { + if (source == null) { + source = AccessibilityNode.takeOwnership(eventBare.getSource()); + } + return source; + } + + /** Returns source node's class name. See public documentation of AccessibilityNodeInfo. */ + public final @Nullable CharSequence sourceGetClassName() { + @Nullable AccessibilityNode sourceNode = getSource(); + return (sourceNode == null) ? null : sourceNode.getClassName(); + } + + /** Returns source node's custom actions. See public documentation of AccessibilityNodeInfo. */ + public final @Nullable List sourceGetActionList() { + @Nullable AccessibilityNode sourceNode = getSource(); + return (sourceNode == null) ? null : sourceNode.getActionList(); + } + + // TODO: Add more methods on demand. Keep alphabetic order. + + /////////////////////////////////////////////////////////////////////////////////////// + // Utility methods. Call AccessibilityEventUtils methods, do not duplicate them. + + /** + * Returns whether the wrapped event matches an event type in typeMask bitmask. See + * AccessibilityEventUtils. + */ + public final boolean eventMatchesAnyType(int typeMask) { + return AccessibilityEventUtils.eventMatchesAnyType(eventBare, typeMask); + } + + /** Returns event description, or falls back to event text. See AccessibilityEventUtils. */ + public final CharSequence getEventTextOrDescription() { + return AccessibilityEventUtils.getEventTextOrDescription(eventBare); + } + + // TODO: Add methods on demand. Keep alphabetic order. + + /////////////////////////////////////////////////////////////////////////////////////// + // Error methods + + /** Overridable for testing. */ + protected boolean isDebug() { + return BuildConfig.DEBUG; + } + + protected void logError(String format, Object... parameters) { + LogUtils.e(TAG, format, parameters); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ExperimentalUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/ExperimentalUtils.java new file mode 100644 index 0000000..b02892a --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ExperimentalUtils.java @@ -0,0 +1,7 @@ +package com.google.android.accessibility.utils; + + +/** Stubs of functions from the unreleased experimental android version. */ +public final class ExperimentalUtils { + private ExperimentalUtils() {} +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ExploreByTouchObjectHelper.java b/utils/src/main/java/com/google/android/accessibility/utils/ExploreByTouchObjectHelper.java new file mode 100644 index 0000000..c9538fd --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ExploreByTouchObjectHelper.java @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.graphics.Rect; +import android.os.Bundle; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityRecordCompat; +import androidx.customview.widget.ExploreByTouchHelper; +import java.util.LinkedList; +import java.util.List; + +/** + * Extension of {@link ExploreByTouchHelper} for custom views that rely on a single class for + * logical units. + * + *

This should be applied to the parent view using {@link ViewCompat#setAccessibilityDelegate}: + * + *

+ * mHelper = new ExploreByTouchHelper(context, someView);
+ * ViewCompat.setAccessibilityDelegate(someView, mHelper);
+ * 
+ */ +public abstract class ExploreByTouchObjectHelper extends ExploreByTouchHelper { + /** + * Constructs a new object-based Explore by Touch helper. + * + * @param parentView The view whose virtual hierarchy is exposed by this helper. + */ + public ExploreByTouchObjectHelper(View parentView) { + super(parentView); + } + + /** + * Populates an event of the specified type with information about an item and attempts to send it + * up through the view hierarchy. + * + *

You should call this method after performing a user action that normally fires an + * accessibility event, such as clicking on an item. + * + *

+   * public void performItemClick(T item) {
+   *   ...
+   *   sendEventForItem(item, AccessibilityEvent.TYPE_VIEW_CLICKED);
+   * }
+   * 
+ * + * @param item The item for which to send an event. + * @param eventType The type of event to send. + * @return {@code true} if the event was sent successfully. + */ + public boolean sendEventForItem(T item, int eventType) { + final int virtualViewId = getVirtualViewIdForItem(item); + return sendEventForVirtualView(virtualViewId, eventType); + } + + @Override + protected final boolean onPerformActionForVirtualView( + int virtualViewId, int action, Bundle arguments) { + final T item = getItemForVirtualViewId(virtualViewId); + return item != null && performActionForItem(item, action); + } + + @Override + protected final void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) { + final T item = getItemForVirtualViewId(virtualViewId); + if (item == null) { + return; + } + + populateEventForItem(item, event); + event.setClassName(item.getClass().getName()); + } + + @Override + protected final void onPopulateNodeForVirtualView( + int virtualViewId, AccessibilityNodeInfoCompat node) { + final T item = getItemForVirtualViewId(virtualViewId); + if (item == null) { + return; + } + + populateNodeForItem(item, node); + node.setClassName(item.getClass().getName()); + } + + @Override + protected final void getVisibleVirtualViews(List virtualViewIds) { + final List items = new LinkedList<>(); + getVisibleItems(items); + + for (T item : items) { + final int virtualViewId = getVirtualViewIdForItem(item); + virtualViewIds.add(virtualViewId); + } + } + + @Override + protected final int getVirtualViewAt(float x, float y) { + final T item = getItemAt(x, y); + if (item == null) { + return INVALID_ID; + } + + return getVirtualViewIdForItem(item); + } + + /** + * Performs an accessibility action on the specified item. See {@link + * AccessibilityNodeInfoCompat#performAction(int, Bundle)}. + * + *

Developers must handle any actions added manually in {@link #populateNodeForItem}. + * + *

The helper class automatically handles focus management resulting from {@link + * AccessibilityNodeInfoCompat#ACTION_ACCESSIBILITY_FOCUS} and {@link + * AccessibilityNodeInfoCompat#ACTION_CLEAR_ACCESSIBILITY_FOCUS}. + * + * @param item The item on which to perform the action. + * @param action The accessibility action to perform. + * @return {@code true} if the action was performed successfully. + */ + protected abstract boolean performActionForItem(T item, int action); + + /** + * Populates an event with information about the specified item. + * + *

Developers must populate the following required fields: + * + *

    + *
  • event content, see {@link AccessibilityEvent#getText()} or {@link + * AccessibilityEvent#setContentDescription} + *
+ * + *

The helper class automatically populates some required fields: + * + *

    + *
  • package name, see {@link AccessibilityEvent#setPackageName} + *
  • item class name, see {@link AccessibilityEvent#setClassName} + *
  • event source, see {@link AccessibilityRecordCompat#setSource(View, int)} + *
+ * + * @param item The item for which to populate the event. + * @param event The event to populate. + */ + protected abstract void populateEventForItem(T item, AccessibilityEvent event); + + /** + * Populates a node with information about the specified item. + * + *

Developers must populate the following required fields: + * + *

    + *
  • node content, see {@link AccessibilityNodeInfoCompat#setText} or {@link + * AccessibilityNodeInfoCompat#setContentDescription} + *
  • parent-relative bounds, see {@link AccessibilityNodeInfoCompat#setBoundsInParent} + *
+ * + *

The helper class automatically populates some required fields: + * + *

    + *
  • package name, see {@link AccessibilityNodeInfoCompat#setPackageName} + *
  • item class name, see {@link AccessibilityNodeInfoCompat#setClassName} + *
  • parent view, see {@link AccessibilityNodeInfoCompat#setParent(View)} + *
  • node source, see {@link AccessibilityNodeInfoCompat#setSource(View, int)} + *
  • visibility, see {@link AccessibilityNodeInfoCompat#setVisibleToUser} + *
  • screen-relative bounds, see {@link AccessibilityNodeInfoCompat#setBoundsInScreen(Rect)} + *
+ * + *

The helper class also automatically handles accessibility focus management by adding one of: + * + *

    + *
  • {@link AccessibilityNodeInfoCompat#ACTION_ACCESSIBILITY_FOCUS} + *
  • {@link AccessibilityNodeInfoCompat#ACTION_CLEAR_ACCESSIBILITY_FOCUS} + *
+ * + * @param item The item for which to populate the node. + * @param node The node to populate. + */ + protected abstract void populateNodeForItem(T item, AccessibilityNodeInfoCompat node); + + /** + * Populates a list with the parent view's visible items. + * + * @param items The list to populate with visible items. + */ + protected abstract void getVisibleItems(List items); + + /** + * Returns the item under the specified parent-relative coordinates. + * + * @param x The parent-relative x coordinate. + * @param y The parent-relative y coordinate. + * @return The item under coordinates (x,y). + */ + protected abstract T getItemAt(float x, float y); + + /** + * Returns the unique identifier for an item. If the specified item does not exist, returns {@link + * #INVALID_ID}. + * + *

Developers must provide a one-to-one mapping consistent with the result of {@link + * #getItemForVirtualViewId}. + * + * @param item The item whose identifier to return. + * @return A unique identifier, or {@link #INVALID_ID}. + */ + protected abstract int getVirtualViewIdForItem(T item); + + /** + * Returns the item for a unique identifier. If the specified item does not exist, or if the + * specified identifier is {@link #INVALID_ID}, returns {@code null}. + * + *

Developers must provide a one-to-one mapping consistent with the result of {@link + * #getVirtualViewIdForItem}. + * + * @param id The identifier for the item to return. + * @return An item, or {@code null}. + */ + protected abstract T getItemForVirtualViewId(int id); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/FeatureSupport.java b/utils/src/main/java/com/google/android/accessibility/utils/FeatureSupport.java new file mode 100644 index 0000000..96ed253 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/FeatureSupport.java @@ -0,0 +1,346 @@ +/* + * Copyright (C) 2018 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.google.android.accessibility.utils; + +import static android.content.Context.SENSOR_SERVICE; +import static android.content.Context.VIBRATOR_SERVICE; + +import android.Manifest; +import android.accessibilityservice.AccessibilityServiceInfo; +import android.annotation.SuppressLint; +import android.app.UiModeManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.hardware.Sensor; +import android.hardware.SensorManager; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.os.Vibrator; +import android.util.Log; +import android.view.accessibility.AccessibilityManager; +import android.view.accessibility.AccessibilityWindowInfo; +import com.google.android.libraries.accessibility.utils.log.LogUtils; + +/** Methods to check hardware and software support for operating system features. */ +public final class FeatureSupport { + + public static boolean isWatch(Context context) { + return context + .getApplicationContext() + .getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_WATCH); + } + + public static boolean isArc() { + return (Build.DEVICE != null) && Build.DEVICE.matches(".+_cheets|cheets_.+"); + } + + public static boolean isTv(Context context) { + if (context == null) { + return false; + } + + UiModeManager modeManager = (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE); + return ((modeManager != null) + && (modeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION)); + } + + public static boolean isPhoneOrTablet(Context context) { + return (!isWatch(context) && !isArc() && !isTv(context)); + } + + /** Returns {@code true} if the device supports accessibility shortcut. */ + public static boolean hasAccessibilityShortcut(Context context) { + return isPhoneOrTablet(context) && BuildVersionUtils.isAtLeastO(); + } + + public static boolean useSpeakPasswordsServicePref() { + return BuildVersionUtils.isAtLeastO(); + } + + /** Returns {@code true} for devices which have separate audio a11y stream. */ + public static boolean hasAccessibilityAudioStream(Context context) { + return BuildVersionUtils.isAtLeastO() && !isTv(context); + } + + /** Return whether fingerprint gesture is supported on this device. */ + public static boolean isFingerprintGestureSupported(Context context) { + // Fingerprint gesture is supported since O. + boolean supportFingerprint = isFingerprintSupported(context); + if (context == null || !BuildVersionUtils.isAtLeastO() || !supportFingerprint) { + return false; + } + int fingerprintSupportsGesturesResID = + context + .getResources() + .getIdentifier("config_fingerprintSupportsGestures", "bool", "android"); + return fingerprintSupportsGesturesResID != 0 + && context.getResources().getBoolean(fingerprintSupportsGesturesResID); + } + + /** Return whether fingerprint feature is supported on this device. */ + private static boolean isFingerprintSupported(Context context) { + if (context == null || isWatch(context)) { + return false; + } + return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_FINGERPRINT); + } + + /** Return whether vibrator is supported on this device. */ + public static boolean isVibratorSupported(Context context) { + final Vibrator vibrator = + (context == null) ? null : (Vibrator) context.getSystemService(VIBRATOR_SERVICE); + return (vibrator != null) && vibrator.hasVibrator(); + } + + /** Return whether VibrationEffect is supported on this device. */ + public static boolean supportVibrationEffect() { + return BuildVersionUtils.isAtLeastO(); + } + + public static boolean disableAnimation() { + return BuildVersionUtils.isAtLeastP(); + } + + public static boolean supportReadClipboard() { + return !BuildVersionUtils.isAtLeastQ(); + } + + public static boolean screenshotRequiresForeground() { + return Build.VERSION.SDK_INT == VERSION_CODES.Q; + } + + /** + * Returns {@code true} if the device supports takeScreenshot by AccessibilityService native API. + */ + public static boolean canTakeScreenShotByAccessibilityService() { + return BuildVersionUtils.isAtLeastR(); + } + + public static boolean supportNotificationChannel() { + return BuildVersionUtils.isAtLeastO(); + } + + public static boolean isHeadingWorks() { + return BuildVersionUtils.isAtLeastN(); + } + + /** Returns {@code true} if the device supports {@link AccessibilityWindowInfo#getTitle()}. */ + public static boolean supportGetTitleFromWindows() { + return BuildVersionUtils.isAtLeastN(); + } + + public static boolean supportSwitchToInputMethod() { + return BuildVersionUtils.isAtLeastR(); + } + + public static boolean supportContentDescriptionInReplacementSpan() { + return BuildVersionUtils.isAtLeastR(); + } + + /** Returns {@code true} if the device supports system actions. */ + public static boolean supportSystemActions(Context context) { + return BuildVersionUtils.isAtLeastR() && !isWatch(context); + } + + public static boolean supportMediaControls() { + return BuildVersionUtils.isAtLeastR(); + } + + /** Returns {@code true} if the device supports brightness float. */ + public static boolean supportBrightnessFloat() { + return BuildVersionUtils.isAtLeastR(); + } + + /** + * Returns {@code true} if the device supports {@link + * AccessibilityService#MagnificationController}. + */ + public static boolean supportMagnificationController() { + return BuildVersionUtils.isAtLeastN(); + } + + /** + * Returns {@code true} if the device should announce magnification state when + * onMagnificationChanged() is called. In S, window magnification is available but the + * onMagnificationChanged listener doesn't support this yet. To prevent user confusing, this is + * blocked after S. + */ + // TODO: framework support onMagnificationChanged() for Window magnification at next + // Android. + public static boolean supportAnnounceMagnificationChanged() { + return BuildVersionUtils.isAtLeastN() && Build.VERSION.SDK_INT != VERSION_CODES.S; + } + + public static boolean isBoundsScaledUpByMagnifier() { + return BuildVersionUtils.isAtLeastOMR1(); + } + + public static boolean supportMultiDisplay() { + return BuildVersionUtils.isAtLeastR(); + } + + /** + * Returns {@code true} if the device requires the phone permission granted to access the call + * state. {@link Manifest.permission#READ_PHONE_STATE} + */ + public static boolean callStateRequiresPermission() { + return BuildVersionUtils.isAtLeastS(); + } + + /** + * Returns {@code true} if all the insets will be reported to the window regarding the z-order. + * {@link android.view.WindowManager.LayoutParams#receiveInsetsIgnoringZOrder} + */ + public static boolean supportReportingInsetsByZOrder() { + return BuildVersionUtils.isAtLeastS(); + } + + /** Returns {@code true} if the device supports customizing focus indicator. */ + public static boolean supportCustomizingFocusIndicator() { + return BuildVersionUtils.isAtLeastS(); + } + + /** + * Provides a function to check if support the dark theme. This feature is supported from Q (API + * 29). + * + * @return {@code true} if the device supports dark theme. + */ + public static boolean supportDarkTheme() { + return BuildVersionUtils.isAtLeastQ(); + } + + /** + * Returns {@code true} if the device supports closing shades when starting an activity. See + * {@link Intent#ACTION_CLOSE_SYSTEM_DIALOGS}. + */ + public static boolean startActivityClosesShades() { + return BuildVersionUtils.isAtLeastS(); + } + + public static boolean supportPassthrough() { + return BuildVersionUtils.isAtLeastR(); + } + + public static boolean supportSettingsTheme() { + return BuildVersionUtils.isAtLeastS(); + } + + /** + * Provides a Talkback menu item to manually enter or change a percentage value for seek controls. + * This functionality is only available on Android N and later. REFERTO. + * + * @return {@code true} if the device supports change slider + */ + public static boolean supportChangeSlider() { + return BuildVersionUtils.isAtLeastN(); + } + + /** + * Returns {@code true} if the device supports {@link + * AccessibilityManager#getRecommendedTimeoutMillis(int, int)}}. + */ + public static boolean supportRecommendedTimeout() { + return BuildVersionUtils.isAtLeastQ(); + } + + /** Returns true if the runtime supports full multi-finger gesture support. */ + @SuppressLint("NewApi") + public static boolean isMultiFingerGestureSupported() { + return BuildVersionUtils.isAtLeastR() + && AccessibilityServiceInfo.flagToString( + AccessibilityServiceInfo.FLAG_REQUEST_2_FINGER_PASSTHROUGH) + != null; + } + + /** + * From Android S and forward, platform extends the multi-finger gestures with + * GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD(43) GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD(44) + * GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD(45) + * + * @return {@code true} if the device supports multi-finger gesture + */ + public static boolean multiFingerTapAndHold() { + return BuildVersionUtils.isAtLeastS(); + } + + /** Returns {@code true} if the device supports customizing bullet radius. */ + public static boolean customBulletRadius() { + return BuildVersionUtils.isAtLeastP(); + } + + /** Returns {@code true} if the device supports sending motion events of gestures. */ + public static boolean supportGestureMotionEvents() { + return BuildVersionUtils.isAtLeastS() + && AccessibilityServiceInfo.flagToString(AccessibilityServiceInfo.FLAG_SEND_MOTION_EVENTS) + != null; + } + + /** Returns {@code true} if the device supports long version code. */ + public static boolean supportLongVersionCode() { + return BuildVersionUtils.isAtLeastP(); + } + + /** + * Supports accessibility button from Android O. * + * + *

Note: Caller should use {@link AccessibilityButtonMonitor} to know whether + * the button is available right now. + * + * @return {@code true} if the device supports accessibility button + */ + public static boolean supportAccessibilityButton() { + return BuildVersionUtils.isAtLeastO(); + } + + /** Returns {@code true} if the device supports accessibility multi-display. */ + public static boolean supportAccessibilityMultiDisplay() { + return BuildVersionUtils.isAtLeastR(); + } + + /** Returns {@code true} if the device has proximity sensor built-in. */ + public static boolean supportProximitySensor(Context context) { + return ((SensorManager) context.getSystemService(SENSOR_SERVICE)) + .getDefaultSensor(Sensor.TYPE_PROXIMITY) + != null; + } + + /** + * Returns {@code true} if the order of receiving touch interaction event and hover event is NOT + * guaranteed. + * + *

Related event type: + * + *

    + *
  • TYPE_TOUCH_INTERACTION_END, + *
  • TYPE_VIEW_HOVER_ENTER + *
+ */ + public static boolean hoverEventOutOfOrder() { + return !BuildVersionUtils.isAtLeastR(); + } + + /** + * Returns true if potentially sensitive information (such as tts text) is allowed to appear in + * logcat. + */ + public static boolean logcatIncludePsi() { + return BuildConfig.DEBUG || (LogUtils.getLogLevel() < Log.ERROR); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/Filter.java b/utils/src/main/java/com/google/android/accessibility/utils/Filter.java new file mode 100644 index 0000000..75a0b77 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/Filter.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.common.base.Predicate; +import java.util.LinkedList; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Filters objects of type T. */ +public abstract class Filter { + /** + * Returns whether the specified object matches the filter. + * + * @param obj The object to filter. + * @return {@code true} if the object is accepted. + */ + public abstract boolean accept(T obj); + + /** + * Returns the logical AND of this and the specified filter. + * + * @param filter The filter to AND this filter with. + * @return A filter where calling accept() returns the result of + * (this.accept() && filter.accept()). + */ + public Filter and(@Nullable Filter filter) { + if (filter == null) { + return this; + } + + return new FilterAnd(this, filter); + } + + /** + * Returns the logical OR of this and the specified filter. + * + * @param filter The filter to OR this filter with. + * @return A filter where calling accept() returns the result of + * (this.accept() || filter.accept()). + */ + public Filter or(@Nullable Filter filter) { + if (filter == null) { + return this; + } + + return new FilterOr(this, filter); + } + + /** Concise filter from a lambda: new Filter.NodeCompat((n) -> n.attribute() == X) */ + public static class NodeCompat extends Filter { + // TODO: Replace com.google.common.base.Predicate with import + // java.util.function.Predicate when --config=android_java8_libs is no longer needed. + private final Predicate predicate; + + public NodeCompat(Predicate predicate) { + this.predicate = predicate; + } + + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return predicate.apply(node); + } + } + + private static class FilterAnd extends Filter { + private final LinkedList> mFilters = new LinkedList<>(); + + public FilterAnd(Filter lhs, Filter rhs) { + mFilters.add(lhs); + mFilters.add(rhs); + } + + @Override + public boolean accept(T obj) { + for (Filter filter : mFilters) { + if (!filter.accept(obj)) { + return false; + } + } + + return true; + } + + @Override + public FilterAnd and(@Nullable Filter filter) { + if (filter != null) { + mFilters.add(filter); + } + + return this; + } + } + + private static class FilterOr extends Filter { + private final LinkedList> mFilters = new LinkedList<>(); + + public FilterOr(Filter lhs, Filter rhs) { + mFilters.add(lhs); + mFilters.add(rhs); + } + + @Override + public boolean accept(T obj) { + for (Filter filter : mFilters) { + if (filter.accept(obj)) { + return true; + } + } + + return false; + } + + @Override + public FilterOr or(@Nullable Filter filter) { + if (filter != null) { + mFilters.add(filter); + } + + return this; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/FocusFinder.java b/utils/src/main/java/com/google/android/accessibility/utils/FocusFinder.java new file mode 100644 index 0000000..f0167e2 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/FocusFinder.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2014 Google Inc. + * + * 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.google.android.accessibility.utils; + +import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_ACCESSIBILITY; +import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT; + +import android.accessibilityservice.AccessibilityService; +import android.view.accessibility.AccessibilityNodeInfo; +import androidx.annotation.IntDef; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Functions to find focus. + * + *

NOTE: To give a consistent behaviour, this code should be kept in sync with the relevant + * subset of code in the {@code CursorController} class in TalkBack. + */ +public class FocusFinder { + + private static final String TAG = "FocusFinder"; + private final AccessibilityService service; + + /** Screen focus types in accessibility. */ + @IntDef({FOCUS_INPUT, FOCUS_ACCESSIBILITY}) + @Retention(RetentionPolicy.SOURCE) + public @interface FocusType {} + + public FocusFinder(AccessibilityService service) { + this.service = service; + } + + /** Finds the view that has the specified focus type. The type is defined in {@link FocusType}. */ + public @Nullable AccessibilityNodeInfoCompat findFocusCompat(@FocusType int focusType) { + switch (focusType) { + case FOCUS_ACCESSIBILITY: + return FocusFinder.getAccessibilityFocusNode(service, false); + case FOCUS_INPUT: + return AccessibilityNodeInfoUtils.toCompat( + service.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)); + default: // fall out + } + + return null; + } + + /** + * Returns the accessibility focus by calling {@link AccessibilityService#findFocus(int)}. If no + * focus is found, it allows to return the root node of the active window. + * + * @param fallbackOnRoot true for returning the root node if no focus is found. + *

Note: Caller is responsible for recycling the returned node. + */ + public static @Nullable AccessibilityNodeInfoCompat getAccessibilityFocusNode( + AccessibilityService service, boolean fallbackOnRoot) { + AccessibilityNodeInfo focused = null; + AccessibilityNodeInfo root = null; + + try { + AccessibilityNodeInfo ret = null; + focused = service.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY); + if (focused == null) { + // Find the focused node from the root of the active window, as a alternative method if + // couldn't find the focused node by AccessibilityService. + root = service.getRootInActiveWindow(); + if (root != null) { + focused = root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY); + } + } + + if (focused != null) { + // If the focused node is from WebView, we still need to return it even though it's not + // visible to user. REFERTO for details. + if (focused.isVisibleToUser() + || WebInterfaceUtils.isWebContainer(AccessibilityNodeInfoUtils.toCompat(focused))) { + ret = focused; + focused = null; + } + } + + if (ret == null && fallbackOnRoot) { + ret = service.getRootInActiveWindow(); + if (ret == null) { + LogUtils.e(TAG, "No current window root"); + } + } + + if (ret != null) { + // When AccessibilityNodeProvider is used, the returned node may be stale. + boolean exist = ret.refresh(); + if (!exist) { + AccessibilityNodeInfoUtils.recycleNodes(ret); + return null; + } + return AccessibilityNodeInfoUtils.toCompat(ret); + } + } finally { + AccessibilityNodeInfoUtils.recycleNodes(focused, root); + } + + return null; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ImageContents.java b/utils/src/main/java/com/google/android/accessibility/utils/ImageContents.java new file mode 100644 index 0000000..1420daf --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ImageContents.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2021 Google Inc. + * + * 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.google.android.accessibility.utils; + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.caption.ImageCaptionStorage; +import com.google.android.accessibility.utils.caption.ImageNode; +import com.google.android.accessibility.utils.labeling.Label; +import com.google.android.accessibility.utils.labeling.LabelManager; +import java.util.Locale; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * A wrapper around LabelManager and ImageCaptionStorage to provide custom labels and the results of + * image captions. + */ +public class ImageContents { + + private final LabelManager labelManager; + private final ImageCaptionStorage imageCaptionStorage; + private @Nullable Locale currentSpeechLocale; + + public ImageContents(LabelManager labelManager, ImageCaptionStorage imageCaptionStorage) { + this.labelManager = labelManager; + this.imageCaptionStorage = imageCaptionStorage; + } + + /** + * Retrieves custom labels from the database. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public @Nullable String getLabel(AccessibilityNodeInfoCompat node) { + if (labelManager == null) { + return null; + } + final Label label = labelManager.getLabelForViewIdFromCache(node.getViewIdResourceName()); + return (label == null || label.getText() == null) ? null : label.getText(); + } + + /** + * Retrieves the results of image captions from the cache. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public @Nullable CharSequence getCaptionResult(AccessibilityNodeInfoCompat node) { + if (imageCaptionStorage == null) { + return null; + } + final @Nullable ImageNode captionResult = imageCaptionStorage.getCaptionResults(node); + return (captionResult == null || captionResult.getOcrText() == null) + ? null + : captionResult.getOcrText(); + } + + /** + * Retrieves the localized label of the detected icon which matches the specified node. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public @Nullable CharSequence getDetectedIconLabel( + Locale locale, AccessibilityNodeInfoCompat node) { + if (imageCaptionStorage == null) { + return null; + } + + // Clears all cached ImageNodes when current speech locale has changed + if (!locale.equals(currentSpeechLocale)) { + if (currentSpeechLocale != null) { + imageCaptionStorage.clearImageNodesCache(); + } + currentSpeechLocale = locale; + } + + CharSequence detectedIconLabel = imageCaptionStorage.getDetectedIconLabel(locale, node); + if (detectedIconLabel == null) { + ImageNode imageNode = imageCaptionStorage.getCaptionResults(node); + if (imageNode != null) { + detectedIconLabel = imageNode.getDetectedIconLabel(); + } + } else { + AccessibilityNode wrapNode = AccessibilityNode.obtainCopy(node); + try { + imageCaptionStorage.updateDetectedIconLabel(wrapNode, detectedIconLabel); + } finally { + AccessibilityNode.recycle("ImageContents.getDetectedIconLabel()", wrapNode); + } + } + return detectedIconLabel; + } + + /** + * Checks if the node needs a label. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public boolean needsLabel(AccessibilityNodeInfoCompat node) { + return labelManager != null && labelManager.needsLabel(node); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/JsonUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/JsonUtils.java new file mode 100644 index 0000000..7d453fe --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/JsonUtils.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2015 Google Inc. + * + * 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.google.android.accessibility.utils; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.content.Context; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public class JsonUtils { + + public static JSONObject readFromRawFile(Context context, int rawFileResId) + throws IOException, JSONException { + try (InputStream stream = context.getResources().openRawResource(rawFileResId); + BufferedReader reader = new BufferedReader(new InputStreamReader(stream, UTF_8))) { + StringBuilder stringBuilder = new StringBuilder(); + String input; + while ((input = reader.readLine()) != null) { + stringBuilder.append(input).append("\n"); + } + return new JSONObject(stringBuilder.toString()); + } + } + + public static @Nullable String getString(JSONObject jsonObject, String key) throws JSONException { + if (jsonObject != null && jsonObject.has(key)) { + return jsonObject.getString(key); + } + + return null; + } + + public static int getInt(JSONObject jsonObject, String key) throws JSONException { + if (jsonObject != null && jsonObject.has(key)) { + return jsonObject.getInt(key); + } + + return -1; + } + + public static @Nullable JSONArray getJsonArray(JSONObject jsonObject, String key) + throws JSONException { + if (jsonObject != null && jsonObject.has(key)) { + return jsonObject.getJSONArray(key); + } + + return null; + } + + public static @Nullable JSONObject getJsonObject(JSONObject jsonObject, String key) + throws JSONException { + if (jsonObject != null && jsonObject.has(key)) { + return jsonObject.getJSONObject(key); + } + + return null; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/LocaleUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/LocaleUtils.java new file mode 100644 index 0000000..2089f32 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/LocaleUtils.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.LocaleSpan; +import java.util.Locale; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Manages parsing for the locale. */ +public class LocaleUtils { + + public static final String LANGUAGE_EN = "en"; + + // Extracts the locale. Keeps language and country but drops the variant. + // Returns null on failure. + public static @Nullable Locale parseLocaleString(String localeString) { + if (TextUtils.isEmpty(localeString)) { + return null; + } + String[] localeParts = localeString.split("_", 3); + + if (localeParts.length >= 2) { + return new Locale(localeParts[0], localeParts[1]); + } else if (localeParts.length >= 1) { + return new Locale(localeParts[0]); + } else { + return null; + } + } + + /** + * Wraps the {@link text} with {@link preferredLocale}. If a LocaleSpan is already attached to the + * {@link text}, {@link SpannableString#setSpan} will add a second LocaleSpan. + */ + public static @Nullable CharSequence wrapWithLocaleSpan( + @Nullable CharSequence text, @Nullable Locale preferredLocale) { + if (text != null && preferredLocale != null) { + SpannableString textToBeWrapped = new SpannableString(text); + textToBeWrapped.setSpan(new LocaleSpan(preferredLocale), 0, textToBeWrapped.length(), 0); + return textToBeWrapped; + } + return text; + } + + public static String getDefaultLocale() { + String locale = Locale.getDefault().toString(); + return getLanguageLocale(locale); + } + + public static String getLanguageLocale(String locale) { + if (locale != null) { + int localeDivider = locale.indexOf('_'); + if (localeDivider > 0) { + return locale.substring(0, localeDivider); + } + } + + return locale; + } + + public static boolean isDefaultLocale(String targetLocale) { + return TextUtils.equals(getDefaultLocale(), targetLocale); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/LogDepth.java b/utils/src/main/java/com/google/android/accessibility/utils/LogDepth.java new file mode 100644 index 0000000..897d96f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/LogDepth.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2020 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.util.Log; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Utility class to arrange verbose log depths before calling {@link LogUtils}. */ +public class LogDepth { + + private LogDepth() {} + + /** + * Log verbose variables by depths. + * + * @param tag The tag that should be associated with the event + * @param depth Add more indents before the log if depth increased + * @param variableName The name of the variable + * @param variableValue The value of the variable + */ + public static void logVar( + String tag, int depth, String variableName, @Nullable Object variableValue) { + log(tag, depth, "%s=%s", variableName, variableValue); + } + + /** + * Log verbose function name by depths. + * + * @param tag The tag that should be associated with the event + * @param depth Add more indents before the log if depth increased + * @param functionName The name of the function + */ + public static void logFunc(String tag, int depth, String functionName) { + log(tag, depth, "%s()", functionName); + } + + /** + * Log verbose string by depths. + * + * @param tag The tag that should be associated with the event + * @param depth Add two space indents before the log if depth increased + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + @FormatMethod + public static void log( + String tag, int depth, @FormatString String format, @Nullable Object... args) { + if (LogUtils.shouldLog(Log.VERBOSE) && depth >= 0) { + String indent = StringBuilderUtils.repeatChar(' ', depth * 2); + String messageStr = String.format(format, args); + LogUtils.v(tag, "%s %s", indent, messageStr); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/MaterialComponentUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/MaterialComponentUtils.java new file mode 100644 index 0000000..30b23ba --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/MaterialComponentUtils.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2021 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.content.Context; +import androidx.appcompat.app.AlertDialog; +import android.widget.Button; + +/** Utility class for Material component */ +public class MaterialComponentUtils { + /** Types of Button style. */ + public enum ButtonStyle { + FILLED_BUTON, + OUTLINED_BUTTON, + DEFAULT_BUTTON, + } + + /** + * Decides if support Material component. Always returns false because it doesn't support. + * + * @return the value to decide if support Material component. + */ + public static boolean supportMaterialComponent() { + return false; + } + + /** + * Creates {@link AlertDialog.Builder} and apply theme. Also customize the Builder so that, the + * dialog buttons can adapt the color of foreground text, when the input focus changed, to comply + * the contrast criteria. + * + * @param context The current context + * @return {@code AlertDialog.Builder} return AlertDialog.Builder + */ + public static AlertDialog.Builder alertDialogBuilder(Context context) { + return AlertDialogAdaptiveContrastUtil.v7AlertDialogAdaptiveContrastBuilder( + context, R.style.A11yAlertDialogTheme); + } + + /** + * Creates {@link Button} since it doesn't support Material component. + * + * @param context The current context + * @param buttonStyle The type of button style + * @return {@code Button} return Button + */ + public static Button createButton(Context context, ButtonStyle buttonStyle) { + return new Button(context); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/MotionEventUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/MotionEventUtils.java new file mode 100644 index 0000000..9c91d3d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/MotionEventUtils.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.view.MotionEvent; +import com.google.android.accessibility.utils.compat.view.InputDeviceCompatUtils; +import com.google.android.accessibility.utils.compat.view.MotionEventCompatUtils; + +/** Utility class for motion event. */ +public class MotionEventUtils { + /** + * Converts a hover {@link MotionEvent} to touch event by changing its + * action and source. Returns an modified clone of the original event. + *

+ * The following types are affected: + *

    + *
  • {@link MotionEventCompatUtils#ACTION_HOVER_ENTER} + *
  • {@link MotionEventCompatUtils#ACTION_HOVER_MOVE} + *
  • {@link MotionEventCompatUtils#ACTION_HOVER_EXIT} + *
+ * + * @param hoverEvent The hover event to convert. + * @return a touch event + */ + public static MotionEvent convertHoverToTouch(MotionEvent hoverEvent) { + final MotionEvent touchEvent = MotionEvent.obtain(hoverEvent); + MotionEventCompatUtils.setSource(hoverEvent, InputDeviceCompatUtils.SOURCE_TOUCHSCREEN); + + switch (hoverEvent.getAction()) { + case MotionEventCompatUtils.ACTION_HOVER_ENTER: + touchEvent.setAction(MotionEvent.ACTION_DOWN); + break; + case MotionEventCompatUtils.ACTION_HOVER_MOVE: + touchEvent.setAction(MotionEvent.ACTION_MOVE); + break; + case MotionEventCompatUtils.ACTION_HOVER_EXIT: + touchEvent.setAction(MotionEvent.ACTION_UP); + break; + } + + return touchEvent; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/NodeActionFilter.java b/utils/src/main/java/com/google/android/accessibility/utils/NodeActionFilter.java new file mode 100644 index 0000000..d5a2885 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/NodeActionFilter.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2018 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.google.android.accessibility.utils; + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; + +/** + * Convenience class for a {@link Filter} that checks whether nodes + * support a specific action. + */ +public class NodeActionFilter extends Filter { + private final int action; + + /** + * Creates a new action filter with the specified action mask. + * + * @param action The ID of the action to accept. + */ + public NodeActionFilter(int action) { + this.action = action; + } + + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return AccessibilityNodeInfoUtils.supportsAction(node, action); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/PackageManagerUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/PackageManagerUtils.java new file mode 100644 index 0000000..3ad2313 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/PackageManagerUtils.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.text.TextUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Utilities for interacting with the {@link PackageManager}. */ +public class PackageManagerUtils { + private static final String TAG = "PackageManagerUtils"; + + /** Invalid version code for a package. */ + private static final int INVALID_VERSION_CODE = -1; + + /** talkback-package-name constants */ + public static final String TALBACK_PACKAGE = BuildConfig.TALKBACK_APPLICATION_ID; + + /** TalkBack service name constant */ + public static final String TALKBACK_SERVICE_NAME = + "com.google.android.marvin.talkback.TalkBackService"; + + /** gmscore-package-name constants */ + private static final String GMSCORE_PACKAGE_NAME = "com.google.android.gms"; + + private static final int MIN_GMSCORE_VERSION = 9200000; // Version should be at least V4. + + /** + * @return The package version code or {@link #INVALID_VERSION_CODE} if the package does not + * exist. + */ + public static long getVersionCode(Context context, CharSequence packageName) { + if (TextUtils.isEmpty(packageName)) { + return INVALID_VERSION_CODE; + } + + final PackageInfo packageInfo = getPackageInfo(context, packageName); + + if (packageInfo == null) { + LogUtils.e(TAG, "Could not find package: %s", packageName); + return INVALID_VERSION_CODE; + } + + return packageInfo.versionCode; + } + + /** @return The package version name or null if the package does not exist. */ + public static @Nullable String getVersionName(Context context) { + String packageName = context.getPackageName(); + return (packageName == null) ? null : getVersionName(context, packageName); + } + + public static @Nullable String getVersionName(Context context, CharSequence packageName) { + if (TextUtils.isEmpty(packageName)) { + return null; + } + + final PackageInfo packageInfo = getPackageInfo(context, packageName); + + if (packageInfo == null) { + LogUtils.e(TAG, "Could not find package: %s", packageName); + return null; + } + + return packageInfo.versionName; + } + + /** @return Whether the package is installed on the device. */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public static boolean hasPackage(Context context, String packageName) { + return (getPackageInfo(context, packageName) != null); + } + + /** Returns {@code true} if the package is Talkback package */ + public static boolean isTalkBackPackage(@Nullable CharSequence packageName) { + return TextUtils.equals(packageName, TALBACK_PACKAGE); + } + + /** Returns {@code true} if the package supports help and feedback. */ + public static boolean supportsHelpAndFeedback(Context context) { + return getVersionCode(context, GMSCORE_PACKAGE_NAME) > MIN_GMSCORE_VERSION; + } + + private static @Nullable PackageInfo getPackageInfo(Context context, CharSequence packageName) { + if (packageName == null) { + return null; + } + + final PackageManager packageManager = context.getPackageManager(); + + try { + return packageManager.getPackageInfo(packageName.toString(), 0); + } catch (NameNotFoundException e) { + return null; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/PerformActionUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/PerformActionUtils.java new file mode 100644 index 0000000..8953a9f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/PerformActionUtils.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils; + +import android.os.Bundle; +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; +import androidx.annotation.Nullable; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.libraries.accessibility.utils.log.LogUtils; + +/** Used to perform an action on a AccessibilityNodeInfoCompat and log relevant information. */ +public class PerformActionUtils { + private static final String TAG = "PerformActionUtils"; + + public static boolean performAction( + @Nullable AccessibilityNodeInfoCompat node, int action, @Nullable EventId eventId) { + return performAction(node, action, null /* args */, eventId); + } + + public static boolean performAction( + @Nullable AccessibilityNodeInfoCompat node, + int action, + Bundle args, + @Nullable EventId eventId) { + if (node == null) { + return false; + } + + boolean result = node.performAction(action, args); + LogUtils.d( + TAG, + "perform action=%d=%s returns %s with args=%s on node=%s for event=%s", + action, + AccessibilityNodeInfoUtils.actionToString(action), + result, + args, + node, + eventId); + + return result; + } + + public static boolean showOnScreen( + @Nullable AccessibilityNodeInfoCompat node, @Nullable EventId eventId) { + return performAction(node, AccessibilityAction.ACTION_SHOW_ON_SCREEN.getId(), eventId); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/Performance.java b/utils/src/main/java/com/google/android/accessibility/utils/Performance.java new file mode 100644 index 0000000..dee42f8 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/Performance.java @@ -0,0 +1,1095 @@ +/* + * Copyright (C) 2016 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.content.res.Configuration; +import android.os.SystemClock; +import android.text.TextUtils; +import android.view.KeyEvent; +import android.view.accessibility.AccessibilityEvent; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Utility class for tracking performance statistic per various event types & processing stages. + * + *

Stages are not strictly sequential, but rather overlap. + * + *

Latency statistics for {@code STAGE_FEEDBACK_HEARD} is currently inaccurate, as event-to-audio + * matching is approximate. + */ +public class Performance { + + private static final String TAG = "Performance"; + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Constants + + /** Stages that each event goes through, where we want to measure latency. */ + @IntDef({STAGE_FRAMEWORK, STAGE_INLINE_HANDLING, STAGE_FEEDBACK_QUEUED, STAGE_FEEDBACK_HEARD}) + public @interface StageId {} + + public static final int STAGE_FRAMEWORK = 0; // Latency before TalkBack + public static final int STAGE_INLINE_HANDLING = 1; // Time during synchronous event handlers + public static final int STAGE_FEEDBACK_QUEUED = 2; // Time until first speech is queued + public static final int STAGE_FEEDBACK_HEARD = 3; // Time until speech is heard. + public static final String[] STAGE_NAMES = { + "STAGE_FRAMEWORK", "STAGE_INLINE_HANDLING", "STAGE_FEEDBACK_QUEUED", "STAGE_FEEDBACK_HEARD" + }; + + /** + * Event types for which we want to measure latency. + * + *

The purpose of this event type is to uniquely identify an event occurring at a point in + * time. Each event type has sub-types, since many AccessibilityEvent or KeyEvent may occur at the + * same time, differentiated only by their sub-types. + * + *

EVENT_TYPE_ACCESSIBILITY: Subtype is AccessibilityEvent event type, from getEventType(). + * EVENT_TYPE_KEY: Subtype is key-code. EVENT_TYPE_KEY_COMBO: Subtype is combo id. + * EVENT_TYPE_VOLUME_KEY_COMBO: Subtype is combo id. EVENT_TYPE_GESTURE: Subtype is gesture id + * (perhaps unnecessary since concurrent gestures have not been observed). + * EVENT_TYPE_FINGERPRINT_GESTURE: Subtype is gesture id. EVENT_TYPE_ROTATE: Subtype is + * orientation. + */ + @IntDef({ + EVENT_TYPE_ACCESSIBILITY, + EVENT_TYPE_KEY, + EVENT_TYPE_KEY_COMBO, + EVENT_TYPE_VOLUME_KEY_COMBO, + EVENT_TYPE_GESTURE, + EVENT_TYPE_ROTATE, + EVENT_TYPE_FINGERPRINT_GESTURE + }) + public @interface EventTypeId {} + + public static final int EVENT_TYPE_ACCESSIBILITY = 0; + public static final int EVENT_TYPE_KEY = 1; + public static final int EVENT_TYPE_KEY_COMBO = 2; + public static final int EVENT_TYPE_VOLUME_KEY_COMBO = 3; + public static final int EVENT_TYPE_GESTURE = 4; + public static final int EVENT_TYPE_ROTATE = 5; + public static final int EVENT_TYPE_FINGERPRINT_GESTURE = 6; + public static final String[] EVENT_TYPE_NAMES = { + "EVENT_TYPE_ACCESSIBILITY", + "EVENT_TYPE_KEY", + "EVENT_TYPE_KEY_COMBO", + "EVENT_TYPE_VOLUME_KEY_COMBO", + "EVENT_TYPE_GESTURE", + "EVENT_TYPE_ROTATE", + "EVENT_TYPE_FINGERPRINT_GESTURE" + }; + + public static final @Nullable EventId EVENT_ID_UNTRACKED = null; + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Member data + + protected boolean mEnabled = false; + + /** Recent events for which we are collecting stage latencies */ + protected static final int MAX_RECENT_EVENTS = 100; + + protected LinkedList mEventQueue = new LinkedList(); + protected HashMap mEventIndex = new HashMap(); + private HashMap mUtteranceToEvent = new HashMap(); + protected final Object mLockRecentEvents = new Object(); + + /** Latency statistics for various event/label types */ + protected HashMap mLabelToStats = + new HashMap(); + + protected final Object mLockLabelToStats = new Object(); + protected Statistics mAllEventStats = new Statistics(); + + private static Performance sInstance = new Performance(); + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Construction + + public static Performance getInstance() { + return sInstance; + } + + protected Performance() {} + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Simple getters/setters + + public boolean getEnabled() { + return mEnabled; + } + + public void setEnabled(boolean enabled) { + mEnabled = enabled; + } + + public Statistics getAllEventStats() { + return mAllEventStats; + } + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Methods to track events + + /** + * Method to start tracking processing latency for an event. Uses event type as statistics + * segmentation label. + * + * @param event An event just received by TalkBack + * @return An event id that can be used to track performance through later stages. + */ + public EventId onEventReceived(@NonNull AccessibilityEvent event) { + @NonNull EventId eventId = toEventId(event); + if (!mEnabled) { + return eventId; + } + + // Segment events based on type. + String label = AccessibilityEventUtils.typeToString(event.getEventType()); + String[] labels = {label}; + + onEventReceived(eventId, labels); + return eventId; + } + + /** + * Constructs an EventId without tracking the event's times. Useful for recreating an id from an + * event that was tracked by onEventReceived(), when the event is available but id is not. Try to + * use this as little as possible, and instead pass the EventId from onEventReceived(). + * + * @param event Event that has already been tracked by onEventReceived() + * @return EventId of event + */ + @NonNull + public EventId toEventId(@NonNull AccessibilityEvent event) { + return new EventId(event.getEventTime(), EVENT_TYPE_ACCESSIBILITY, event.getEventType()); + } + + /** + * Method to start tracking processing latency for a key event. + * + * @param event A key event just received by TalkBack + * @return An event id that can be used to track performance through later stages. + */ + public EventId onEventReceived(@NonNull KeyEvent event) { + int keycode = event.getKeyCode(); + EventId eventId = new EventId(event.getEventTime(), EVENT_TYPE_KEY, keycode); + if (!mEnabled) { + return eventId; + } + + // Segment key events based on key groups. + String label = "KeyEvent-other"; + if (KeyEvent.KEYCODE_0 <= keycode && keycode <= KeyEvent.KEYCODE_9) { + label = "KeyEvent-numeric"; + } else if (KeyEvent.KEYCODE_A <= keycode && keycode <= KeyEvent.KEYCODE_Z) { + label = "KeyEvent-alpha"; + } else if (KeyEvent.KEYCODE_DPAD_UP <= keycode && keycode <= KeyEvent.KEYCODE_DPAD_CENTER) { + label = "KeyEvent-dpad"; + } else if (KeyEvent.KEYCODE_VOLUME_UP <= keycode && keycode <= KeyEvent.KEYCODE_VOLUME_DOWN) { + label = "KeyEvent-volume"; + } + String[] labels = {label}; + + onEventReceived(eventId, labels); + return eventId; + } + + /** + * Method to start tracking processing latency for a gesture event. Uses event type as statistics + * segmentation label. + * + * @param gestureId A gesture just recognized by TalkBack + * @return An event id that can be used to track performance through later stages. + */ + public EventId onGestureEventReceived(int gestureId) { + EventId eventId = new EventId(getUptime(), EVENT_TYPE_GESTURE, gestureId); + if (!mEnabled) { + return eventId; + } + + // Segment events based on gesture id. + String label = AccessibilityServiceCompatUtils.gestureIdToString(gestureId); + String[] labels = {label}; + + onEventReceived(eventId, labels); + return eventId; + } + + /** + * Method to start tracking processing latency for a fingerprint gesture event. Uses event type as + * statistics segmentation label. + * + * @param fingerprintGestureId A fingerprint gesture just recognized by TalkBack + * @return An event id that can be used to track performance through later stages. + */ + public EventId onFingerprintGestureEventReceived(int fingerprintGestureId) { + EventId eventId = + new EventId(getUptime(), EVENT_TYPE_FINGERPRINT_GESTURE, fingerprintGestureId); + if (!mEnabled) { + return eventId; + } + + // Segment events based on fingerprint gesture id. + String[] labels = { + AccessibilityServiceCompatUtils.fingerprintGestureIdToString(fingerprintGestureId) + }; + + onEventReceived(eventId, labels); + return eventId; + } + + public EventId onKeyComboEventReceived(int keyComboId) { + EventId eventId = new EventId(getUptime(), EVENT_TYPE_KEY_COMBO, keyComboId); + if (!mEnabled) { + return eventId; + } + + // Segment events based on key combo id. + String label = Integer.toString(keyComboId); + String[] labels = {label}; + + onEventReceived(eventId, labels); + return eventId; + } + + public EventId onVolumeKeyComboEventReceived(int keyComboId) { + EventId eventId = new EventId(getUptime(), EVENT_TYPE_VOLUME_KEY_COMBO, keyComboId); + if (!mEnabled) { + return eventId; + } + + // Segment events based on key combo id. + String label = Integer.toString(keyComboId); + String[] labels = {label}; + + onEventReceived(eventId, labels); + return eventId; + } + + public EventId onRotateEventReceived(int orientation) { + EventId eventId = new EventId(getUptime(), EVENT_TYPE_ROTATE, orientation); + if (!mEnabled) { + return eventId; + } + + // Segment events based on orientation. + String label = "ORIENTATION_UNDEFINED"; + if (orientation == Configuration.ORIENTATION_PORTRAIT) { + label = "ORIENTATION_PORTRAIT"; + } else if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + label = "ORIENTATION_LANDSCAPE"; + } + String[] labels = {label}; + + onEventReceived(eventId, labels); + return eventId; + } + + protected void onEventReceived(@NonNull EventId eventId, String[] labels) { + if (!mEnabled) { + return; + } + + // Create event data. + EventData eventData = new EventData(getTime(), labels, eventId); + + // Collect event data. + addRecentEvent(eventId, eventData); + trimRecentEvents(MAX_RECENT_EVENTS); + + @StageId int prevStage = STAGE_INLINE_HANDLING - 1; + long prevStageLatency = getUptime() - eventId.getEventTimeMs(); // Event times are uptime. + mAllEventStats.increment(prevStageLatency); + + // For each event label... increment statistics. + if (eventData.labels != null) { + int numLabels = eventData.labels.length; + for (int labelIndex = 0; labelIndex < numLabels; ++labelIndex) { + String label = eventData.labels[labelIndex]; + Statistics stats = getOrCreateStatistics(label, prevStage); + stats.increment(prevStageLatency); + } + } + } + + /** + * Track event latency between receiving event, and finishing synchronous event handling. + * + * @param eventId Identity of an event just handled by TalkBack + */ + public void onHandlerDone(@NonNull EventId eventId) { + if (!mEnabled) { + return; + } + + // If recent event not found... then labels are not available to increment statistics. + EventData eventData = getRecentEvent(eventId); + if (eventData == null) { + return; + } + // If time already collected for this event & stage... do not update. + if (eventData.timeInlineHandled != -1) { + return; + } + + // Compute stage latency. + long now = getTime(); + eventData.timeInlineHandled = now; + long stageLatency = now - eventData.timeReceivedAtTalkback; + + // For each event label... increment stage latency statistics. + if (eventData.labels != null) { + for (String label : eventData.labels) { + Statistics stats = getOrCreateStatistics(label, STAGE_INLINE_HANDLING); + stats.increment(stageLatency); + } + } + } + + /** + * Track event latency between receiving event, and queueing first piece of spoken feedback. + * + * @param eventId Identity of an event handled by TalkBack + * @param utteranceId Identity of a piece of spoken feedback, resulting from the event. + */ + public void onFeedbackQueued(@NonNull EventId eventId, @NonNull String utteranceId) { + if (!mEnabled) { + return; + } + + // If recent event not found... then labels are not available to increment statistics. + EventData eventData = getRecentEvent(eventId); + if (eventData == null) { + return; + } + // If utterance already matched with this event... do not update. + if (eventData.getUtteranceId() != null) { + return; + } + + // Compute stage latency. + long now = getTime(); + eventData.setFeedbackQueued(now, utteranceId); + indexRecentUtterance(utteranceId, eventId); + long stageLatency = now - eventData.timeReceivedAtTalkback; + + // For each event label... increment stage latency statistics. + if (eventData.labels != null) { + for (String label : eventData.labels) { + Statistics stats = getOrCreateStatistics(label, STAGE_FEEDBACK_QUEUED); + stats.increment(stageLatency); + } + } + } + + /** Track event latency between receiving event, and hearing audio feedback. */ + public void onFeedbackOutput(@NonNull String utteranceId) { + if (!mEnabled) { + return; + } + + // If recent event not found... then labels are not available to increment statistics. + EventId eventId = getRecentUtterance(utteranceId); + if (eventId == null) { + return; + } + EventData eventData = getRecentEvent(eventId); + if (eventData == null) { + return; + } + + // If speech is not already matched with this event... + if (eventData.getTimeFeedbackOutput() <= 0) { + // Compute stage latency. + long now = getTime(); + eventData.setFeedbackOutput(now); + long stageLatency = now - eventData.timeReceivedAtTalkback; + + // For each event label... increment stage latency statistics. + if (eventData.labels != null) { + for (String label : eventData.labels) { + Statistics stats = getOrCreateStatistics(label, STAGE_FEEDBACK_HEARD); + stats.increment(stageLatency); + } + } + } + + // Clear the recent event, since we have no more use for it after tracking all stages. + collectMissingLatencies(eventData); + removeRecentEvent(eventId); + removeRecentUtterance(utteranceId); + } + + /** Pop recent events off the queue, and increment their statistics as "missing" */ + protected void trimRecentEvents(int targetSize) { + while (getNumRecentEvents() > targetSize) { + EventData eventData = popOldestRecentEvent(); + if (eventData != null) { + collectMissingLatencies(eventData); + } + } + } + + /** Increment statistics for missing stages. */ + private void collectMissingLatencies(@NonNull EventData eventData) { + // For each label x unreached stage... collect latency=missing. + if (eventData != null && eventData.labels != null) { + for (String label : eventData.labels) { + if (eventData.timeInlineHandled <= 0) { + incrementNumMissing(label, STAGE_INLINE_HANDLING); + } + if (eventData.getTimeFeedbackQueued() <= 0) { + incrementNumMissing(label, STAGE_FEEDBACK_QUEUED); + } + if (eventData.getTimeFeedbackOutput() <= 0) { + incrementNumMissing(label, STAGE_FEEDBACK_HEARD); + } + } + } + } + + private void incrementNumMissing(@NonNull String label, @StageId int stageId) { + Statistics stats = getStatistics(label, stageId); + if (stats != null) { + stats.incrementNumMissing(); + } + } + + /** Make clock override-able for testing. */ + protected long getTime() { + return System.currentTimeMillis(); + } + + protected long getUptime() { + return SystemClock.uptimeMillis(); + } + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Methods to access recent event collection + + protected void addRecentEvent(@NonNull EventId eventId, @NonNull EventData eventData) { + synchronized (mLockRecentEvents) { + mEventQueue.add(eventId); + mEventIndex.put(eventId, eventData); + } + } + + private void indexRecentUtterance(@NonNull String utteranceId, @NonNull EventId eventId) { + synchronized (mLockRecentEvents) { + mUtteranceToEvent.put(utteranceId, eventId); + } + } + + protected EventData getRecentEvent(@NonNull EventId eventId) { + synchronized (mLockRecentEvents) { + return mEventIndex.get(eventId); + } + } + + protected EventId getRecentUtterance(@NonNull String utteranceId) { + synchronized (mLockRecentEvents) { + return mUtteranceToEvent.get(utteranceId); + } + } + + protected int getNumRecentEvents() { + synchronized (mLockRecentEvents) { + return mEventQueue.size(); + } + } + + protected @Nullable EventData popOldestRecentEvent() { + synchronized (mLockRecentEvents) { + if (mEventQueue.size() == 0) { + return null; + } + EventId eventId = mEventQueue.remove(); + EventData eventData = mEventIndex.remove(eventId); + String utteranceId = (eventData == null) ? null : eventData.getUtteranceId(); + if (utteranceId != null) { + mUtteranceToEvent.remove(eventData.getUtteranceId()); + } + return eventData; + } + } + + protected void removeRecentEvent(@NonNull EventId eventId) { + synchronized (mLockRecentEvents) { + mEventIndex.remove(eventId); + mEventQueue.remove(eventId); + } + } + + public void clearRecentEvents() { + synchronized (mLockRecentEvents) { + mEventIndex.clear(); + mEventQueue.clear(); + } + } + + protected void removeRecentUtterance(@NonNull String utteranceId) { + synchronized (mLockRecentEvents) { + mUtteranceToEvent.remove(utteranceId); + } + } + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Methods to access latency statistics collection + + /** + * Looks up current latency statistics for a given label and stage. + * + * @param label The label used for events. + * @param stage The talkback processing {@code @StageId} + * @return The statistics for requested label & stage, or null if no such label & stage found. + */ + public Statistics getStatistics(@NonNull String label, @StageId int stage) { + synchronized (mLockLabelToStats) { + StatisticsKey statsKey = new StatisticsKey(label, stage); + return mLabelToStats.get(statsKey); + } + } + + public void clearAllStats() { + synchronized (mLockLabelToStats) { + mLabelToStats.clear(); + } + mAllEventStats.clear(); + } + + protected Statistics getOrCreateStatistics(@NonNull String label, @StageId int stage) { + synchronized (mLockLabelToStats) { + StatisticsKey statsKey = new StatisticsKey(label, stage); + Statistics stats = mLabelToStats.get(statsKey); + if (stats == null) { + stats = new Statistics(); + mLabelToStats.put(statsKey, stats); + } + return stats; + } + } + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Methods to display results + + /** Display label-vs-label comparisons for each summary statistic. */ + public void displayStatToLabelCompare() { + display("displayStatToLabelCompare()"); + + StatisticsKey[] labelsSorted = new StatisticsKey[mLabelToStats.size()]; + labelsSorted = mLabelToStats.keySet().toArray(labelsSorted); + Arrays.sort(labelsSorted); + + ArrayList barsMissing = new ArrayList(labelsSorted.length); + ArrayList barsCount = new ArrayList(labelsSorted.length); + ArrayList barsMean = new ArrayList(labelsSorted.length); + ArrayList barsMedian = new ArrayList(labelsSorted.length); + ArrayList barsStdDev = new ArrayList(labelsSorted.length); + + // For each label... collect summary statistics. + for (StatisticsKey label : labelsSorted) { + Statistics stats = mLabelToStats.get(label); + barsMissing.add(new BarInfo(label.toString(), stats.getNumMissing())); + barsCount.add(new BarInfo(label.toString(), stats.getCount())); + barsMean.add(new BarInfo(label.toString(), stats.getMean())); + barsMedian.add( + new BarInfo( + label.toString(), stats.getMedianBinStart(), (2 * stats.getMedianBinStart()))); + barsStdDev.add(new BarInfo(label.toString(), (float) stats.getStdDev())); + } + + // For each summary statistic... display comparison bar graph. + displayBarGraph(" ", "missing", barsMissing, "" /* barUnits */); + displayBarGraph(" ", "count", barsCount, "" /* barUnits */); + displayBarGraph(" ", "mean", barsMean, "ms"); + displayBarGraph(" ", "median", barsMedian, "ms"); + displayBarGraph(" ", "stddev", barsStdDev, "ms"); + } + + /** Display latency statistics for each label. */ + public void displayLabelToStats() { + display("displayLabelToStats()"); + + // For each label... + StatisticsKey[] labelsSorted = new StatisticsKey[mLabelToStats.size()]; + labelsSorted = mLabelToStats.keySet().toArray(labelsSorted); + Arrays.sort(labelsSorted); + for (StatisticsKey labelAndStage : labelsSorted) { + Statistics stats = mLabelToStats.get(labelAndStage); + display(" %s", labelAndStage); + displayStatistics(stats); + } + } + + public void displayAllEventStats() { + display("displayAllEventStats()"); + displayStatistics(mAllEventStats); + } + + public static void displayStatistics(Statistics stats) { + // Display summary statistics. + display( + " missing=%s count=%s mean=%sms stdDev=%sms median=%sms", + stats.getNumMissing(), + stats.getCount(), + stats.getMean(), + stats.getStdDev(), + stats.getMedianBinStart()); + + // Display latency distribution. + ArrayList bars = new ArrayList(stats.mHistogram.size()); + for (int bin = 0; bin < stats.mHistogram.size(); ++bin) { + long binStart = stats.histogramBinToStartValue(bin); + bars.add( + new BarInfo( + "" + binStart + "-" + (2 * binStart) + "ms", stats.mHistogram.get(bin).longValue())); + } + displayBarGraph(" ", "distribution=", bars, "count"); + } + + /** + * Display a bar graph. + * + * @param prefix Indentation to prepend to each bar line + * @param title Title of graph + * @param bars Series of bar labels & sizes + * @param barUnits Units to append to each bar value + */ + private static void displayBarGraph( + String prefix, String title, ArrayList bars, String barUnits) { + if (!TextUtils.isEmpty(title)) { + display(" %s", title); + } + + // Find multiplier to scale bars. + float maxValue = 0.0f; + for (BarInfo barInfo : bars) { + maxValue = Math.max(maxValue, barInfo.value); + } + float maxBarLength = 40.0f; + float barScale = maxBarLength / maxValue; + + // For each bar... scale bar size, display bar. + String barCharacter = "#"; + for (BarInfo barInfo : bars) { + int barLength = (int) ((float) barInfo.value * barScale); + String bar = repeat(barCharacter, barLength + 1); + StringBuilder line = new StringBuilder(); + line.append(prefix + bar + " " + floatToString(barInfo.value)); + if (barInfo.rangeEnd != -1) { + line.append("-" + floatToString(barInfo.rangeEnd)); + } + line.append(barUnits + " for " + barInfo.label); + display(line.toString()); + } + display(""); + } + + private static String floatToString(float value) { + // If float is an integer... do not show fractional part of number. + return ((int) value == value) ? String.format("%d", (int) value) : String.format("%f", value); + } + + public void displayRecentEvents() { + display("perf.mEventQueue="); + for (EventId i : mEventQueue) { + display("\t" + i); + } + display("perf.mEventIndex="); + for (EventId i : mEventIndex.keySet()) { + display("\t" + i + ":" + mEventIndex.get(i)); + } + } + + private static void display(String format, Object... args) { + LogUtils.i(TAG, format, args); + } + + protected static String repeat(String string, int repetitions) { + StringBuilder repeated = new StringBuilder(string.length() * repetitions); + for (int r = 0; r < repetitions; ++r) { + repeated.append(string); + } + return repeated.toString(); + } + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Inner classes for recent events + + /** Key for looking up EventData in HashMap. */ + public static class EventId { + private final long mEventTimeMs; + @EventTypeId private final int mEventType; + private final int mEventSubtype; + + /** + * Create a small event identifier for tracking event through processing stages, even after + * AccessibilityEvent has been recycled. + * + * @param time Time in milliseconds. + * @param type Event object type. + * @param subtype Event object subtype from AccessibilityEvent.getEventType() or gesture id. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + public EventId(long time, @EventTypeId int type, int subtype) { + mEventTimeMs = time; // Event creation times use system uptime. + mEventType = type; + mEventSubtype = subtype; + } + + public long getEventTimeMs() { + return mEventTimeMs; + } + + @EventTypeId + public int getEventType() { + return mEventType; + } + + public int getEventSubtype() { + return mEventSubtype; + } + + @Override + public boolean equals(@Nullable Object otherObj) { + if (this == otherObj) { + return true; + } + if (!(otherObj instanceof EventId)) { + return false; + } + EventId other = (EventId) otherObj; + return this.mEventTimeMs == other.mEventTimeMs + && this.mEventType == other.mEventType + && this.mEventSubtype == other.mEventSubtype; + } + + @Override + public int hashCode() { + return Objects.hash(mEventTimeMs, mEventType, mEventSubtype); + } + + @Override + public String toString() { + String subtypeString; + switch (mEventType) { + case EVENT_TYPE_ACCESSIBILITY: + subtypeString = AccessibilityEventUtils.typeToString(mEventSubtype); + break; + case EVENT_TYPE_KEY: + subtypeString = KeyEvent.keyCodeToString(mEventSubtype); + break; + case EVENT_TYPE_GESTURE: + subtypeString = AccessibilityServiceCompatUtils.gestureIdToString(mEventSubtype); + break; + case EVENT_TYPE_FINGERPRINT_GESTURE: + subtypeString = + AccessibilityServiceCompatUtils.fingerprintGestureIdToString(mEventSubtype); + break; + default: + subtypeString = Integer.toString(mEventSubtype); + } + return "type:" + + EVENT_TYPE_NAMES[mEventType] + + " subtype:" + + subtypeString + + " time:" + + mEventTimeMs; + } + } + + /** Tracking the stage start times for an event. */ + protected static class EventData { + + // Members set when event is received at TalkBack. + public final String[] labels; + public final EventId eventId; // This EventData's key in mEventIndex. + public final long timeReceivedAtTalkback; + + public long timeInlineHandled = -1; + + // Members set when feedback is queued. + private long mTimeFeedbackQueued = -1; + private String mUtteranceId; // Updates may come from TalkBack or TextToSpeech threads. + + private long mTimeFeedbackOutput = -1; + + public EventData(long timeReceivedAtTalkbackArg, String[] labelsArg, EventId eventIdArg) { + labels = labelsArg; + eventId = eventIdArg; + timeReceivedAtTalkback = timeReceivedAtTalkbackArg; + } + + // Synchronized because this method may be called from a separate audio handling thread. + public synchronized void setFeedbackQueued(long timeFeedbackQueued, String utteranceId) { + mTimeFeedbackQueued = timeFeedbackQueued; + mUtteranceId = utteranceId; + } + + // Synchronized because this method may be called from a separate audio handling thread. + public synchronized void setFeedbackOutput(long timeFeedbackOutput) { + mTimeFeedbackOutput = timeFeedbackOutput; + } + + public synchronized long getTimeFeedbackQueued() { + return mTimeFeedbackQueued; + } + + public synchronized String getUtteranceId() { + return mUtteranceId; + } + + public synchronized long getTimeFeedbackOutput() { + return mTimeFeedbackOutput; + } + + @Override + public String toString() { + return " labels=" + + TextUtils.join(",", labels) + + " timeReceivedAtTalkback=" + + timeReceivedAtTalkback + + " mTimeFeedbackQueued=" + + mTimeFeedbackQueued + + " mTimeFeedbackOutput=" + + mTimeFeedbackOutput + + " timeInlineHandled=" + + timeInlineHandled + + String.format(" mUtteranceId=%s", mUtteranceId); + } + } + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Inner classes for latency statistics + + /** Key for looking up Statistics by event label & stage. */ + // REFERTO + @SuppressWarnings("ComparableType") + public static class StatisticsKey implements Comparable { + private final String mLabel; + @StageId private final int mStage; + + public StatisticsKey(String label, @StageId int stage) { + mLabel = label; + mStage = stage; + } + + public String getLabel() { + return mLabel; + } + + public int getStage() { + return mStage; + } + + @Override + public int compareTo(Object otherObj) { + if (otherObj == null || !(otherObj instanceof StatisticsKey)) { + return 1; + } + if (this == otherObj) { + return 0; + } + StatisticsKey other = (StatisticsKey) otherObj; + // Compare stage. + int stageCompare = mStage - other.getStage(); + if (stageCompare != 0) { + return stageCompare; + } + // Compare label. + return mLabel.compareTo(other.getLabel()); + } + + @Override + public boolean equals(@Nullable Object otherObj) { + if (!(otherObj instanceof StatisticsKey)) { + return false; + } + if (this == otherObj) { + return true; + } + StatisticsKey other = (StatisticsKey) otherObj; + return this.mStage == other.mStage && this.mLabel.equals(other.getLabel()); + } + + @Override + public int hashCode() { + return Objects.hash(mLabel, mStage); + } + + @Override + public String toString() { + return mLabel + "-" + STAGE_NAMES[mStage]; + } + } + + /** General-purpose summary & distribution statistics for a group of values. */ + public static class Statistics { + protected long mNumMissing; + protected long mCount; + protected long mSum; + protected long mSumSquares; + + /** Bin start value = 2^(index-1) , except index=0 holds bin start value=0. */ + protected ArrayList mHistogram = new ArrayList(); + + public Statistics() { + clear(); + } + + public synchronized void clear() { + mNumMissing = 0; + mCount = 0; + mSum = 0; + mSumSquares = 0; + mHistogram.clear(); + } + + public synchronized void incrementNumMissing() { + ++mNumMissing; + } + + public synchronized void increment(long value) { + // Increment summary statistics. + ++mCount; + mSum += value; + mSumSquares += value * value; + + // Ensure histogram is big enough to hold this value. + int binIndex = valueToHistogramBin(value); + if (mHistogram.size() < binIndex + 1) { + mHistogram.ensureCapacity(binIndex + 1); + while (mHistogram.size() <= binIndex) { + mHistogram.add(new AtomicLong(0)); + } + } + // Increment histogram count. + AtomicLong binCount = mHistogram.get(binIndex); + binCount.set(binCount.longValue() + 1); + } + + public long getNumMissing() { + return mNumMissing; + } + + public long getCount() { + return mCount; + } + + public long getMean() { + return (mCount <= 0) ? 0 : (mSum / mCount); + } + + /** + * Computes standard devication based on the mistaken assumption that values have gaussian + * distribution. + * + * @return Standard deviation of {@code increment(value)} + */ + public double getStdDev() { + if (mCount <= 0) { + return 0; + } + double mean = (double) mSum / (double) mCount; + double meanOfSquares = (double) mSumSquares / (double) mCount; + double variance = meanOfSquares - (mean * mean); + return Math.sqrt(variance); + } + + public long getMedianBinStart() { + if (mCount <= 0) { + return 0; + } + // For each histogram bin, in order... + long medianCount = mCount / 2; + long sumBins = 0; + for (int binIndex = 0; binIndex < mHistogram.size(); ++binIndex) { + // If bin contains mCount/2... return bin start. + sumBins += mHistogram.get(binIndex).longValue(); + if (sumBins >= medianCount) { + return histogramBinToStartValue(binIndex); + } + } + return histogramBinToStartValue(mHistogram.size()); + } + + public int valueToHistogramBin(long value) { + return valueToPower(value) + 1; + } + + public long histogramBinToStartValue(int index) { + return (index < 1) ? 0L : (1L << (index - 1)); + } + + /** + * Converts a positive value to the exponent of preceding 2^P. Returns the largest integer + * exponent "P" such that 2^P < value. Returns -1 for value <= 0. + */ + public static int valueToPower(long value) { + if (value < 1) { + return -1; + } + // For each power that leaves a remainder... increment power. + long power = -1; + for (long remainder = value; remainder > 0; remainder >>= 1) { + ++power; + } + return (int) power; + } + } + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Inner classes + + /** Holds data for one bar in a bar graph. */ + protected static class BarInfo { + public String label = ""; + public float value = 0; + public float rangeEnd = -1; + + public BarInfo(String labelArg, float valueArg) { + label = labelArg; + value = valueArg; + } + + public BarInfo(String labelArg, float valueArg, float rangeEndArg) { + label = labelArg; + value = valueArg; + rangeEnd = rangeEndArg; + } + } + + /** A message object with a corresponding EventId, for use by deferred event handlers. */ + public static class EventIdAnd { + public final T object; + public final @Nullable EventId eventId; + + public EventIdAnd(T objectArg, @Nullable EventId eventIdArg) { + object = objectArg; + eventId = eventIdArg; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/PreferenceSettingsUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/PreferenceSettingsUtils.java new file mode 100644 index 0000000..6822ad9 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/PreferenceSettingsUtils.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2018 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.google.android.accessibility.utils; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.preference.PreferenceFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.annotation.XmlRes; +import androidx.preference.Preference; +import androidx.preference.PreferenceDialogFragmentCompat; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceGroup; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Utilities for Android Preference Screens. */ +public final class PreferenceSettingsUtils { + // Cannot be instantiated. + private PreferenceSettingsUtils() {} + + /** + * Given an array of resource ids corresponding to the actual String keys for TalkBack + * preferences, hide the preferences. This will also be done recursively form the root + * PreferenceGroup. + */ + public static void hidePreferences( + final Context context, PreferenceGroup root, int[] prefKeyIds) { + if (context == null) { + return; + } + Set hiddenPreferenceKeys = new HashSet<>(); + for (int hiddenPreferenceKeyId : prefKeyIds) { + hiddenPreferenceKeys.add(context.getString(hiddenPreferenceKeyId)); + } + hidePreferences(root, hiddenPreferenceKeys); + } + + /** + * Given a list of resource ids corresponding to the actual String keys for TalkBack preferences, + * hide the preferences. This will also be done recursively form the root PreferenceGroup. + */ + public static void hidePreferences( + final Context context, PreferenceGroup root, List prefKeyIds) { + if (context == null) { + return; + } + Set hiddenPreferenceKeys = new HashSet<>(); + for (Integer hiddenPreferenceKeyId : prefKeyIds) { + hiddenPreferenceKeys.add(context.getString(hiddenPreferenceKeyId.intValue())); + } + hidePreferences(root, hiddenPreferenceKeys); + } + + private static void hidePreferences(PreferenceGroup root, Set preferenceKeysToBeHidden) { + for (int i = 0; i < root.getPreferenceCount(); i++) { + Preference preference = root.getPreference(i); + if (preferenceKeysToBeHidden.contains(preference.getKey())) { + root.removePreference(preference); + i--; + } else if (preference instanceof PreferenceGroup) { + hidePreferences((PreferenceGroup) preference, preferenceKeysToBeHidden); + } + } + } + + /** + * Given the resource id corresponding to the actual String key for TalkBack preference, hide the + * preference. This will also be done recursively form the root PreferenceGroup. + */ + public static void hidePreference(final Context context, PreferenceGroup root, int prefKeyId) { + hidePreferences(context, root, new int[] {prefKeyId}); + } + + /** + * Inflates the given XML resource and replaces the current preference hierarchy (if any) with the + * preference hierarchy rooted at {@code key}.{@link + * PreferenceFragmentCompat#setPreferencesFromResource(int, String)} + */ + public static void setPreferencesFromResource( + PreferenceFragmentCompat preferenceFragment, @XmlRes int preferencesResId, String key) { + // Set preferences to use device-protected storage. + if (BuildVersionUtils.isAtLeastN()) { + preferenceFragment.getPreferenceManager().setStorageDeviceProtected(); + } + preferenceFragment.setPreferencesFromResource(preferencesResId, key); + } + + /** + * Inflates the given XML resource and adds the preference hierarchy to the current preference + * hierarchy. {@link PreferenceFragmentCompat#addPreferencesFromResource} + */ + public static void addPreferencesFromResource( + PreferenceFragmentCompat preferenceFragment, @XmlRes int preferencesResId) { + // Set preferences to use device-protected storage. + if (BuildVersionUtils.isAtLeastN()) { + preferenceFragment.getPreferenceManager().setStorageDeviceProtected(); + } + preferenceFragment.addPreferencesFromResource(preferencesResId); + } + + /** + * Inflates the given XML resource and adds the preference hierarchy to the current preference + * hierarchy. {@link PreferenceFragment#addPreferencesFromResource} + */ + public static void addPreferencesFromResource( + PreferenceFragment preferenceFragment, @XmlRes int preferencesResId) { + // Set preferences to use device-protected storage. + if (BuildVersionUtils.isAtLeastN()) { + preferenceFragment.getPreferenceManager().setStorageDeviceProtected(); + } + preferenceFragment.addPreferencesFromResource(preferencesResId); + } + + /** + * Assigns an URL intent to the preference. When clicking the preference, it would jump to URL. + * This function can't be used by wear. + * + * @param fragment PreferenceFragmentCompat to get context. + * @param preference Preference to send Intent + * @param url URL which launches web page + */ + public static void assignWebIntentToPreference( + PreferenceFragmentCompat fragment, Preference preference, String url) { + if (!SettingsUtils.allowLinksOutOfSettings(fragment.getContext())) { + return; + } + + Uri uri = Uri.parse(url); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + Activity activity = fragment.getActivity(); + if (activity != null) { + if (!systemCanHandleIntent(fragment.getActivity(), intent)) { + intent = new Intent(activity, WebActivity.class); + intent.setData(uri); + } + } + + preference.setIntent(intent); + } + + /** Checks if the intent could be performed by a system. */ + private static boolean systemCanHandleIntent(Activity activity, Intent intent) { + if (activity == null) { + return false; + } + + PackageManager manager = activity.getPackageManager(); + List infos = manager.queryIntentActivities(intent, 0); + return infos != null && !infos.isEmpty(); + } + + /** + * Finds the preference by the key Id. + * + * @param activity FragmentActivity which contain Fragments. + * @param prefKeyId key Id of Preference which likes to find + * @return Preference by search key. + */ + public static @Nullable Preference findPreference(FragmentActivity activity, int prefKeyId) { + return findPreference(activity, activity.getString(prefKeyId)); + } + + /** + * Finds the preference by the key string. + * + * @param activity FragmentActivity which contain Fragments. + * @param key key string of Preference which likes to find + * @return Preference by search key. + */ + public static @Nullable Preference findPreference(FragmentActivity activity, String key) { + List fragments = activity.getSupportFragmentManager().getFragments(); + for (Fragment fragment : fragments) { + if (fragment instanceof PreferenceFragmentCompat) { + return ((PreferenceFragmentCompat) fragment).findPreference(key); + } else if (fragment instanceof PreferenceDialogFragmentCompat) { + return ((PreferenceDialogFragmentCompat) fragment).getPreference(); + } + } + + return null; + } + + /** + * Finds the preference by the key Id. + * + * @param fragment Fragment which contain preference key. + * @param prefKeyId key Id of Preference which likes to find + * @return Preference by search key. + */ + public static @Nullable Preference findPreference( + PreferenceFragmentCompat fragment, int prefKeyId) { + if (fragment == null) { + return null; + } + Context context = fragment.getContext(); + return fragment.findPreference(context.getString(prefKeyId)); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/PreferencesActivity.java b/utils/src/main/java/com/google/android/accessibility/utils/PreferencesActivity.java new file mode 100644 index 0000000..e6dadf5 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/PreferencesActivity.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2021 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.google.android.accessibility.utils; + +import android.os.Bundle; +import android.view.View; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Provides interfaces and common methods for a11y preference activity used. This class inherits + * {@link BasePreferencesActivity} and provide common functions for a11y preference activity. + */ +public abstract class PreferencesActivity extends BasePreferencesActivity { + // This variable is used as argument of Intent to identify which fragment should be created. + public static final String FRAGMENT_NAME = "FragmentName"; + + /** Creates a PreferenceFragmentCompat when AccessibilityPreferencesActivity is called. */ + protected abstract PreferenceFragmentCompat createPreferenceFragment(); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // TODO: Overlay PreferencesActivity for TV and Watch to replace of this check. + if (FeatureSupport.isWatch(this)) { + disableActionBar(); + } else { + prepareActionBar(/* icon= */ null); + } + + if (FeatureSupport.isTv(this)) { + disableExpandActionBar(); + } + + if (supportHatsSurvey()) { + setContentView(R.layout.preference_with_survey); + } + + // Creates UI for the preferenceFragment created by the child class of + // AccessibilityBasePreferencesActivity. + PreferenceFragmentCompat preferenceFragment = createPreferenceFragment(); + if (preferenceFragment != null) { + getSupportFragmentManager() + .beginTransaction() + .replace(getContainerId(), preferenceFragment, getFragmentTag()) + // Add root page to back-history + .addToBackStack(/* name= */ null) + .commit(); + } + } + + /** + * If action-bar "navigate up" button is pressed, end this sub-activity when there is no fragment + * in the stack. Otherwise, it will go to last fragment. + */ + @Override + public boolean onNavigateUp() { + if (getSupportFragmentManager().getBackStackEntryCount() > 0) { + getSupportFragmentManager().popBackStackImmediate(); + } + + // Closes the activity if there is no fragment inside the stack. Otherwise the activity will has + // a blank screen since there is no any fragment. + if (getSupportFragmentManager().getBackStackEntryCount() == 0) { + finishAfterTransition(); + } + return true; + } + + @Override + protected void onStart() { + super.onStart(); + // To avoid texts showing outside of the watch face, set a padding value if the preference + // fragment is shown on watch. Square screen and round screen have different values. + if (FeatureSupport.isWatch(getApplicationContext())) { + int padding = + (int) + getResources().getDimension(R.dimen.full_screen_preference_fragment_padding_on_watch); + View activityView = getWindow().getDecorView(); + activityView.setBackgroundResource(R.color.google_black); + activityView.setPadding(/* left= */ 0, padding, /* right= */ 0, padding); + } + } + + @Override + protected final int getContainerId() { + return supportHatsSurvey() ? R.id.preference_root : super.getContainerId(); + } + + /** + * Finds preference from createPreferenceFragment() called only in onCreate(). gets non-updated + * preferences, because base class stores only 1 createPreferenceFragment() call. + */ + public Preference findPreference(String key) { + return PreferenceSettingsUtils.findPreference(this, key); + } + + /** The implementation of the activity should supports HaTS survey layout or not. */ + protected boolean supportHatsSurvey() { + return false; + } + + /** + * Gets tag of the fragment(s) are to be used. + * + * @return tag of the fragment. + */ + protected @Nullable String getFragmentTag() { + return null; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/PrimitiveUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/PrimitiveUtils.java new file mode 100644 index 0000000..994ab7d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/PrimitiveUtils.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +import static com.google.common.base.Preconditions.checkArgument; + +/** Utility class containing operations on primitive data. */ +public final class PrimitiveUtils { + private PrimitiveUtils() {} + + /** + * Returns whether the target integer number is inside a given interval. + * + * @param target The target number + * @param intervalFrom The start of interval + * @param intervalTo Then end of interval + * @param isClosure Whether it's a closure interval. + */ + public static boolean isInInterval( + int target, int intervalFrom, int intervalTo, boolean isClosure) { + return (intervalFrom < target && target < intervalTo) + || (isClosure && (intervalFrom == target || intervalTo == target)); + } + + /** + * Given a value and a previous min/max, returns a corresponding scaled value with the same + * relative position to the new min/max. + */ + public static int scaleValue(int prevValue, int prevMin, int prevMax, int min, int max) { + if (prevMin == prevMax) { + return min; + } + final float fraction = ((float) (prevValue - prevMin)) / (prevMax - prevMin); + return (int) (min + fraction * (max - min)); + } + + /** + * Given a value and a previous min/max, returns a corresponding scaled value with the same + * relative position to the new min/max. + */ + public static float scaleValue( + float prevValue, float prevMin, float prevMax, float min, float max) { + if (prevMin == prevMax) { + return min; + } + final float fraction = (prevValue - prevMin) / (prevMax - prevMin); + return (min + fraction * (max - min)); + } + + /** + * Given an integer value and min/max, clamp the value in the range of [min, max] and return the + * new value. + */ + public static int clampValue(int value, int min, int max) { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; + } + + /** + * Given a float value and min/max, clamp the value in the range of [min, max] and return the new + * value. + */ + public static float clampValue(float value, float min, float max) { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; + } + + /** + * Returns {@code true} if {@code a} and {@code b} are within {@code tolerance} of each other. + * + *

This is a copy of Guava's {@code DoubleMath#fuzzyEquals} method but for floats. + */ + @SuppressWarnings("RestrictTo") + public static boolean fuzzyEquals(float a, float b, float tolerance) { + checkArgument(tolerance >= 0); + return Math.copySign(a - b, 1.0f) <= tolerance + // copySign(x, 1.0) is a branch-free version of abs(x), but with different NaN semantics + || (a == b) // needed to ensure that infinities equal themselves + || (Float.isNaN(a) && Float.isNaN(b)); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ProximitySensor.java b/utils/src/main/java/com/google/android/accessibility/utils/ProximitySensor.java new file mode 100755 index 0000000..d6f2fa7 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ProximitySensor.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2010 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.os.Handler; +import com.google.android.libraries.accessibility.utils.log.LogUtils; + +/** + * Convenience class for working with the ProximitySensor. Also uses the ambient light sensor in its + * place, when available. + */ +public class ProximitySensor { + + private static final String TAG = "ProximitySensor"; + + /** + * Number of milliseconds to wait before reporting onSensorChanged events to the listener. Used to + * compensate for platform inconsistencies surrounding reporting the sensor state after listener + * registration or an accuracy change. + */ + private static final long REGISTRATION_EVENT_FILTER_TIMEOUT = 120; + + // Trigger proximity if distance is less than 5 cm. + private static final float TYPICAL_PROXIMITY_THRESHOLD = 5.0f; + + private final SensorManager mSensorManager; + private final Sensor mProxSensor; + private final Handler mHandler = new Handler(); + + private final float mFarValue; + + private ProximityChangeListener mCallback; + + /** Whether this class should be dropping onSensorChanged events from reaching the client. */ + private boolean mShouldDropEvents; + + /** Whether the user is close to the proximity sensor. */ + private boolean mIsClose; + + /** Whether the sensor is currently active. */ + private boolean mIsActive; + + /** + * Constructor for ProximitySensor + * + * @param context The parent context. + */ + public ProximitySensor(Context context) { + mSensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); + mProxSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY); + if (mProxSensor != null) { + mFarValue = Math.min(mProxSensor.getMaximumRange(), TYPICAL_PROXIMITY_THRESHOLD); + } else { + mFarValue = 0; + } + } + + public void setProximityChangeListener(ProximityChangeListener listener) { + mCallback = listener; + } + + /** + * Checks if something is close to the proximity sensor + * + * @return {@code true} if there is something close to the proximity sensor + */ + public boolean isClose() { + return mIsClose; + } + + /** Stops listening for sensor events. */ + public void stop() { + if ((mProxSensor == null) || !mIsActive) { + return; + } + + LogUtils.v(TAG, "Proximity sensor stopped at %d.", System.currentTimeMillis()); + mIsActive = false; + mSensorManager.unregisterListener(mListener); + } + + /** Starts listening for sensor events. */ + public void start() { + if ((mProxSensor == null) || mIsActive) { + return; + } + + mIsActive = true; + mShouldDropEvents = true; + mSensorManager.registerListener(mListener, mProxSensor, SensorManager.SENSOR_DELAY_UI); + LogUtils.v(TAG, "Proximity sensor registered at %d.", System.currentTimeMillis()); + mHandler.postDelayed(mFilterRunnable, REGISTRATION_EVENT_FILTER_TIMEOUT); + } + + private final SensorEventListener mListener = + new SensorEventListener() { + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + LogUtils.v(TAG, "Processing onAccuracyChanged event at %d.", System.currentTimeMillis()); + mShouldDropEvents = true; + mHandler.removeCallbacks(mFilterRunnable); + mHandler.postDelayed(mFilterRunnable, REGISTRATION_EVENT_FILTER_TIMEOUT); + } + + @Override + public void onSensorChanged(SensorEvent event) { + if (mShouldDropEvents) { + LogUtils.v(TAG, "Dropping onSensorChanged event at %d.", System.currentTimeMillis()); + return; + } + + LogUtils.v(TAG, "Processing onSensorChanged event at %d.", System.currentTimeMillis()); + mIsClose = (event.values[0] < mFarValue); + mCallback.onProximityChanged(mIsClose); + } + }; + + /** Runnable used to enforce the {@link #REGISTRATION_EVENT_FILTER_TIMEOUT} */ + private final Runnable mFilterRunnable = + new Runnable() { + @Override + public void run() { + mShouldDropEvents = false; + LogUtils.v(TAG, "Stopped filtering proximity events at %d.", System.currentTimeMillis()); + } + }; + + /** Callback for when the proximity sensor detects a change */ + public interface ProximityChangeListener { + public void onProximityChanged(boolean isClose); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/PureFunction.java b/utils/src/main/java/com/google/android/accessibility/utils/PureFunction.java new file mode 100644 index 0000000..557f98d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/PureFunction.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.TYPE) +@Inherited +@Retention(RetentionPolicy.RUNTIME) +public @interface PureFunction {} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ReadOnly.java b/utils/src/main/java/com/google/android/accessibility/utils/ReadOnly.java new file mode 100644 index 0000000..ecc4f22 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ReadOnly.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +/** An abstract base class for lockable data containers. */ +public abstract class ReadOnly { + private boolean mIsWritable = true; + + public boolean isWritable() { + return mIsWritable; + } + + public void setReadOnly() { + mIsWritable = false; + } + + protected void checkIsWritable() { + if (!mIsWritable) { + throw new IllegalStateException("Attempting to write a read-only object."); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/RectUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/RectUtils.java new file mode 100644 index 0000000..7618f33 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/RectUtils.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +import android.graphics.Rect; +import androidx.annotation.NonNull; +import com.google.common.base.Function; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** Utility methods for Rects. */ +public final class RectUtils { + /* A {@link Comparator} used to compare position of rectangles. The position order is from top + * to bottom, from left to right. */ + public static final Comparator RECT_POSITION_COMPARATOR = + new Comparator() { + @Override + public int compare(Rect o1, Rect o2) { + if (o1.top != o2.top) { + return o1.top - o2.top; + } else if (o1.bottom != o2.bottom) { + return o1.bottom - o2.bottom; + } else if (o1.left != o2.left) { + return o1.left - o2.left; + } else { + return o1.right - o2.right; + } + } + }; + + private RectUtils() {} + + /** + * Adjusts the {@code rect} such that each edge is at least {@code minimumEdge} pixels long. If + * the rect needs to be expanded, it will expand around the center point. + * + * @param rect the rectangle to adjust bounds + * @param minimumEdge the minimum edge length + */ + public static void ensureMinimumEdge(Rect rect, int minimumEdge) { + final boolean flipHorizontal = rect.left > rect.right; + final int width = Math.abs(rect.left - rect.right); + final int widthDelta = (minimumEdge - width) / 2; + if (widthDelta > 0) { + if (flipHorizontal) { + rect.left += widthDelta; + rect.right -= widthDelta; + } else { + rect.left -= widthDelta; + rect.right += widthDelta; + } + } + + final boolean flipVertical = rect.top > rect.bottom; + final int height = Math.abs(rect.bottom - rect.top); + final int heightDelta = (minimumEdge - height) / 2; + if (heightDelta > 0) { + if (flipVertical) { + rect.top += heightDelta; + rect.bottom -= heightDelta; + } else { + rect.top -= heightDelta; + rect.bottom += heightDelta; + } + } + } + + /** + * Checks whether top/bottom or left/right edges are flipped (i.e. left > right and/or top > + * bottom). + * + * @return true if the edges are NOT flipped. + */ + public static boolean isSorted(Rect rect) { + return rect.left <= rect.right && rect.top <= rect.bottom; + } + + /** + * Returns true if the rectangle is empty (left == right or top == bottom) Note: + * This method is different from {@link Rect#isEmpty()}, unsorted Rect is defined as non-empty + * Rect here. + */ + public static boolean isEmpty(Rect rect) { + return rect.left == rect.right || rect.top == rect.bottom; + } + + /** + * Find the largest sub-rectangle that doesn't intersect a specified one. Note: + * {@code rectToModify} and {@code otherRect} will be sorted after operation. + * + * @param rectToModify The rect that may be modified to avoid intersections + * @param otherRect The rect that should be avoided + */ + public static void adjustRectToAvoidIntersection(Rect rectToModify, Rect otherRect) { + /* + * Some rectangles are flipped around (left > right). Make sure we have two Rects free of + * such pathologies. + */ + rectToModify.sort(); + otherRect.sort(); + + if (rectToModify.contains(otherRect) || !Rect.intersects(rectToModify, otherRect)) { + return; + } + + /* + * Intersect rectToModify with four rects that represent cuts of the entire space along + * lines defined by the otherRect's edges + */ + Rect[] cuts = { + new Rect(rectToModify.left, rectToModify.top, otherRect.left, rectToModify.bottom), + new Rect(rectToModify.left, rectToModify.top, rectToModify.right, otherRect.top), + new Rect(otherRect.right, rectToModify.top, rectToModify.right, rectToModify.bottom), + new Rect(rectToModify.left, otherRect.bottom, rectToModify.right, rectToModify.bottom) + }; + + int maxIntersectingRectArea = 0; + int indexOfLargestIntersection = -1; + for (int i = 0; i < cuts.length; i++) { + if (!isSorted(cuts[i])) { + continue; + } + if (Rect.intersects(cuts[i], rectToModify)) { + /* Reassign this cut to its intersection with rectToModify */ + int visibleRectArea = cuts[i].width() * cuts[i].height(); + if (visibleRectArea > maxIntersectingRectArea) { + maxIntersectingRectArea = visibleRectArea; + indexOfLargestIntersection = i; + } + } + } + if (maxIntersectingRectArea <= 0) { + // The rectToModify isn't within any of our cuts, so it's entirely occuled by otherRect. + rectToModify.setEmpty(); + return; + } + rectToModify.set(cuts[indexOfLargestIntersection]); + } + + /** + * Returns whether the two rectangles are at the same line. (Have the same top & bottom values.) + */ + public static boolean isAligned(@NonNull Rect rect1, @NonNull Rect rect2) { + return rect1.top == rect2.top && rect1.bottom == rect2.bottom; + } + + /** + * Finds the smallest Rect that contains {@code target} and {@code candidate}, and store the + * result in {@code target}. Note: The input Rect must be sorted. + */ + public static void join(Rect candidate, Rect target) { + target.set( + Math.min(target.left, candidate.left), + Math.min(target.top, candidate.top), + Math.max(target.right, candidate.right), + Math.max(target.bottom, candidate.bottom)); + } + + /** + * Collapses aligned rectangles into a single rectangle. Stores a list of rectangles representing + * joint lines. The result list is sorted from top to bottom, left to right. + * + * @see {@link #isAligned(Rect, Rect)} + * @see {@link #join(Rect, Rect)} + */ + public static void collapseRects(List rectList) { + if (rectList == null || rectList.size() <= 1) { + return; + } + List copy = new ArrayList<>(rectList); + Collections.sort(copy, RECT_POSITION_COMPARATOR); + rectList.clear(); + Rect tmp = new Rect(copy.get(0)); + for (Rect rect : copy) { + if (isAligned(tmp, rect)) { + join(rect, tmp); + } else { + rectList.add(tmp); + tmp = new Rect(rect); + } + } + rectList.add(tmp); + } + + /** + * Sorts elements by their bounds. + * + *

If current screen layout is RTL, the order is from right to left, from top to bottom. + * + *

If current screen layout is not RTL, the order is from left to right, from top to bottom. + * + * @param elements The elements which have bounds + * @param rectExtractor The function used to extract bounds from the element + * @param isRTL Whether the current screen layout is RTL + * @param The type of the element + */ + public static ImmutableList sortByRows( + ImmutableList elements, Function rectExtractor, boolean isRTL) { + if (elements.size() <= 1) { + return elements; + } + + List list = new ArrayList<>(elements); + ImmutableList.Builder results = ImmutableList.builder(); + while (!list.isEmpty()) { + // Finds the element with the minimum top value + T minTopElement = list.get(0); + int minTop = rectExtractor.apply(minTopElement).top; + for (int i = 1; i < list.size(); i++) { + Rect bounds = rectExtractor.apply(list.get(i)); + if (bounds.top < minTop) { + minTop = bounds.top; + minTopElement = list.get(i); + } + } + + // Finds all elements which are considered in the same row as the element with the minimum top + // value + List newRow = new ArrayList<>(); + int bottom = rectExtractor.apply(minTopElement).bottom; + for (int i = list.size() - 1; i >= 0; i--) { + T element = list.get(i); + Rect bounds = rectExtractor.apply(element); + if (bounds.top < bottom) { + newRow.add(element); + list.remove(i); + } + } + + Collections.sort( + newRow, + (t1, t2) -> { + Rect r1 = rectExtractor.apply(t1); + Rect r2 = rectExtractor.apply(t2); + int start1 = isRTL ? r1.right : r2.left; + int start2 = isRTL ? r2.right : r1.left; + return (start1 == start2) ? (r1.top - r2.top) : (start2 - start1); + }); + results.addAll(newRow); + } + return results.build(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ResourceUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/ResourceUtils.java new file mode 100644 index 0000000..eb43dbe --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ResourceUtils.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils; + +import android.content.Context; +import android.content.res.Resources; +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Utility methods for resources. */ +public class ResourceUtils { + + public static int getResourceIdFromString(Context context, String resourceIdString) { + if (resourceIdString == null) { + return 0; + } + + if (resourceIdString.startsWith("@")) { + resourceIdString = resourceIdString.substring(1); + } + + String[] pair = resourceIdString.split("/"); + if (pair == null || pair.length != 2) { + throw new IllegalArgumentException("Resource parameter is malformed: " + resourceIdString); + } + + Resources res = context.getResources(); + return res.getIdentifier(pair[1], pair[0], context.getPackageName()); + } + + public static @Nullable String readStringByResourceIdFromString( + Context context, String resourceIdString) { + int resourceId = getResourceIdFromString(context, resourceIdString); + if (resourceId != 0) { + return context.getString(resourceId); + } + + return null; + } + + /** Returns the @colorInt associated with a particular resource ID {@code colorResId}. */ + @ColorInt + public static int getColor(@ColorRes int colorResId, Context context) { + // Resources.getColor(int) is deprecated M onwards and + // Context.getColor(int) is added from M onwards. + return context.getColor(colorResId); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/Role.java b/utils/src/main/java/com/google/android/accessibility/utils/Role.java new file mode 100644 index 0000000..1544d50 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/Role.java @@ -0,0 +1,496 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.ProgressBar; +import android.widget.SeekBar; +import androidx.annotation.IntDef; +import androidx.core.view.accessibility.AccessibilityEventCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat; +import androidx.core.view.accessibility.AccessibilityRecordCompat; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Utility methods for managing AccessibilityNodeInfo Roles. */ +public class Role { + + /** + * Ids of user-interface element roles, which are flexibly mapped from specific UI classes. This + * mapping allows us to abstract similar UI elements to the same role, and to isolate UI element + * interpretation logic. + */ + @IntDef({ + ROLE_NONE, + ROLE_BUTTON, + ROLE_CHECK_BOX, + ROLE_CHECKED_TEXT_VIEW, + ROLE_DROP_DOWN_LIST, + ROLE_EDIT_TEXT, + ROLE_GRID, + ROLE_IMAGE, + ROLE_IMAGE_BUTTON, + ROLE_LIST, + ROLE_PAGER, + ROLE_RADIO_BUTTON, + ROLE_SEEK_CONTROL, + ROLE_SWITCH, + ROLE_TAB_BAR, + ROLE_TOGGLE_BUTTON, + ROLE_VIEW_GROUP, + ROLE_WEB_VIEW, + ROLE_PROGRESS_BAR, + ROLE_ACTION_BAR_TAB, + ROLE_DRAWER_LAYOUT, + ROLE_SLIDING_DRAWER, + ROLE_ICON_MENU, + ROLE_TOAST, + ROLE_ALERT_DIALOG, + ROLE_DATE_PICKER_DIALOG, + ROLE_TIME_PICKER_DIALOG, + ROLE_DATE_PICKER, + ROLE_TIME_PICKER, + ROLE_NUMBER_PICKER, + ROLE_SCROLL_VIEW, + ROLE_HORIZONTAL_SCROLL_VIEW, + ROLE_KEYBOARD_KEY, + ROLE_TALKBACK_EDIT_TEXT_OVERLAY, + ROLE_TEXT_ENTRY_KEY, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface RoleName {} + + // Please keep the constants in this list sorted by constant index order, and not by + // alphabetical order. If you add a new constant, it must also be added to the RoleName + // annotation interface. + public static final int ROLE_NONE = 0; + public static final int ROLE_BUTTON = 1; + public static final int ROLE_CHECK_BOX = 2; + public static final int ROLE_DROP_DOWN_LIST = 3; + public static final int ROLE_EDIT_TEXT = 4; + public static final int ROLE_GRID = 5; + public static final int ROLE_IMAGE = 6; + public static final int ROLE_IMAGE_BUTTON = 7; + public static final int ROLE_LIST = 8; + public static final int ROLE_RADIO_BUTTON = 9; + public static final int ROLE_SEEK_CONTROL = 10; + public static final int ROLE_SWITCH = 11; + public static final int ROLE_TAB_BAR = 12; + public static final int ROLE_TOGGLE_BUTTON = 13; + public static final int ROLE_VIEW_GROUP = 14; + public static final int ROLE_WEB_VIEW = 15; + public static final int ROLE_PAGER = 16; + public static final int ROLE_CHECKED_TEXT_VIEW = 17; + public static final int ROLE_PROGRESS_BAR = 18; + public static final int ROLE_ACTION_BAR_TAB = 19; + public static final int ROLE_DRAWER_LAYOUT = 20; + public static final int ROLE_SLIDING_DRAWER = 21; + public static final int ROLE_ICON_MENU = 22; + public static final int ROLE_TOAST = 23; + public static final int ROLE_ALERT_DIALOG = 24; + public static final int ROLE_DATE_PICKER_DIALOG = 25; + public static final int ROLE_TIME_PICKER_DIALOG = 26; + public static final int ROLE_DATE_PICKER = 27; + public static final int ROLE_TIME_PICKER = 28; + public static final int ROLE_NUMBER_PICKER = 29; + public static final int ROLE_SCROLL_VIEW = 30; + public static final int ROLE_HORIZONTAL_SCROLL_VIEW = 31; + public static final int ROLE_KEYBOARD_KEY = 32; + public static final int ROLE_TALKBACK_EDIT_TEXT_OVERLAY = 33; + public static final int ROLE_TEXT_ENTRY_KEY = 34; + + // Number of roles: 34 + + /** Used to identify and ignore a11y overlay windows created by Talkback. */ + public static final String TALKBACK_EDIT_TEXT_OVERLAY_CLASSNAME = "TalkbackEditTextOverlay"; + + /** + * Gets the source {@link Role} from the {@link AccessibilityEvent}. + * + *

It checks the role with {@link AccessibilityEvent#getClassName()}. If it returns {@link + * #ROLE_NONE}, fallback to check {@link AccessibilityNodeInfoCompat#getClassName()} of the source + * node. + */ + @RoleName + public static int getSourceRole(AccessibilityEvent event) { + if (event == null) { + return ROLE_NONE; + } + + // Try to get role from event's class name. + @RoleName int role = sourceClassNameToRole(event); + if (role != ROLE_NONE) { + return role; + } + + // Extract event's source node, and map source node class to role. + AccessibilityRecordCompat eventRecord = AccessibilityEventCompat.asRecord(event); + AccessibilityNodeInfoCompat source = eventRecord.getSource(); + try { + return getRole(source); + } finally { + AccessibilityNodeInfoUtils.recycleNodes(source); + } + } + + /** Find role from source event's class name string. */ + @RoleName + private static int sourceClassNameToRole(AccessibilityEvent event) { + if (event == null) { + return ROLE_NONE; + } + + // Event TYPE_NOTIFICATION_STATE_CHANGED always has null source node. + CharSequence eventClassName = event.getClassName(); + + // When comparing event.getClassName() to class name of standard widgets, we should take care of + // the order of the "if" statements: check subclasses before checking superclasses. + + // Toast.TN is a private class, thus we have to hard code the class name. + // "$TN" is only in the class-name before android-R. + if (ClassLoadingCache.checkInstanceOf(eventClassName, "android.widget.Toast$TN") + || ClassLoadingCache.checkInstanceOf(eventClassName, "android.widget.Toast")) { + return ROLE_TOAST; + } + + // Some events have different value for getClassName() and getSource().getClass() + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.app.ActionBar.Tab.class)) { + return ROLE_ACTION_BAR_TAB; + } + + // ////////////////////////////////////////////////////////////////////////////////////////// + // Subclasses of ViewGroup. + + // Inheritance: View->ViewGroup->DrawerLayout + if (ClassLoadingCache.checkInstanceOf( + eventClassName, androidx.drawerlayout.widget.DrawerLayout.class) + || ClassLoadingCache.checkInstanceOf(eventClassName, "androidx.core.widget.DrawerLayout")) { + return ROLE_DRAWER_LAYOUT; + } + + // Inheritance: View->ViewGroup->SlidingDrawer + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.SlidingDrawer.class)) { + return ROLE_SLIDING_DRAWER; + } + + // Inheritance: View->ViewGroup->IconMenuView + // IconMenuView is a hidden class, thus we have to hard code the class name. + if (ClassLoadingCache.checkInstanceOf( + eventClassName, "com.android.internal.view.menu.IconMenuView")) { + return ROLE_ICON_MENU; + } + + // Inheritance: View->ViewGroup->FrameLayout->DatePicker + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.DatePicker.class)) { + return ROLE_DATE_PICKER; + } + + // Inheritance: View->ViewGroup->FrameLayout->TimePicker + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.TimePicker.class)) { + return ROLE_TIME_PICKER; + } + + // Inheritance: View->ViewGroup->LinearLayout->NumberPicker + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.NumberPicker.class)) { + return ROLE_NUMBER_PICKER; + } + + // ////////////////////////////////////////////////////////////////////////////////////////// + // Subclasses of Dialog. + // Inheritance: Dialog->AlertDialog->DatePickerDialog + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.app.DatePickerDialog.class)) { + return ROLE_DATE_PICKER_DIALOG; + } + + // Inheritance: Dialog->AlertDialog->TimePickerDialog + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.app.TimePickerDialog.class)) { + return ROLE_TIME_PICKER_DIALOG; + } + + // Inheritance: Dialog->AlertDialog + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.app.AlertDialog.class) + || ClassLoadingCache.checkInstanceOf( + eventClassName, "androidx.appcompat.app.AlertDialog")) { + return ROLE_ALERT_DIALOG; + } + + return ROLE_NONE; + } + + /** Gets {@link Role} for {@link AccessibilityNodeInfoCompat}. */ + @RoleName + public static int getRole(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return ROLE_NONE; + } + + // We check Text entry key from property instead of class, so it needs to be in the beginning. + if (AccessibilityNodeInfoUtils.isTextEntryKey(node)) { + return ROLE_TEXT_ENTRY_KEY; + } + CharSequence className = node.getClassName(); + + // When comparing node.getClassName() to class name of standard widgets, we should take care of + // the order of the "if" statements: check subclasses before checking superclasses. + // e.g. RadioButton is a subclass of Button, we should check Role RadioButton first and fall + // down to check Role Button. + + // Identifies a11y overlay added by Talkback on edit texts. + if (ClassLoadingCache.checkInstanceOf(className, TALKBACK_EDIT_TEXT_OVERLAY_CLASSNAME)) { + return ROLE_TALKBACK_EDIT_TEXT_OVERLAY; + } + + // Inheritance: View->ImageView + if (ClassLoadingCache.checkInstanceOf(className, android.widget.ImageView.class)) { + return node.isClickable() ? ROLE_IMAGE_BUTTON : ROLE_IMAGE; + } + + // ////////////////////////////////////////////////////////////////////////////////////////// + // Subclasses of TextView. + + // Inheritance: View->TextView->Button->CompoundButton->Switch + if (ClassLoadingCache.checkInstanceOf(className, android.widget.Switch.class)) { + return ROLE_SWITCH; + } + + // Inheritance: View->TextView->Button->CompoundButton->ToggleButton + if (ClassLoadingCache.checkInstanceOf(className, android.widget.ToggleButton.class)) { + return ROLE_TOGGLE_BUTTON; + } + + // Inheritance: View->TextView->Button->CompoundButton->RadioButton + if (ClassLoadingCache.checkInstanceOf(className, android.widget.RadioButton.class)) { + return ROLE_RADIO_BUTTON; + } + + // Inheritance: View->TextView->Button->CompoundButton + if (ClassLoadingCache.checkInstanceOf(className, android.widget.CompoundButton.class)) { + return ROLE_CHECK_BOX; + } + + // Inheritance: View->TextView->Button + if (ClassLoadingCache.checkInstanceOf(className, android.widget.Button.class)) { + return ROLE_BUTTON; + } + + // Inheritance: View->TextView->CheckedTextView + if (ClassLoadingCache.checkInstanceOf(className, android.widget.CheckedTextView.class)) { + return ROLE_CHECKED_TEXT_VIEW; + } + + // Inheritance: View->TextView->EditText + if (ClassLoadingCache.checkInstanceOf(className, android.widget.EditText.class)) { + return ROLE_EDIT_TEXT; + } + + // ////////////////////////////////////////////////////////////////////////////////////////// + // Subclasses of ProgressBar. + + // Inheritance: View->ProgressBar->AbsSeekBar->SeekBar + if (ClassLoadingCache.checkInstanceOf(className, SeekBar.class) + || (AccessibilityNodeInfoUtils.hasValidRangeInfo(node) + && AccessibilityNodeInfoUtils.supportsAction( + node, android.R.id.accessibilityActionSetProgress))) { + return ROLE_SEEK_CONTROL; + } + + // Inheritance: View->ProgressBar + if (ClassLoadingCache.checkInstanceOf(className, ProgressBar.class) + || (AccessibilityNodeInfoUtils.hasValidRangeInfo(node) + && !AccessibilityNodeInfoUtils.supportsAction( + node, android.R.id.accessibilityActionSetProgress))) { + // ProgressBar check must come after SeekBar, because SeekBar specializes ProgressBar. + return ROLE_PROGRESS_BAR; + } + + if (ClassLoadingCache.checkInstanceOf( + className, android.inputmethodservice.Keyboard.Key.class)) { + return ROLE_KEYBOARD_KEY; + } + + // ////////////////////////////////////////////////////////////////////////////////////////// + // Subclasses of ViewGroup. + + // Inheritance: View->ViewGroup->AbsoluteLayout->WebView + if (ClassLoadingCache.checkInstanceOf(className, android.webkit.WebView.class)) { + return ROLE_WEB_VIEW; + } + + // Inheritance: View->ViewGroup->LinearLayout->TabWidget + if (ClassLoadingCache.checkInstanceOf(className, android.widget.TabWidget.class)) { + return ROLE_TAB_BAR; + } + + // Inheritance: View->ViewGroup->FrameLayout->HorizontalScrollView + // If there is a CollectionInfo, fall into a ROLE_LIST/ROLE_GRID + if (ClassLoadingCache.checkInstanceOf(className, android.widget.HorizontalScrollView.class) + && node.getCollectionInfo() == null) { + return ROLE_HORIZONTAL_SCROLL_VIEW; + } + + // Inheritance: View->ViewGroup->FrameLayout->ScrollView + if (ClassLoadingCache.checkInstanceOf(className, android.widget.ScrollView.class)) { + return ROLE_SCROLL_VIEW; + } + + // Inheritance: View->ViewGroup->ViewPager + if (ClassLoadingCache.checkInstanceOf(className, androidx.viewpager.widget.ViewPager.class) + || ClassLoadingCache.checkInstanceOf(className, "android.support.v4.view.ViewPager") + || ClassLoadingCache.checkInstanceOf(className, "androidx.core.view.ViewPager")) { + return ROLE_PAGER; + } + + // Inheritance: View->ViewGroup->AdapterView->AbsSpinner->Spinner + if (ClassLoadingCache.checkInstanceOf(className, android.widget.Spinner.class)) { + return ROLE_DROP_DOWN_LIST; + } + + // Inheritance: View->ViewGroup->AdapterView->AbsListView->GridView + if (ClassLoadingCache.checkInstanceOf(className, android.widget.GridView.class)) { + return ROLE_GRID; + } + + // Inheritance: View->ViewGroup->AdapterView->AbsListView + if (ClassLoadingCache.checkInstanceOf(className, android.widget.AbsListView.class)) { + return ROLE_LIST; + } + + // Inheritance: View->ViewGroup->ViewPager2 + if (AccessibilityNodeInfoUtils.supportsAction( + node, AccessibilityActionCompat.ACTION_PAGE_UP.getId()) + || AccessibilityNodeInfoUtils.supportsAction( + node, AccessibilityActionCompat.ACTION_PAGE_DOWN.getId()) + || AccessibilityNodeInfoUtils.supportsAction( + node, AccessibilityActionCompat.ACTION_PAGE_LEFT.getId()) + || AccessibilityNodeInfoUtils.supportsAction( + node, AccessibilityActionCompat.ACTION_PAGE_RIGHT.getId())) { + return ROLE_PAGER; + } + + CollectionInfoCompat collection = node.getCollectionInfo(); + if (collection != null) { + // RecyclerView will be classified as a list or grid. + if (collection.getRowCount() > 1 && collection.getColumnCount() > 1) { + return ROLE_GRID; + } else { + return ROLE_LIST; + } + } + + // Inheritance: View->ViewGroup + if (ClassLoadingCache.checkInstanceOf(className, android.view.ViewGroup.class)) { + return ROLE_VIEW_GROUP; + } + + return ROLE_NONE; + } + + /** + * Gets {@link Role} for {@link AccessibilityNodeInfo}. @See {@link + * #getRole(AccessibilityNodeInfoCompat)} + */ + @RoleName + public static int getRole(@Nullable AccessibilityNodeInfo node) { + if (node == null) { + return Role.ROLE_NONE; + } + + AccessibilityNodeInfoCompat nodeCompat = AccessibilityNodeInfoUtils.toCompat(node); + return getRole(nodeCompat); + } + + /** For use in logging. */ + public static String roleToString(@RoleName int role) { + switch (role) { + case ROLE_NONE: + return "ROLE_NONE"; + case ROLE_BUTTON: + return "ROLE_BUTTON"; + case ROLE_CHECK_BOX: + return "ROLE_CHECK_BOX"; + case ROLE_DROP_DOWN_LIST: + return "ROLE_DROP_DOWN_LIST"; + case ROLE_EDIT_TEXT: + return "ROLE_EDIT_TEXT"; + case ROLE_GRID: + return "ROLE_GRID"; + case ROLE_IMAGE: + return "ROLE_IMAGE"; + case ROLE_IMAGE_BUTTON: + return "ROLE_IMAGE_BUTTON"; + case ROLE_LIST: + return "ROLE_LIST"; + case ROLE_RADIO_BUTTON: + return "ROLE_RADIO_BUTTON"; + case ROLE_SEEK_CONTROL: + return "ROLE_SEEK_CONTROL"; + case ROLE_SWITCH: + return "ROLE_SWITCH"; + case ROLE_TAB_BAR: + return "ROLE_TAB_BAR"; + case ROLE_TOGGLE_BUTTON: + return "ROLE_TOGGLE_BUTTON"; + case ROLE_VIEW_GROUP: + return "ROLE_VIEW_GROUP"; + case ROLE_WEB_VIEW: + return "ROLE_WEB_VIEW"; + case ROLE_PAGER: + return "ROLE_PAGER"; + case ROLE_CHECKED_TEXT_VIEW: + return "ROLE_CHECKED_TEXT_VIEW"; + case ROLE_PROGRESS_BAR: + return "ROLE_PROGRESS_BAR"; + case ROLE_ACTION_BAR_TAB: + return "ROLE_ACTION_BAR_TAB"; + case ROLE_DRAWER_LAYOUT: + return "ROLE_DRAWER_LAYOUT"; + case ROLE_SLIDING_DRAWER: + return "ROLE_SLIDING_DRAWER"; + case ROLE_ICON_MENU: + return "ROLE_ICON_MENU"; + case ROLE_TOAST: + return "ROLE_TOAST"; + case ROLE_ALERT_DIALOG: + return "ROLE_ALERT_DIALOG"; + case ROLE_DATE_PICKER_DIALOG: + return "ROLE_DATE_PICKER_DIALOG"; + case ROLE_TIME_PICKER_DIALOG: + return "ROLE_TIME_PICKER_DIALOG"; + case ROLE_DATE_PICKER: + return "ROLE_DATE_PICKER"; + case ROLE_TIME_PICKER: + return "ROLE_TIME_PICKER"; + case ROLE_NUMBER_PICKER: + return "ROLE_NUMBER_PICKER"; + case ROLE_SCROLL_VIEW: + return "ROLE_SCROLL_VIEW"; + case ROLE_HORIZONTAL_SCROLL_VIEW: + return "ROLE_HORIZONTAL_SCROLL_VIEW"; + case ROLE_KEYBOARD_KEY: + return "ROLE_KEYBOARD_KEY"; + case ROLE_TALKBACK_EDIT_TEXT_OVERLAY: + return "ROLE_TALKBACK_EDIT_TEXT_OVERLAY"; + case ROLE_TEXT_ENTRY_KEY: + return "ROLE_TEXT_ENTRY_KEY"; + default: + return "(unknown role " + role + ")"; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ServiceKeyEventListener.java b/utils/src/main/java/com/google/android/accessibility/utils/ServiceKeyEventListener.java new file mode 100644 index 0000000..8384dfb --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ServiceKeyEventListener.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2017 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.view.KeyEvent; +import com.google.android.accessibility.utils.Performance.EventId; + +/** + * Receive events from a service, with the option to continue receiving them if the service is in a + * suspended state. + */ +public interface ServiceKeyEventListener { + /** Called when a key event is received. */ + boolean onKeyEvent(KeyEvent event, EventId eventId); + + /** Determines whether events are received when the service isn't running. */ + boolean processWhenServiceSuspended(); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ServiceStateListener.java b/utils/src/main/java/com/google/android/accessibility/utils/ServiceStateListener.java new file mode 100644 index 0000000..85e4edc --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ServiceStateListener.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2017 Google Inc. + * + * 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.google.android.accessibility.utils; + +/** + * Interface for receiving callbacks when the state a service changes. + * + *

Implementing controllers should note that this may be invoked even after the controller was + * explicitly shut down. + */ +public interface ServiceStateListener { + /** The possible states of the service. */ + /** The state of the service before the system has bound to it or after it is destroyed. */ + final int SERVICE_STATE_INACTIVE = 0; + /** The state of the service when it initialized and active. */ + final int SERVICE_STATE_ACTIVE = 1; + /** The state of the service when it has been suspended by the user. */ + final int SERVICE_STATE_SUSPENDED = 2; + /** + * The state of the service when it is in the process of beeing shut down by the user. Unhandled + * exceptions will be logged in this state but will not be passed on to the system's unhandled + * exception handler. + */ + final int SERVICE_STATE_SHUTTING_DOWN = 3; + + void onServiceStateChanged(int newState); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/SettingsUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/SettingsUtils.java new file mode 100644 index 0000000..8af4782 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/SettingsUtils.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.provider.Settings; + +/** Utility class to access {@link android.provider.Settings}. */ +public class SettingsUtils { + + /** Value of hidden constant {@code android.provider.Settings.Secure.USER_SETUP_COMPLETE} */ + public static final String USER_SETUP_COMPLETE = "user_setup_complete"; + + public static boolean allowLinksOutOfSettings(Context context) { + // Do not allow access to web during setup. REFERTO affects android M-O. + return 1 == Settings.Secure.getInt(context.getContentResolver(), USER_SETUP_COMPLETE, 0); + } + + /** + * Returns whether a specific accessibility service is enabled. + * + * @param context The parent context. + * @param packageName The package name of the accessibility service. + * @return {@code true} of the service is enabled. + */ + public static boolean isAccessibilityServiceEnabled(Context context, String packageName) { + final ContentResolver resolver = context.getContentResolver(); + final String enabledServices = + Settings.Secure.getString(resolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES); + + return enabledServices.contains(packageName); + } + + /** + * Returns flag whether animation setting is definitely disabled. If no setting found, returns + * false. + */ + public static boolean isAnimationDisabled(Context context) { + return FeatureSupport.disableAnimation() + && (0 == getGlobalInt(context, Settings.Global.WINDOW_ANIMATION_SCALE)) + && (0 == getGlobalInt(context, Settings.Global.TRANSITION_ANIMATION_SCALE)) + && (0 == getGlobalInt(context, Settings.Global.ANIMATOR_DURATION_SCALE)); + } + + /** + * Requests system settings writing permission if the parent context needs. + * + * @param context The parent context + * @return {@code true} has the permission; {@code false} need to request the permission + */ + public static boolean requestWriteSettingsPermission(Context context) { + boolean hasWritePermission = Settings.System.canWrite(context); + if (hasWritePermission) { + return true; + } + // Starting in M, we need the user to manually allow the app to modify system settings. + Intent intent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS); + intent.setData(Uri.parse("package:" + context.getPackageName())); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + return false; + } + + /** Returns value of constants in Settings.Global. */ + private static int getGlobalInt(Context context, String constantName) { + int value = Settings.Global.getInt(context.getContentResolver(), constantName, -1); + return value; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/SharedKeyEvent.java b/utils/src/main/java/com/google/android/accessibility/utils/SharedKeyEvent.java new file mode 100644 index 0000000..9c197f3 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/SharedKeyEvent.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2017 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.view.KeyEvent; +import java.util.ArrayList; +import java.util.List; + +/** + * This class forwards keyEvents to all services that listen for it. + * + *

On Android M and lower, keyEvents are only sent to a single service. On Android N and higher, + * all services get key events. This class hides this detail from services, and ensures they all get + * key events. + */ +public class SharedKeyEvent { + /** Interface for receiving a shared key event */ + public interface Listener { + boolean onKeyEventShared(KeyEvent keyEvent); + } + + // List of keyEvent listeners. + private static List sListeners = new ArrayList<>(); + + // Cannot be instantiated. + private SharedKeyEvent() {} + + /** + * Add a listener to shared key events. + * + * @param listener Object that will listen to shared key events. + */ + public static void register(Listener listener) { + sListeners.add(listener); + } + + /** + * Remove a listener to shared key events. + * + * @param listener Object that will listen to shared key events. + */ + public static void unregister(Listener listener) { + sListeners.remove(listener); + } + + /** + * Send a key event to all listeners. + * + *

On M and lower, the event will be sent to all listeners. On N and higher, it will only be + * sent to the current listener. + * + * @param listener Service that received the keyEvent. + * @param keyEvent Event received from the system. + */ + public static boolean onKeyEvent(Listener listener, KeyEvent keyEvent) { + if (BuildVersionUtils.isAtLeastN()) { + return listener.onKeyEventShared(keyEvent); + } else { + boolean handled = false; + for (Listener currentListener : sListeners) { + handled = currentListener.onKeyEventShared(keyEvent) || handled; + } + return handled; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/SharedPreferencesUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/SharedPreferencesUtils.java new file mode 100644 index 0000000..02838d9 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/SharedPreferencesUtils.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.preference.PreferenceManager; +import androidx.core.content.ContextCompat; + +/** Utility methods for interacting with {@link SharedPreferences} objects. */ +public class SharedPreferencesUtils { + /** + * Returns the value of an integer preference stored as a string. This is necessary when using a + * {@link android.preference.ListPreference} to manage an integer preference, since the entries + * must be String values. + * + * @param prefs Shared preferences from which to obtain the value + * @param res Resources from which to obtain the key and default value + * @param keyResId Resource identifier for the key + * @param defaultResId Resource identifier for the default value + * @return The preference value, or the default value if not set + */ + public static int getIntFromStringPref( + SharedPreferences prefs, Resources res, int keyResId, int defaultResId) { + final String strPref = prefs.getString(res.getString(keyResId), res.getString(defaultResId)); + return Integer.parseInt(strPref); + } + + /** + * Returns the value of a floating point preference stored as a string. This is necessary when + * using a {@link android.preference.ListPreference} to manage a floating point preference, since + * the entries must be String values. + * + * @param prefs Shared preferences from which to obtain the value + * @param res Resources from which to obtain the key and default value + * @param keyResId Resource identifier for the key + * @param defaultResId Resource identifier for the default value + * @return The preference value, or the default value if not set + */ + public static float getFloatFromStringPref( + SharedPreferences prefs, Resources res, int keyResId, int defaultResId) { + final String strPref = prefs.getString(res.getString(keyResId), res.getString(defaultResId)); + return Float.parseFloat(strPref); + } + + /** + * Returns the value of a string preference. + * + * @param prefs Shared preferences from which to obtain the value + * @param res Resources from which to obtain the key and default value + * @param keyResId Resource identifier for the key + * @param defaultResId Resource identifier for the default value + * @return The preference value, or the default value if not set + */ + public static String getStringPref( + SharedPreferences prefs, Resources res, int keyResId, int defaultResId) { + return prefs.getString( + res.getString(keyResId), ((defaultResId == 0) ? null : res.getString(defaultResId))); + } + + /** + * Stores the value of a String preference. + * + * @param prefs Shared preferences from which to obtain the value + * @param res Resources from which to obtain the key and default value + * @param keyResId Resource identifier for the key + * @param value The value to store + */ + public static void putStringPref( + SharedPreferences prefs, Resources res, int keyResId, String value) { + final SharedPreferences.Editor editor = prefs.edit(); + editor.putString(res.getString(keyResId), value); + editor.apply(); + } + + /** + * Returns the value of an integer preference. + * + * @param prefs Shared preferences from which to obtain the value + * @param res Resources from which to obtain the key and default value + * @param keyResId Resource identifier for the key + * @param defaultResId Resource identifier for the default value + * @return The preference value, or the default value if not set + */ + public static int getIntPref( + SharedPreferences prefs, Resources res, int keyResId, int defaultResId) { + if (defaultResId == 0) { + throw new IllegalArgumentException("defaultResId should not be 0"); + } + return prefs.getInt(res.getString(keyResId), res.getInteger(defaultResId)); + } + + /** + * Stores the value of an integer preference. + * + * @param prefs Shared preferences from which to obtain the value + * @param res Resources from which to obtain the key and default value + * @param keyResId Resource identifier for the key + * @param value The value to store + */ + public static void putIntPref(SharedPreferences prefs, Resources res, int keyResId, int value) { + final SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(res.getString(keyResId), value); + editor.apply(); + } + + /** + * Returns the value of a boolean preference. + * + * @param prefs Shared preferences from which to obtain the value + * @param res Resources from which to obtain the key and default value + * @param keyResId Resource identifier for the key + * @param defaultResId Resource identifier for the default value + * @return The preference value, or the default value if not set + */ + public static boolean getBooleanPref( + SharedPreferences prefs, Resources res, int keyResId, int defaultResId) { + return prefs.getBoolean(res.getString(keyResId), res.getBoolean(defaultResId)); + } + + /** + * Returns the value of a boolean preference. + * + * @param prefs Shared preferences from which to obtain the value + * @param res Resources from which to obtain the key and default value + * @param keyResId Resource identifier for the key + * @param defaultValue The default value + * @return The preference value, or the default value if not set + */ + public static boolean getBooleanPref( + SharedPreferences prefs, Resources res, int keyResId, boolean defaultValue) { + return prefs.getBoolean(res.getString(keyResId), defaultValue); + } + + /** + * Stores the value of a boolean preference. + * + * @param prefs Shared preferences from which to obtain the value + * @param res Resources from which to obtain the key and default value + * @param keyResId Resource identifier for the key + * @param value The value to store + */ + public static void putBooleanPref( + SharedPreferences prefs, Resources res, int keyResId, boolean value) { + storeBooleanAsync(prefs, res.getString(keyResId), value); + } + + /** + * Stores the value of a boolean preference async. + * + * @param prefs Shared preferences from which to obtain the value + * @param key The pref key + * @param value The value to store + */ + public static void storeBooleanAsync(SharedPreferences prefs, String key, boolean value) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(key, value); + editor.apply(); + } + + /** + * Gets the appropriate SharedPreferences depending on the device capabilities. On systems that + * support device-protected storage (Android N or later with compatible device), returns + * SharedPreferences backed by device-protected storage. Otherwise, returns SharedPreferences + * backed by a standard credential-protected storage context. + */ + public static SharedPreferences getSharedPreferences(Context context) { + Context deContext = ContextCompat.createDeviceProtectedStorageContext(context); + if (deContext != null) { + return PreferenceManager.getDefaultSharedPreferences(deContext); + } else { + return PreferenceManager.getDefaultSharedPreferences(context); + } + } + + /** + * Gets the appropriate SharedPreferences depending on the device capabilities by {@code name}. On + * systems that support device-protected storage (Android N or later with compatible device), + * returns SharedPreferences backed by device-protected storage. Otherwise, returns + * SharedPreferences backed by a standard credential-protected storage context. + */ + public static SharedPreferences getSharedPreferences(Context context, String name) { + Context deContext = ContextCompat.createDeviceProtectedStorageContext(context); + if (deContext != null) { + return deContext.getSharedPreferences(name, Context.MODE_PRIVATE); + } + return context.getSharedPreferences(name, Context.MODE_PRIVATE); + } + + /** + * Move existing preferences file from credential protected storage to device protected storage. + * This is used to migrate data between storage locations after an Android upgrade from + * Build.VERSION < N to Build.VERSION >= N. + */ + public static void migrateSharedPreferences(Context context) { + if (BuildVersionUtils.isAtLeastN()) { + Context deContext = ContextCompat.createDeviceProtectedStorageContext(context); + deContext.moveSharedPreferencesFrom( + context, PreferenceManager.getDefaultSharedPreferencesName(context)); + } + } + + /** Removes the preferences. */ + public static void remove(SharedPreferences pref, String... keys) { + for (String key : keys) { + if (pref.contains(key)) { + pref.edit().remove(key).apply(); + } + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/SpannableUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/SpannableUtils.java new file mode 100644 index 0000000..f3dfe2b --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/SpannableUtils.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2017 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.os.Build; +import android.os.LocaleList; +import android.os.PersistableBundle; +import android.text.ParcelableSpan; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.SpannedString; +import android.text.TextUtils; +import android.text.style.LocaleSpan; +import android.text.style.TtsSpan; +import android.text.style.URLSpan; +import android.util.Log; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.Locale; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Utility methods for working with spannable objects. */ +public final class SpannableUtils { + + /** Identifies separators attached in spoken feedback. */ + public static class IdentifierSpan {} + + public static CharSequence wrapWithIdentifierSpan(CharSequence text) { + if (TextUtils.isEmpty(text)) { + return text; + } + SpannableString spannedText = new SpannableString(text); + spannedText.setSpan( + new SpannableUtils.IdentifierSpan(), + /* start= */ 0, + /* end= */ text.length(), + /* flags= */ 0); + return spannedText; + } + + public static boolean isWrappedWithTargetSpan( + CharSequence text, Class spanClass, boolean shouldTrim) { + if (TextUtils.isEmpty(text) || !(text instanceof Spannable)) { + return false; + } + if (shouldTrim) { + text = trimText(text); + } + if (TextUtils.isEmpty(text)) { + return false; + } + Spannable spannable = (Spannable) text; + T[] spans = spannable.getSpans(0, text.length(), spanClass); + if ((spans == null) || (spans.length != 1)) { + return false; + } + + T span = spans[0]; + return (spannable.getSpanStart(span) == 0) + && (spannable.getSpanEnd(span) == spannable.length()); + } + + // Avoid using String.trim() so that Span info is not lost. Use this method for CharSequence trim. + public static CharSequence trimText(CharSequence text) { + int start = 0; + int last = text.length() - 1; + while ((start <= last) && Character.isWhitespace(text.charAt(start))) { + start++; + } + + while ((last > start) && Character.isWhitespace(text.charAt(last))) { + last--; + } + CharSequence trimmedText = text.subSequence(start, (last + 1)); + return trimmedText; + } + + /** + * Retrieves SpannableString containing the target span in the accessibility node. The content + * description and text of the node is checked in order. + * + * @param node The AccessibilityNodeInfoCompat where the text comes from. + * @param spanClass Class of target span. + * @return SpannableString with at least 1 target span. null if no target span found in the node. + */ + public static @Nullable SpannableString getStringWithTargetSpan( + AccessibilityNodeInfoCompat node, Class spanClass) { + + CharSequence text = node.getContentDescription(); + if (isEmptyOrNotSpannableStringType(text)) { + text = AccessibilityNodeInfoUtils.getText(node); + if (isEmptyOrNotSpannableStringType(text)) { + return null; + } + } + + SpannableString spannable = SpannableString.valueOf(text); + T[] spans = spannable.getSpans(0, spannable.length(), spanClass); + if (spans == null || spans.length == 0) { + return null; + } + + return spannable; + } + + /** + * Strip out all the spans of target span class from the given text. + * + * @param text Text to remove span. + * @param spanClass class of span to be removed. + */ + public static void stripTargetSpanFromText(CharSequence text, Class spanClass) { + if (TextUtils.isEmpty(text) || !(text instanceof SpannableString)) { + return; + } + SpannableString spannable = (SpannableString) text; + T[] spans = spannable.getSpans(0, spannable.length(), spanClass); + if (spans != null) { + for (T span : spans) { + if (span != null) { + spannable.removeSpan(span); + } + } + } + } + + /** + * Logs the type, position and args of spans which attach to given text, but only if log priority + * is equal to Log.VERBOSE. Format is {type 'spanned text' extra-data} {type 'other text' + * extra-data} ..." + * + * @param text Text to be logged + */ + public static @Nullable String spansToStringForLogging(CharSequence text) { + if (!LogUtils.shouldLog(Log.VERBOSE)) { + return null; + } + + if (isEmptyOrNotSpannableStringType(text)) { + return null; + } + + Spanned spanned = (Spanned) text; + ParcelableSpan[] spans = spanned.getSpans(0, text.length(), ParcelableSpan.class); + if (spans.length == 0) { + return null; + } + + StringBuilder stringBuilder = new StringBuilder(); + for (ParcelableSpan span : spans) { + stringBuilder.append("{"); + // Span type. + stringBuilder.append(span.getClass().getSimpleName()); + + // Span text. + int start = spanned.getSpanStart(span); + int end = spanned.getSpanEnd(span); + if (start < 0 || end < 0 || start == end) { + stringBuilder.append(" invalid index:["); + stringBuilder.append(start); + stringBuilder.append(","); + stringBuilder.append(end); + stringBuilder.append("]}"); + continue; + } else { + stringBuilder.append(" '"); + stringBuilder.append(spanned, start, end); + stringBuilder.append("'"); + } + + // Extra data. + if (span instanceof LocaleSpan) { + LocaleSpan localeSpan = (LocaleSpan) span; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + Locale locale = localeSpan.getLocale(); + if (locale != null) { + stringBuilder.append(" locale="); + stringBuilder.append(locale); + } + } else { + LocaleList localeList = localeSpan.getLocales(); + int size = localeList.size(); + if (size > 0) { + stringBuilder.append(" locale=["); + for (int i = 0; i < size - 1; i++) { + stringBuilder.append(localeList.get(i)); + stringBuilder.append(","); + } + stringBuilder.append(localeList.get(size - 1)); + stringBuilder.append("]"); + } + } + + } else if (span instanceof TtsSpan) { + TtsSpan ttsSpan = (TtsSpan) span; + stringBuilder.append(" ttsType="); + stringBuilder.append(ttsSpan.getType()); + PersistableBundle bundle = ttsSpan.getArgs(); + Set keys = bundle.keySet(); + if (!keys.isEmpty()) { + for (String key : keys) { + stringBuilder.append(" "); + stringBuilder.append(key); + stringBuilder.append("="); + stringBuilder.append(bundle.get(key)); + } + } + } else if (span instanceof URLSpan) { + URLSpan urlSpan = (URLSpan) span; + stringBuilder.append(" url="); + stringBuilder.append(urlSpan.getURL()); + } + stringBuilder.append("}"); + } + return stringBuilder.toString(); + } + + private static boolean isEmptyOrNotSpannableStringType(CharSequence text) { + return TextUtils.isEmpty(text) + || !(text instanceof SpannedString + || text instanceof SpannableString + || text instanceof SpannableStringBuilder); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/SparseIterableArray.java b/utils/src/main/java/com/google/android/accessibility/utils/SparseIterableArray.java new file mode 100644 index 0000000..79d415d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/SparseIterableArray.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2013 Google Inc. + * + * 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.google.android.accessibility.utils; + +import androidx.collection.SparseArrayCompat; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** Extension of {@link SparseArrayCompat} that implements {@link Iterable}. */ +public class SparseIterableArray extends SparseArrayCompat implements Iterable { + @Override + public Iterator iterator() { + return new SparseIterator(); + } + + private class SparseIterator implements Iterator { + private int mIndex = 0; + + @Override + public boolean hasNext() { + return (mIndex < size()); + } + + @Override + public T next() { + if (mIndex >= size()) { + throw new NoSuchElementException(); + } + + return valueAt(mIndex++); + } + + @Override + public void remove() { + if ((mIndex < 0) || (mIndex >= size())) { + throw new IllegalStateException(); + } + + removeAt(mIndex--); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/StringBuilderUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/StringBuilderUtils.java new file mode 100644 index 0000000..e6e0315 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/StringBuilderUtils.java @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2009 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.google.android.accessibility.utils; + +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import java.util.Arrays; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Frequently used functions for concatenating text. */ +public class StringBuilderUtils { + + private static final String TAG = "StringBuildingUtils"; + + //////////////////////////////////////////////////////////////////////////////////////// + // Constants + + /** + * Breaking separator inserted between text, intended to make TTS pause an appropriate amount. + * Using a period breaks pronunciation of street abbreviations, and using a new line doesn't work + * in eSpeak. + */ + public static final String DEFAULT_BREAKING_SEPARATOR = ", "; + + /** + * Non-breaking separator inserted between text. Used when text already ends with some sort of + * breaking separator or non-alphanumeric character. + */ + public static final String DEFAULT_SEPARATOR = " "; + + /** The hex alphabet. */ + private static final char[] HEX_ALPHABET = "0123456789abcdef".toCharArray(); + + //////////////////////////////////////////////////////////////////////////////////////// + // Methods + + public static String repeatChar(char c, int times) { + char[] chars = new char[times]; + Arrays.fill(chars, c); + return new String(chars); + } + + /** Return labeled field-value, only if field-value is not null. */ + public static String optionalField(String fieldName, @Nullable Object fieldValue) { + return (fieldValue == null) ? "" : String.format("%s=%s", fieldName, fieldValue.toString()); + } + + /** Return labeled delimited field-value, only if field-value is not null. */ + public static String optionalSubObj(String fieldName, @Nullable Object fieldValue) { + return (fieldValue == null) ? "" : String.format("%s= %s", fieldName, fieldValue.toString()); + } + + /** Return labeled quoted field-value, only if field-value is not null. */ + public static String optionalText(String fieldName, @Nullable CharSequence fieldValue) { + return (fieldValue == null) ? "" : String.format("%s=\"%s\"", fieldName, fieldValue); + } + + /** Return labeled field-value, only if field-value is not default. */ + public static String optionalInt(String fieldName, int fieldValue, int defaultValue) { + return (fieldValue == defaultValue) ? "" : String.format("%s=%s", fieldName, fieldValue); + } + + /** Return labeled field-value, only if field-value is not default. */ + public static String optionalInt(String fieldName, long fieldValue, long defaultValue) { + return (fieldValue == defaultValue) ? "" : String.format("%s=%s", fieldName, fieldValue); + } + + /** Return labeled field-value, only if field-value is not default. */ + public static String optionalNum(String fieldName, float fieldValue, float defaultValue) { + return (fieldValue == defaultValue) ? "" : String.format("%s=%s", fieldName, fieldValue); + } + + /** Return field-tag, only if field-value is true. */ + public static String optionalTag(String tagName, boolean tagValue) { + return tagValue ? tagName : ""; + } + + public static String joinFields(String... strings) { + return joinStrings(" ", strings); + } + + public static String joinSubObjects(String... strings) { + return joinStrings("\n ", strings); + } + + private static String joinStrings(String delimiter, String[] strings) { + StringBuilder builder = new StringBuilder(); + for (String s : strings) { + if (!TextUtils.isEmpty(s)) { + builder.append(s); + builder.append(delimiter); + } + } + return builder.toString(); + } + + /** + * Generates the aggregate text from a list of {@link CharSequence}s, separating as necessary. + * + * @param textList The list of text to process. + * @return The separated aggregate text, or null if no text was appended. + */ + public static @Nullable CharSequence getAggregateText(List textList) { + if (textList == null || textList.isEmpty()) { + return null; + } else { + SpannableStringBuilder builder = new SpannableStringBuilder(); + for (CharSequence text : textList) { + appendWithSeparator(builder, text); + } + + return builder; + } + } + + /** + * Appends CharSequence representations of the specified arguments to a {@link + * SpannableStringBuilder}, creating one if the supplied builder is {@code null}. A separator will + * be inserted between each of the arguments. + * + * @param builder An existing {@link SpannableStringBuilder}, or {@code null} to create one. + * @param args The objects to append to the builder. + * @return A builder with the specified objects appended. + */ + public static SpannableStringBuilder appendWithSeparator( + SpannableStringBuilder builder, CharSequence... args) { + if (builder == null) { + builder = new SpannableStringBuilder(); + } + + for (CharSequence arg : args) { + if (arg == null) { + continue; + } + + if (arg.toString().length() == 0) { + continue; + } + + if (builder.length() > 0) { + if (needsBreakingSeparator(builder)) { + builder.append(DEFAULT_BREAKING_SEPARATOR); + } else { + builder.append(DEFAULT_SEPARATOR); + } + } + + builder.append(arg); + } + + return builder; + } + + /** + * Appends CharSequence representations of the specified arguments to a {@link + * SpannableStringBuilder}, creating one if the supplied builder is {@code null}. A separator will + * be inserted before the first non-{@code null} argument, but additional separators will not be + * inserted between the following elements. + * + * @param builder An existing {@link SpannableStringBuilder}, or {@code null} to create one. + * @param args The objects to append to the builder. + * @return A builder with the specified objects appended. + */ + public static SpannableStringBuilder append( + SpannableStringBuilder builder, CharSequence... args) { + if (builder == null) { + builder = new SpannableStringBuilder(); + } + + boolean didAppend = false; + for (CharSequence arg : args) { + if (arg == null) { + continue; + } + + if (arg.toString().length() == 0) { + continue; + } + + if (builder.length() > 0) { + if (!didAppend && needsBreakingSeparator(builder)) { + builder.append(DEFAULT_BREAKING_SEPARATOR); + } else { + builder.append(DEFAULT_SEPARATOR); + } + } + + builder.append(arg); + didAppend = true; + } + + return builder; + } + + /** + * Returns whether the text needs a breaking separator (e.g. a period followed by a space) + * appended before more text is appended. + * + *

If text ends with a letter or digit (according to the current locale) then this method will + * return {@code true}. + */ + private static boolean needsBreakingSeparator(CharSequence text) { + return !TextUtils.isEmpty(text) && Character.isLetterOrDigit(text.charAt(text.length() - 1)); + } + + /** + * Convert a byte array to a hex-encoded string. + * + * @param bytes The byte array of data to convert + * @return The hex encoding of {@code bytes}, or null if {@code bytes} was null + */ + public static @Nullable String bytesToHexString(byte[] bytes) { + if (bytes == null) { + return null; + } + + final StringBuilder hex = new StringBuilder(bytes.length * 2); + int nibble1; + int nibble2; + for (byte b : bytes) { + nibble1 = (b >>> 4) & 0xf; + nibble2 = b & 0xf; + hex.append(HEX_ALPHABET[nibble1]); + hex.append(HEX_ALPHABET[nibble2]); + } + + return hex.toString(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/TimedFlags.java b/utils/src/main/java/com/google/android/accessibility/utils/TimedFlags.java new file mode 100644 index 0000000..da3ebb7 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/TimedFlags.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils; + +import android.os.SystemClock; +import android.util.SparseArray; + +/** Encapsulates a set of flags that persist for a limited time frame. */ +public class TimedFlags { + private static final int FLAG_TIMEOUT = 1000; + + private final SparseArray mFlags = new SparseArray<>(); + + public void setFlag(int flag) { + mFlags.put(flag, SystemClock.uptimeMillis()); + } + + public void clearFlag(int flag) { + mFlags.remove(flag); + } + + public boolean checkAndClearRecentFlag(int flag) { + if (hasFlag(flag, FLAG_TIMEOUT)) { + mFlags.remove(flag); + return true; + } + + return false; + } + + private boolean hasFlag(int flag, long timeout) { + Long lastFlagTime = mFlags.get(flag); + if (lastFlagTime != null) { + return SystemClock.uptimeMillis() - lastFlagTime < timeout; + } + + return false; + } + + public void clearAllFlags() { + mFlags.clear(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/TreeDebug.java b/utils/src/main/java/com/google/android/accessibility/utils/TreeDebug.java new file mode 100644 index 0000000..8c13c51 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/TreeDebug.java @@ -0,0 +1,303 @@ +/* + * Copyright (C) 2014 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.google.android.accessibility.utils; + +import android.accessibilityservice.AccessibilityService; +import android.graphics.Rect; +import android.text.TextUtils; +import android.view.accessibility.AccessibilityWindowInfo; +import androidx.annotation.NonNull; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.traversal.OrderedTraversalStrategy; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.HashSet; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Util class to help debug Node trees. */ +public class TreeDebug { + + public static final String TAG = "TreeDebug"; + + /** Logs the layout hierarchy of node trees for given list of windows. */ + public static void logNodeTrees(List windows) { + if (windows == null || windows.isEmpty()) { + return; + } + int displayId = AccessibilityWindowInfoUtils.getDisplayId(windows.get(0)); + LogUtils.v(TAG, "------------Node tree------------ display %d", displayId); + for (AccessibilityWindowInfo window : windows) { + if (window == null) { + continue; + } + // TODO: Filter and print useful window information. + LogUtils.v(TAG, "Window: %s", window); + AccessibilityNodeInfoCompat root = + AccessibilityNodeInfoUtils.toCompat(AccessibilityWindowInfoUtils.getRoot(window)); + logNodeTree(root); + AccessibilityNodeInfoUtils.recycleNodes(root); + } + } + + /** Logs the layout hierarchy of node tree for using the input node as the root. */ + public static void logNodeTree(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return; + } + + HashSet seen = new HashSet<>(); + logNodeTree(AccessibilityNodeInfoCompat.obtain(node), "", seen); + for (AccessibilityNodeInfoCompat n : seen) { + n.recycle(); + } + } + + private static void logNodeTree( + AccessibilityNodeInfoCompat node, String indent, HashSet seen) { + if (!seen.add(node)) { + LogUtils.v(TAG, "Cycle: %d", node.hashCode()); + return; + } + + // Include the hash code as a "poor man's" id, knowing that it + // might not always be unique. + LogUtils.v(TAG, "%s(%d)%s", indent, node.hashCode(), nodeDebugDescription(node)); + + indent += " "; + int childCount = node.getChildCount(); + for (int i = 0; i < childCount; ++i) { + AccessibilityNodeInfoCompat child = node.getChild(i); + if (child == null) { + LogUtils.v(TAG, "%sCouldn't get child %d", indent, i); + continue; + } + + logNodeTree(child, indent, seen); + } + } + + private static void appendSimpleName(StringBuilder sb, CharSequence fullName) { + int dotIndex = TextUtils.lastIndexOf(fullName, '.'); + if (dotIndex < 0) { + dotIndex = 0; + } + + sb.append(fullName, dotIndex, fullName.length()); + } + + /** Gets a description of the properties of a node. */ + public static CharSequence nodeDebugDescription(AccessibilityNodeInfoCompat node) { + StringBuilder sb = new StringBuilder(); + sb.append(node.getWindowId()); + + if (node.getClassName() != null) { + appendSimpleName(sb, node.getClassName()); + } else { + sb.append("??"); + } + + if (!node.isVisibleToUser()) { + sb.append(":invisible"); + } + + Rect rect = new Rect(); + node.getBoundsInScreen(rect); + sb.append(":"); + sb.append("(") + .append(rect.left) + .append(", ") + .append(rect.top) + .append(" - ") + .append(rect.right) + .append(", ") + .append(rect.bottom) + .append(")"); + + if (!TextUtils.isEmpty(node.getPaneTitle())) { + sb.append(":PANE{"); + sb.append(node.getPaneTitle()); + sb.append("}"); + } + + @Nullable CharSequence nodeText = AccessibilityNodeInfoUtils.getText(node); + if (nodeText != null) { + sb.append(":TEXT{"); + sb.append(nodeText.toString().trim()); + sb.append("}"); + } + + if (node.getContentDescription() != null) { + sb.append(":CONTENT{"); + sb.append(node.getContentDescription().toString().trim()); + sb.append("}"); + } + + if (AccessibilityNodeInfoUtils.getState(node) != null) { + sb.append(":STATE{"); + sb.append(AccessibilityNodeInfoUtils.getState(node).toString().trim()); + sb.append("}"); + } + // Views that inherit Checkable can have its own state description and the log already covered + // by above SD, but for some views that are not Checkable but have checked status, like + // overriding by AccessibilityDelegate, we should also log it. + if (node.isCheckable()) { + sb.append(":"); + if (node.isChecked()) { + sb.append("checked"); + } else { + sb.append("not checked"); + } + } + + int actions = node.getActions(); + if (actions != 0) { + sb.append("(action:"); + if ((actions & AccessibilityNodeInfoCompat.ACTION_FOCUS) != 0) { + sb.append("FOCUS/"); + } + if ((actions & AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS) != 0) { + sb.append("A11Y_FOCUS/"); + } + if ((actions & AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS) != 0) { + sb.append("CLEAR_A11Y_FOCUS/"); + } + if ((actions & AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD) != 0) { + sb.append("SCROLL_BACKWARD/"); + } + if ((actions & AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD) != 0) { + sb.append("SCROLL_FORWARD/"); + } + if ((actions & AccessibilityNodeInfoCompat.ACTION_CLICK) != 0) { + sb.append("CLICK/"); + } + if ((actions & AccessibilityNodeInfoCompat.ACTION_LONG_CLICK) != 0) { + sb.append("LONG_CLICK/"); + } + if ((actions & AccessibilityNodeInfoCompat.ACTION_EXPAND) != 0) { + sb.append("EXPAND/"); + } + if ((actions & AccessibilityNodeInfoCompat.ACTION_COLLAPSE) != 0) { + sb.append("COLLAPSE/"); + } + sb.setLength(sb.length() - 1); + sb.append(")"); + } + + if (node.isFocusable()) { + sb.append(":focusable"); + } + if (node.isScreenReaderFocusable()) { + sb.append(":screenReaderfocusable"); + } + + if (node.isFocused()) { + sb.append(":focused"); + } + + if (node.isSelected()) { + sb.append(":selected"); + } + + if (node.isScrollable()) { + sb.append(":scrollable"); + } + + if (node.isClickable()) { + sb.append(":clickable"); + } + + if (node.isLongClickable()) { + sb.append(":longClickable"); + } + + if (node.isAccessibilityFocused()) { + sb.append(":accessibilityFocused"); + } + if (AccessibilityNodeInfoUtils.supportsTextLocation(node)) { + sb.append(":supportsTextLocation"); + } + if (!node.isEnabled()) { + sb.append(":disabled"); + } + + if (node.getCollectionInfo() != null) { + sb.append(":collection"); + sb.append("#R"); + sb.append(node.getCollectionInfo().getRowCount()); + sb.append("C"); + sb.append(node.getCollectionInfo().getColumnCount()); + } + + if (AccessibilityNodeInfoUtils.isHeading(node)) { + sb.append(":heading"); + } else if (node.getCollectionItemInfo() != null) { + sb.append(":item"); + } + if (node.getCollectionItemInfo() != null) { + sb.append("#r"); + sb.append(node.getCollectionItemInfo().getRowIndex()); + sb.append("c"); + sb.append(node.getCollectionItemInfo().getColumnIndex()); + } + + return sb.toString(); + } + + /** Logs the traversal order of node trees for given list of windows. */ + public static void logOrderedTraversalTree(List windows) { + if (windows == null || windows.isEmpty()) { + return; + } + int displayId = AccessibilityWindowInfoUtils.getDisplayId(windows.get(0)); + LogUtils.v(TAG, "------------Node tree traversal order---------- display %d", displayId); + for (AccessibilityWindowInfo window : windows) { + if (window == null) { + continue; + } + LogUtils.v(TreeDebug.TAG, "Window: %s", window); + AccessibilityNodeInfoCompat root = + AccessibilityNodeInfoUtils.toCompat(AccessibilityWindowInfoUtils.getRoot(window)); + logOrderedTraversalTree(root); + AccessibilityNodeInfoUtils.recycleNodes(root); + } + } + + /** Logs the traversal order of node tree for using the input node as the root. */ + private static void logOrderedTraversalTree(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return; + } + OrderedTraversalStrategy orderTraversalStrategy = new OrderedTraversalStrategy(node); + orderTraversalStrategy.dumpTree(); + orderTraversalStrategy.recycle(); + } + + /** + * Logs the layout hierarchy of node trees and the traversal order of node tree of all the + * displays. + * + * @param service The parent service + */ + public static void logNodeTreesOnAllDisplays(@NonNull AccessibilityService service) { + AccessibilityServiceCompatUtils.forEachWindowInfoListOnAllDisplays( + service, + windowInfoList -> { + TreeDebug.logNodeTrees(windowInfoList); + TreeDebug.logOrderedTraversalTree(windowInfoList); + }); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/WeakReferenceHandler.java b/utils/src/main/java/com/google/android/accessibility/utils/WeakReferenceHandler.java new file mode 100644 index 0000000..00f7a03 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/WeakReferenceHandler.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import java.lang.ref.WeakReference; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Convenience class for making a static inner {@link Handler} class that keeps a {@link + * WeakReference} to its parent class. If the reference is cleared, the {@link WeakReferenceHandler} + * will stop handling {@link Message}s. + * + *

Example usage: + * + *

+ * private final MyHandler mHandler = new MyHandler(this);
+ *
+ * private static class MyHandler extends WeakReferenceHandler {
+ *     protected void handleMessage(Message msg, MyClass parent) {
+ *         parent.onMessageReceived(msg.what, msg.arg1);
+ *     }
+ * }
+ * 
+ * + * @param The handler's parent class. + */ +public abstract class WeakReferenceHandler extends Handler { + private final WeakReference mParentRef; + + /** + * Constructs a new {@link WeakReferenceHandler} with a reference to its parent class. + * + * @param parent The handler's parent class. + */ + public WeakReferenceHandler(T parent) { + mParentRef = new WeakReference<>(parent); + } + + /** + * Constructs a new {@link WeakReferenceHandler} with a reference to its parent class. + * + * @param parent The handler's parent class. + * @param looper The looper. + */ + public WeakReferenceHandler(T parent, Looper looper) { + super(looper); + mParentRef = new WeakReference<>(parent); + } + + @Override + public final void handleMessage(Message msg) { + final T parent = getParent(); + + if (parent == null) { + return; + } + + handleMessage(msg, parent); + } + + /** @return The parent class, or {@code null} if the reference has been cleared. */ + protected @Nullable T getParent() { + return mParentRef.get(); + } + + /** Subclasses must implement this to receive messages. */ + protected abstract void handleMessage(Message msg, T parent); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/WebActivity.java b/utils/src/main/java/com/google/android/accessibility/utils/WebActivity.java new file mode 100644 index 0000000..4a0693a --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/WebActivity.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import org.checkerframework.checker.nullness.qual.Nullable; + +public class WebActivity extends Activity { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Intent intent = getIntent(); + if (intent == null) { + finish(); + return; + } + + Uri uri = intent.getData(); + if (uri == null) { + finish(); + return; + } + + setContentView(R.layout.web_activity); + + WebView webView = (WebView) findViewById(R.id.web); + webView.setWebViewClient(new AllowlistWebViewClient()); + webView.loadUrl(uri.toString()); + } + + private static class AllowlistWebViewClient extends WebViewClient { + @Override + public @Nullable WebResourceResponse shouldInterceptRequest( + WebView view, WebResourceRequest request) { + final String host = request.getUrl().getHost(); + // Allow URLs from Google for the TOS and Privacy Policy. + if ((host != null) + && (host.matches("[a-z]*.google.com") + || host.matches("[a-z]*.google.[a-z][a-z]") + || host.matches("[a-z]*.google.co.[a-z][a-z]") + || host.matches("[a-z]*.google.com.[a-z][a-z]") + || host.matches("[a-z]*.gstatic.com") + || host.equals("fonts.googleapis.com"))) { + return super.shouldInterceptRequest(view, request); + } + return new WebResourceResponse("", "", 403, "Denied", null, null); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/WebContentHandler.java b/utils/src/main/java/com/google/android/accessibility/utils/WebContentHandler.java new file mode 100644 index 0000000..f6743dd --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/WebContentHandler.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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.google.android.accessibility.utils; + +import java.util.Map; +import java.util.Stack; +import org.xml.sax.Attributes; +import org.xml.sax.helpers.DefaultHandler; + +/** A handler for parsing simple HTML from Android WebView. */ +public class WebContentHandler extends DefaultHandler { + /** Maps input type attribute to element description. */ + private final Map mInputTypeToDesc; + + /** Maps ARIA role attribute to element description. */ + private final Map mAriaRoleToDesc; + + /** Map tags to element description. */ + private final Map mTagToDesc; + + /** A stack for storing post-order text generated by opening tags. */ + private Stack mPostorderTextStack; + + /** Builder for a string to be spoken based on parsed HTML. */ + private StringBuilder mOutputBuilder; + + /** + * Initializes the handler with maps that provide descriptions for relevant features in HTML. + * + * @param htmlInputMap A mapping from input types to text descriptions. + * @param htmlRoleMap A mapping from ARIA roles to text descriptions. + * @param htmlTagMap A mapping from common tags to text descriptions. + */ + public WebContentHandler( + Map htmlInputMap, + Map htmlRoleMap, + Map htmlTagMap) { + mInputTypeToDesc = htmlInputMap; + mAriaRoleToDesc = htmlRoleMap; + mTagToDesc = htmlTagMap; + } + + @Override + public void startDocument() { + mOutputBuilder = new StringBuilder(); + mPostorderTextStack = new Stack<>(); + } + + /** + * Depending on the type of element, generate text describing its conceptual value and role and + * add it to the output. The role text is spoken after any content, so it is added to the stack to + * wait for the closing tag. + */ + @Override + public void startElement(String uri, String localName, String name, Attributes attributes) { + fixWhiteSpace(); + final String ariaLabel = attributes.getValue("aria-label"); + final String alt = attributes.getValue("alt"); + final String title = attributes.getValue("title"); + + if (ariaLabel != null) { + mOutputBuilder.append(ariaLabel); + } else if (alt != null) { + mOutputBuilder.append(alt); + } else if (title != null) { + mOutputBuilder.append(title); + } + + /* + * Add role text to the stack so it appears after the content. If there + * is no text we still need to push a blank string, since this will pop + * when this element ends. + */ + final String role = attributes.getValue("role"); + final String roleName = mAriaRoleToDesc.get(role); + final String type = attributes.getValue("type"); + final String tagInfo = mTagToDesc.get(name.toLowerCase()); + + if (roleName != null) { + mPostorderTextStack.push(roleName); + } else if (name.equalsIgnoreCase("input") && (type != null)) { + final String typeInfo = mInputTypeToDesc.get(type.toLowerCase()); + + if (typeInfo != null) { + mPostorderTextStack.push(typeInfo); + } else { + mPostorderTextStack.push(""); + } + } else if (tagInfo != null) { + mPostorderTextStack.push(tagInfo); + } else { + mPostorderTextStack.push(""); + } + + /* + * The value should be spoken as long as the element is not a form + * element with a non-human-readable value. + */ + final String value = attributes.getValue("value"); + + if (value != null) { + String elementType = name; + + if (name.equalsIgnoreCase("input") && (type != null)) { + elementType = type; + } + + if (!elementType.equalsIgnoreCase("checkbox") && !elementType.equalsIgnoreCase("radio")) { + fixWhiteSpace(); + mOutputBuilder.append(value); + } + } + } + + /** Character data is passed directly to output. */ + @Override + public void characters(char[] ch, int start, int length) { + mOutputBuilder.append(ch, start, length); + } + + /** + * After the end of an element, get the post-order text from the stack and add it to the output. + */ + @Override + public void endElement(String uri, String localName, String name) { + final String postorderText = mPostorderTextStack.pop(); + + if (postorderText.length() > 0) { + fixWhiteSpace(); + } + + mOutputBuilder.append(postorderText); + } + + /** Ensure the output string has a character of whitespace before adding another word. */ + void fixWhiteSpace() { + final int index = mOutputBuilder.length() - 1; + + if (index >= 0) { + final char lastCharacter = mOutputBuilder.charAt(index); + + if (!Character.isWhitespace(lastCharacter)) { + mOutputBuilder.append(" "); + } + } + } + + /** + * Get the processed string in mBuilder. Call this after parsing is done to get the finished + * output. + * + * @return A string with HTML tags converted to descriptions suitable for speaking. + */ + public String getOutput() { + return mOutputBuilder.toString(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/WebInterfaceUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/WebInterfaceUtils.java new file mode 100644 index 0000000..e9dd3f1 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/WebInterfaceUtils.java @@ -0,0 +1,431 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * 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.google.android.accessibility.utils; + +import android.content.Context; +import android.os.Bundle; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.traversal.TraversalStrategy; +import com.google.android.accessibility.utils.traversal.TraversalStrategy.SearchDirectionOrUnknown; +import com.google.android.accessibility.utils.traversal.TraversalStrategyUtils; +import java.util.ArrayList; +import java.util.Collections; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Utility class for sending commands to ChromeVox. */ +public class WebInterfaceUtils { + + private static final String KEY_WEB_IMAGE = "AccessibilityNodeInfo.hasImage"; + private static final String VALUE_HAS_WEB_IMAGE = "true"; + + private static final String ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES = + "ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES"; + + /** Direction constant for forward movement within a page. */ + public static final int DIRECTION_FORWARD = 1; + + /** Direction constant for backward movement within a page. */ + public static final int DIRECTION_BACKWARD = -1; + + /** + * Action argument to use with {@link #performSpecialAction(AccessibilityNodeInfoCompat, int, int, + * EventId)} to instruct ChromeVox to move into or out of the special content navigation mode. + * + *

Using this constant also requires specifying a direction. {@link #DIRECTION_FORWARD} + * indicates ChromeVox should move into this content navigation mode, {@link #DIRECTION_BACKWARD} + * indicates ChromeVox should move out of this mode. + */ + public static final int ACTION_TOGGLE_SPECIAL_CONTENT = -4; + + /** + * Action argument to use with {@link #performSpecialAction(AccessibilityNodeInfoCompat, int, int, + * EventId)} to instruct ChromeVox to move into or out of the incremental search mode. + * + *

Using this constant does not require a direction as it only toggles the state. + */ + public static final int ACTION_TOGGLE_INCREMENTAL_SEARCH = -5; + + /** + * HTML element argument to use with {@link + * #performNavigationToHtmlElementAction(AccessibilityNodeInfoCompat, int, String, EventId)} to + * instruct ChromeVox to move to the next or previous page section. + */ + public static final String HTML_ELEMENT_MOVE_BY_SECTION = "SECTION"; + + /** + * HTML element argument to use with {@link + * #performNavigationToHtmlElementAction(AccessibilityNodeInfoCompat, int, String, EventId)} to + * instruct ChromeVox to move to the next or previous page heading. + */ + public static final String HTML_ELEMENT_MOVE_BY_HEADING = "HEADING"; + + /** + * HTML element argument to use with {@link + * #performNavigationToHtmlElementAction(AccessibilityNodeInfoCompat, int, String, EventId)} to + * instruct ChromeVox to move to the next or previous page section. + */ + public static final String HTML_ELEMENT_MOVE_BY_LANDMARK = "LANDMARK"; + + /** + * HTML element argument to use with {@link + * #performNavigationToHtmlElementAction(AccessibilityNodeInfoCompat, int, String, EventId)} to + * instruct ChromeVox to move to the next or previous link. + */ + public static final String HTML_ELEMENT_MOVE_BY_LINK = "LINK"; + + /** + * HTML element argument to use with {@link + * #performNavigationToHtmlElementAction(AccessibilityNodeInfoCompat, int, String, EventId)} to + * instruct ChromeVox to move to the next or previous list. + */ + public static final String HTML_ELEMENT_MOVE_BY_LIST = "LIST"; + + /** + * HTML element argument to use with {@link + * #performNavigationToHtmlElementAction(AccessibilityNodeInfoCompat, int, String, EventId)} to + * instruct ChromeVox to move to the next or previous control. + */ + public static final String HTML_ELEMENT_MOVE_BY_CONTROL = "CONTROL"; + + /** + * Filter for WebView container node. See {@link + * #ascendToWebViewContainer(AccessibilityNodeInfoCompat)}. + */ + private static final Filter FILTER_WEB_VIEW_CONTAINER = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + AccessibilityNodeInfoCompat parent = node.getParent(); + try { + return Role.getRole(node) == Role.ROLE_WEB_VIEW + && Role.getRole(parent) != Role.ROLE_WEB_VIEW; + } finally { + AccessibilityNodeInfoUtils.recycleNodes(parent); + } + } + }; + + /** Filter for WebView node. See {@link #ascendToWebView(AccessibilityNodeInfoCompat)}. */ + private static final Filter FILTER_WEB_VIEW = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return node != null && Role.getRole(node) == Role.ROLE_WEB_VIEW; + } + }; + + public static int searchDirectionToWebNavigationDirection( + Context context, @SearchDirectionOrUnknown int searchDirection) { + if (searchDirection == TraversalStrategy.SEARCH_FOCUS_UNKNOWN) { + return 0; + } + @SearchDirectionOrUnknown + int logicalDirection = + TraversalStrategyUtils.getLogicalDirection( + searchDirection, WindowUtils.isScreenLayoutRTL(context)); + return logicalDirection == TraversalStrategy.SEARCH_FOCUS_FORWARD + ? WebInterfaceUtils.DIRECTION_FORWARD + : WebInterfaceUtils.DIRECTION_BACKWARD; + } + + /** + * Sends an instruction to ChromeVox to read the specified HTML element in the given direction + * within a node. + * + *

WARNING: Calling this method with a source node of {@link android.webkit.WebView} has the + * side effect of closing the IME if currently displayed. + * + * @param node The node containing web content with ChromeVox to which the message should be sent + * @param direction {@link #DIRECTION_FORWARD} or {@link #DIRECTION_BACKWARD} + * @param htmlElement The HTML tag to send + * @return {@code true} if the action was performed, {@code false} otherwise. + */ + public static boolean performNavigationToHtmlElementAction( + AccessibilityNodeInfoCompat node, int direction, String htmlElement, EventId eventId) { + final int action = + (direction == DIRECTION_FORWARD) + ? AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT + : AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT; + final Bundle args = new Bundle(); + args.putString(AccessibilityNodeInfoCompat.ACTION_ARGUMENT_HTML_ELEMENT_STRING, htmlElement); + return PerformActionUtils.performAction(node, action, args, eventId); + } + + /** + * Sends an instruction to ChromeVox to navigate by DOM object in the given direction within a + * node. + * + * @param node The node containing web content with ChromeVox to which the message should be sent + * @param direction {@link #DIRECTION_FORWARD} or {@link #DIRECTION_BACKWARD} + * @return {@code true} if the action was performed, {@code false} otherwise. + */ + public static boolean performNavigationByDOMObject( + AccessibilityNodeInfoCompat node, int direction) { + final int action = + (direction == DIRECTION_FORWARD) + ? AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT + : AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT; + return node.performAction(action); + } + + /** + * Gets supported html elements, such as HEADING, LANDMARK, LINK and LIST, by + * AccessibilityNodeInfoCompat. Caller retains ownership of node, caller must recycle this node. + * + * @param node The node containing supported html elements + * @return supported html elements + */ + public static String @Nullable [] getSupportedHtmlElements(AccessibilityNodeInfoCompat node) { + SupportedHtmlNodeCollector supportedHtmlNodeCollector = new SupportedHtmlNodeCollector(); + AccessibilityNodeInfoUtils.isOrHasMatchingAncestor(node, supportedHtmlNodeCollector); + if ((supportedHtmlNodeCollector.getSupportedTypes() == null) + || supportedHtmlNodeCollector.getSupportedTypes().isEmpty()) { + return null; + } + return supportedHtmlNodeCollector.getSupportedTypes().toArray(new String[] {}); + } + + private static class SupportedHtmlNodeCollector extends Filter { + private final ArrayList supportedTypes = new ArrayList<>(); + + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + Bundle bundle = node.getExtras(); + CharSequence supportedHtmlElements = + bundle.getCharSequence(ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES); + + if (supportedHtmlElements != null) { + Collections.addAll(supportedTypes, supportedHtmlElements.toString().split(",")); + return true; + } + return false; + } + + public ArrayList getSupportedTypes() { + return supportedTypes; + } + } + + /** + * Sends an instruction to ChromeVox to move within a page at a specified granularity in a given + * direction. + * + *

WARNING: Calling this method with a source node of {@link android.webkit.WebView} has the + * side effect of closing the IME if currently displayed. + * + * @param node The node containing web content with ChromeVox to which the message should be sent + * @param direction {@link #DIRECTION_FORWARD} or {@link #DIRECTION_BACKWARD} + * @param granularity The granularity with which to move or a special case argument. + * @return {@code true} if the action was performed, {@code false} otherwise. + */ + public static boolean performNavigationAtGranularityAction( + AccessibilityNodeInfoCompat node, int direction, int granularity, EventId eventId) { + final int action = + (direction == DIRECTION_FORWARD) + ? AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY + : AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY; + final Bundle args = new Bundle(); + args.putInt(AccessibilityNodeInfoCompat.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, granularity); + return PerformActionUtils.performAction(node, action, args, eventId); + } + + /** + * Sends instruction to ChromeVox to perform one of the special actions defined by the ACTION + * constants in this class. + * + *

WARNING: Calling this method with a source node of {@link android.webkit.WebView} has the + * side effect of closing the IME if currently displayed. + * + * @param node The node containing web content with ChromeVox to which the message should be sent + * @param action The ACTION constant in this class match the special action that ChromeVox should + * perform. + * @return {@code true} if the action was performed, {@code false} otherwise. + */ + public static boolean performSpecialAction(AccessibilityNodeInfoCompat node, int action) { + return performSpecialAction(node, action, DIRECTION_FORWARD, null); + } + + /** + * Sends instruction to ChromeVox to perform one of the special actions defined by the ACTION + * constants in this class. + * + *

WARNING: Calling this method with a source node of {@link android.webkit.WebView} has the + * side effect of closing the IME if currently displayed. + * + * @param node The node containing web content with ChromeVox to which the message should be sent + * @param action The ACTION constant in this class match the special action that ChromeVox should + * perform. + * @param direction The DIRECTION constant in this class to add as an extra argument to the + * special action. + * @return {@code true} if the action was performed, {@code false} otherwise. + */ + public static boolean performSpecialAction( + AccessibilityNodeInfoCompat node, int action, int direction, EventId eventId) { + /* + * We use performNavigationAtGranularity to communicate with ChromeVox + * for these actions because it is side-effect-free. If we use + * performNavigationToHtmlElementAction and ChromeVox isn't injected, + * we'll actually move selection within the fallback implementation. We + * use the granularity field to hold a value that ChromeVox interprets + * as a special command. + */ + return performNavigationAtGranularityAction( + node, direction, action /* fake granularity */, eventId); + } + + /** + * Sends a message to ChromeVox indicating that it should enter or exit special content + * navigation. This is applicable for things like tables and math expressions. + * + *

NOTE: further navigation should occur at the default movement granularity. + * + * @param node The node representing the web content + * @param enabled Whether this mode should be entered or exited + * @return {@code true} if the action was performed, {@code false} otherwise. + */ + public static boolean setSpecialContentModeEnabled( + AccessibilityNodeInfoCompat node, boolean enabled, EventId eventId) { + final int direction = (enabled) ? DIRECTION_FORWARD : DIRECTION_BACKWARD; + return performSpecialAction(node, ACTION_TOGGLE_SPECIAL_CONTENT, direction, eventId); + } + + /** + * Returns the WebView container node if the {@code node} is a web element. Note: + * A web content node tree is always constructed with a WebView root node, a second level WebView + * node, and all other nodes attached beneath the second level WebView node. When referring to the + * WebView container, we prefer the root node instead of the second level node, because attributes + * like isVisibleToUser() sometimes are not correctly exposed at second level WebView node. + */ + public static @Nullable AccessibilityNodeInfoCompat ascendToWebViewContainer( + AccessibilityNodeInfoCompat node) { + if (!WebInterfaceUtils.supportsWebActions(node)) { + return null; + } + return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(node, FILTER_WEB_VIEW_CONTAINER); + } + + /** Returns the closest ancestor(inclusive) WebView node if the {@code node} is a web element. */ + public static @Nullable AccessibilityNodeInfoCompat ascendToWebView( + AccessibilityNodeInfoCompat node) { + if (!WebInterfaceUtils.supportsWebActions(node)) { + return null; + } + return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(node, FILTER_WEB_VIEW); + } + + /** + * Determines whether or not the given node contains web content. + * + * @param node The node to evaluate + * @return {@code true} if the node contains web content, {@code false} otherwise + */ + public static boolean supportsWebActions(AccessibilityNodeInfoCompat node) { + return AccessibilityNodeInfoUtils.supportsAnyAction( + node, + AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT, + AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT); + } + + /** + * Determines whether or not the given node contains native web content (and not ChromeVox). + * + * @param node The node to evaluate + * @return {@code true} if the node contains native web content, {@code false} otherwise + */ + public static boolean hasNativeWebContent(AccessibilityNodeInfoCompat node) { + return supportsWebActions(node); + } + + /** + * Determines whether or not the given node contains ChromeVox content. + * + * @param node The node to evaluate + * @return {@code true} if the node contains ChromeVox content, {@code false} otherwise + */ + public static boolean hasLegacyWebContent(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + + if (!supportsWebActions(node)) { + return false; + } + + // ChromeVox does not have sub elements, so if the parent element also has web content + // this cannot be ChromeVox. + AccessibilityNodeInfoCompat parent = node.getParent(); + if (supportsWebActions(parent)) { + if (parent != null) { + parent.recycle(); + } + + return false; + } + + if (parent != null) { + parent.recycle(); + } + + // ChromeVox never has child elements + return node.getChildCount() == 0; + } + + /** + * Returns whether the given node has navigable web content, either legacy (ChromeVox) or native + * web content. + * + * @param node The node to check for web content. + * @return Whether the given node has navigable web content. + */ + public static boolean hasNavigableWebContent(AccessibilityNodeInfoCompat node) { + return supportsWebActions(node); + } + + /** Check if node is web container */ + public static boolean isWebContainer(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + return hasNativeWebContent(node) || isNodeFromFirefox(node); + } + + /** Returns {@code true} if the {@code node} or its descendant contains image. */ + public static boolean containsImage(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + Bundle extras = node.getExtras(); + return (extras != null) && VALUE_HAS_WEB_IMAGE.equals(extras.getString(KEY_WEB_IMAGE)); + } + + private static boolean isNodeFromFirefox(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + + final String packageName = + node.getPackageName() != null ? node.getPackageName().toString() : ""; + return packageName.startsWith("org.mozilla."); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/WindowUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/WindowUtils.java new file mode 100644 index 0000000..e3a5318 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/WindowUtils.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils; + +import android.accessibilityservice.AccessibilityService; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Insets; +import android.graphics.Rect; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.view.Display; +import android.view.Surface; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.view.WindowMetrics; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityWindowInfo; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** Utility functions for system-UI windows. */ +public class WindowUtils { + + private WindowUtils() {} + + /** Return whether the current screen layout is RTL. */ + public static boolean isScreenLayoutRTL(Context context) { + Configuration config = context.getResources().getConfiguration(); + if (config == null) { + return false; + } + return (config.screenLayout & Configuration.SCREENLAYOUT_LAYOUTDIR_MASK) + == Configuration.SCREENLAYOUT_LAYOUTDIR_RTL; + } + + /** + * Uses window's bounds to guess StatusBar on top. + * + * @param context context + * @param window the target window to check + * @return {@code true} if the window is StatusBar on top + */ + public static boolean isStatusBar(Context context, AccessibilityWindowInfo window) { + if (context == null || window == null) { + return false; + } + + android.view.WindowManager windowManager = + (android.view.WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + @Nullable Display display = (windowManager == null) ? null : windowManager.getDefaultDisplay(); + if (display == null) { + return false; + } + + DisplayMetrics metrics = new DisplayMetrics(); + display.getRealMetrics(metrics); + Rect rect = new Rect(); + window.getBoundsInScreen(rect); + return isRoughEqual(rect.top, 0) + && isRoughEqual(rect.left, 0) + && isRoughEqual(rect.right, metrics.widthPixels) + && rect.bottom < (metrics.heightPixels / 5); + } + + /** + * Uses window's bounds to guess NavigationBar on bottom. + * + * @param context context + * @param window the target window to check + * @return {@code true} if the window is NavigationBar on bottom + */ + public static boolean isNavigationBar(Context context, AccessibilityWindowInfo window) { + if (context == null || window == null) { + return false; + } + + WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + @Nullable Display display = (windowManager == null) ? null : windowManager.getDefaultDisplay(); + if (display == null) { + return false; + } + + DisplayMetrics metrics = new DisplayMetrics(); + display.getRealMetrics(metrics); + Rect rect = new Rect(); + window.getBoundsInScreen(rect); + switch (display.getRotation()) { + // NavigationBar may located in the left side or right side in the landscape mode. + case (Surface.ROTATION_90): + case (Surface.ROTATION_270): + if (isRoughEqual(rect.top, 0) + && rect.left > ((metrics.heightPixels / 4) * 3) + && isRoughEqual(rect.right, metrics.widthPixels) + && isRoughEqual(rect.bottom, metrics.heightPixels)) { + return true; + } else if (isRoughEqual(rect.top, 0) + && isRoughEqual(rect.left, 0) + && rect.right < (metrics.widthPixels / 4) + && isRoughEqual(rect.bottom, metrics.heightPixels)) { + return true; + } + break; + default: + if (rect.top > ((metrics.heightPixels / 4) * 3) + && isRoughEqual(rect.left, 0) + && isRoughEqual(rect.right, metrics.widthPixels) + && isRoughEqual(rect.bottom, metrics.heightPixels)) { + return true; + } + break; + } + return false; + } + + /** + * Gets the global window insets from the window metrics. + * + * @param windowMetrics Metrics about a Window + * @return windowInsets + */ + @NonNull + public static Insets getWindowInsets(WindowMetrics windowMetrics) { + if (FeatureSupport.supportReportingInsetsByZOrder()) { + return windowMetrics + .getWindowInsets() + .getInsetsIgnoringVisibility( + WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout()); + } + return Insets.NONE; + } + + /** + * Returns true if {@code resId} is the resource ID of the node on the currently active window. + */ + public static boolean rootChildMatchesResId(AccessibilityService service, int resId) { + AccessibilityNodeInfo root = service.getRootInActiveWindow(); + try { + return (root != null) + && WindowUtils.isChildNodeResId( + service, AccessibilityNodeInfoUtils.getWindow(root), resId); + } finally { + AccessibilityNodeInfoUtils.recycleNodes(root); + } + } + + /** Return true if {@code resId} is the resource ID of child node. */ + private static boolean isChildNodeResId( + Context context, @Nullable AccessibilityWindowInfo window, int resId) { + if (window == null) { + return false; + } + + AccessibilityNodeInfo root = window.getRoot(); + if (root == null) { + return false; + } + + AccessibilityNodeInfo node = null; + try { + for (int i = 0; i < root.getChildCount(); i++) { + node = root.getChild(i); + if (node == null) { + continue; + } + boolean result = + TextUtils.equals( + node.getViewIdResourceName(), context.getResources().getResourceName(resId)); + AccessibilityNodeInfoUtils.recycleNodes(node); + node = null; + if (result) { + return true; + } + } + return false; + } finally { + AccessibilityNodeInfoUtils.recycleNodes(node, root); + } + } + + /** Return {@code true} if {@code intA} is equal to {@code intB} roughly. */ + private static boolean isRoughEqual(int intA, int intB) { + final int distance = 5; + return (intA < (intB + distance)) && (intA > (intB - distance)); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/accessibilitybutton/AccessibilityButtonMonitor.java b/utils/src/main/java/com/google/android/accessibility/utils/accessibilitybutton/AccessibilityButtonMonitor.java new file mode 100644 index 0000000..8750591 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/accessibilitybutton/AccessibilityButtonMonitor.java @@ -0,0 +1,354 @@ +package com.google.android.accessibility.utils.accessibilitybutton; + +import android.accessibilityservice.AccessibilityButtonController; +import android.accessibilityservice.AccessibilityService; +import android.accessibilityservice.AccessibilityServiceInfo; +import android.annotation.TargetApi; +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.os.Message; +import android.view.Display; +import android.view.accessibility.AccessibilityManager; +import androidx.annotation.NonNull; +import com.google.android.accessibility.utils.AccessibilityServiceCompatUtils; +import com.google.android.accessibility.utils.BuildVersionUtils; +import com.google.android.accessibility.utils.FeatureSupport; +import com.google.android.accessibility.utils.WeakReferenceHandler; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Monitors whether accessibility button is supported on devices and notifies accessibility button + * click event on the display. + * + *

{@link AccessibilityButtonController} provides API to listen to availability of the + * accessibility button. The availability changes during runtime when the device goes into/out of + * fullscreen mode. SelectToSpeak service needs an API to check whether the accessibility button is + * supported on device, regardless of the "fullscreen mode" scenario. This class is a work around + * for the problem, it wraps {@link AccessibilityButtonController.AccessibilityButtonCallback} and + * exposes another callback to notify button click actions and the detect the supportability of a11y + * button. + * + *

If the build supports a11y multi-display, {@link AccessibilityButtonController} should handle + * the a11y button callback registration and callback unregistration for multi-display. + */ +public class AccessibilityButtonMonitor { + + private static final String TAG = "A11yMenuButtonMonitor"; + + /** Callbacks for click action and confirmation of supportability for the a11y button. */ + public interface AccessibilityButtonMonitorCallback { + + /** Called when the a11y button is clicked. */ + void onAccessibilityButtonClicked(); + + /** + * Called when we can confirm the a11y button is supported or not supported on device. + * Note: This callback method will only be called once. + */ + void onConfirmSupportability(boolean isSupported); + } + + // The state when we cannot confirm whether the button is supported or not. + public static final int PENDING = 0; + // The state when we can confirm that the button is not supported on device. + public static final int NOT_SUPPORTED = 1; + // The state when we can confirm that the button is supported on device. + public static final int SUPPORTED = 2; + + /** Defines whether a11y button is supported on the device. */ + @Retention(RetentionPolicy.SOURCE) + public @interface ButtonSupportability {} + + // Time out to post delayed confirmation of a11y button supportability. + private static final long TIMEOUT = 1000; + + private final AccessibilityService mService; + private final AccessibilityButtonCallBackHandler mHandler; + + // Callback used to notify AccessibilityService of button availability and click action. + private AccessibilityButtonMonitorCallback mCallback; + + // Callback to be registered in AccessibilityButtonController. + private AccessibilityButtonController.AccessibilityButtonCallback accessibilityButtonCallback; + private final DisplayManager displayManager; + // Listener that monitors the display change to support a11y button in multi-display. + // AccessibilityButtonMonitor has to register or unregister the a11y button controller callback + // for each display when the specified display is just added or removed. + private final DisplayManager.DisplayListener displayListener = + new DisplayManager.DisplayListener() { + @Override + public void onDisplayAdded(int displayId) { + if (FeatureSupport.supportAccessibilityMultiDisplay() + && accessibilityButtonCallback != null) { + mService + .getAccessibilityButtonController(displayId) + .registerAccessibilityButtonCallback(accessibilityButtonCallback); + } + } + + @Override + public void onDisplayChanged(int displayId) {} + + @Override + public void onDisplayRemoved(int displayId) { + if (FeatureSupport.supportAccessibilityMultiDisplay() + && accessibilityButtonCallback != null) { + mService + .getAccessibilityButtonController(displayId) + .unregisterAccessibilityButtonCallback(accessibilityButtonCallback); + } + } + }; + + @ButtonSupportability private int mButtonState = PENDING; + + public AccessibilityButtonMonitor(@NonNull AccessibilityService service) { + mHandler = new AccessibilityButtonCallBackHandler(this); + mService = service; + displayManager = (DisplayManager) mService.getSystemService(Context.DISPLAY_SERVICE); + } + + @TargetApi(Build.VERSION_CODES.O) + public void initAccessibilityButton(@NonNull AccessibilityButtonMonitorCallback callback) { + mCallback = callback; + if (!FeatureSupport.supportAccessibilityButton()) { + LogUtils.d(TAG, "Accessibility button is not supported for pre-O devices."); + // A11y button is not supported on pre-O devices. + mHandler.confirmAccessibilityButtonSupportability(false); + return; + } + + // Ensure the flag is added to AccessibilityServiceInfo. + AccessibilityServiceInfo info = mService.getServiceInfo(); + if (info != null) { + info.flags |= AccessibilityServiceInfo.FLAG_REQUEST_ACCESSIBILITY_BUTTON; + mService.setServiceInfo(info); + } + + @NonNull + AccessibilityButtonController accessibilityButtonController = + mService.getAccessibilityButtonController(); + if (AccessibilityServiceCompatUtils.isAccessibilityButtonAvailableCompat( + accessibilityButtonController)) { + LogUtils.d(TAG, "Accessibility button is available on initialization."); + // If a11y button is available at the very beginning when the monitor is initialized, we can + // confirm that the a11y button is supported on the device. + mHandler.confirmAccessibilityButtonSupportability(true); + } else { + LogUtils.d(TAG, "Accessibility button is not available on initialization."); + // If a11y button is not available when monitor is initialized, there could be two reasons: + // 1. The device has physical nav bar button and the virtual nav bar is not supported on the + // device, which is permanent unavailability. + // 2. Race condition during framework initialization, it returns false when we call + // AccessibilityButtonController.isAccessibilityButtonAvailable(), but soon the + // AccessibilityButtonCallback.onAvailabilityChanged will be called to update availability. + // + // In both cases, it's acceptable to post delay to notify unavailability. If we get notified + // that the availability changes before time out, we can cancel this delayed message and + // update the availability with another message. + mHandler.postDelayedConfirmAccessibilityButtonSupportability(TIMEOUT); + } + + accessibilityButtonCallback = + new AccessibilityButtonController.AccessibilityButtonCallback() { + @Override + public void onClicked(AccessibilityButtonController controller) { + LogUtils.d(TAG, "Accessibility button clicked."); + handleControllerCallbackButtonClicked(); + } + + @Override + public void onAvailabilityChanged( + AccessibilityButtonController controller, boolean available) { + LogUtils.d(TAG, "Accessibility button availability changed. isAvailable=%s", available); + handleControllerCallbackAvailabilityChanged(available); + } + }; + + // Register callback to AccessibilityButtonController. + if (FeatureSupport.supportAccessibilityMultiDisplay()) { + displayManager.registerDisplayListener(displayListener, null); + for (Display display : displayManager.getDisplays()) { + mService + .getAccessibilityButtonController(display.getDisplayId()) + .registerAccessibilityButtonCallback(accessibilityButtonCallback); + } + } else { + accessibilityButtonController.registerAccessibilityButtonCallback( + accessibilityButtonCallback); + } + } + + @TargetApi(Build.VERSION_CODES.O) + public void shutdown() { + if (!FeatureSupport.supportAccessibilityButton()) { + return; + } + // Unregister callback from AccessibilityButtonController. + if (FeatureSupport.supportAccessibilityMultiDisplay()) { + displayManager.unregisterDisplayListener(displayListener); + + for (Display display : displayManager.getDisplays()) { + mService + .getAccessibilityButtonController(display.getDisplayId()) + .unregisterAccessibilityButtonCallback(accessibilityButtonCallback); + } + } else { + mService + .getAccessibilityButtonController() + .unregisterAccessibilityButtonCallback(accessibilityButtonCallback); + } + } + + /** + * Returns {@code true} if accessibility button is detected and supported on the device. + * Note: When it returns {@code false}, it could either because the device + * doesn't support a11y nav bar button, or the a11y button is supported but not detected yet. + */ + public boolean isAccessibilityButtonSupported() { + return mButtonState == SUPPORTED; + } + + /** Handles the callback AccessibilityButtonCallback.onClicked() */ + private void handleControllerCallbackButtonClicked() { + // Override button state, and notify callback if necessary. + if (mButtonState == PENDING) { + mHandler.confirmAccessibilityButtonSupportability(true); + } else if (mButtonState == NOT_SUPPORTED) { + // If the previous state detection is a false negative, override the state without notifying + // availability change. + LogUtils.w( + TAG, + "A11y button is clicked after it's reported as NOT_SUPPORTED. " + + "Update state from NOT_SUPPORTED to SUPPORTED."); + mButtonState = SUPPORTED; + } + + mHandler.notifyButtonClicked(); + } + + /** Handles the callback AccessibilityButtonCallback.onAvailabilityChanged(). */ + private void handleControllerCallbackAvailabilityChanged(boolean available) { + switch (mButtonState) { + case NOT_SUPPORTED: + if (available) { + // The previous detection indicates that the a11y button is not supported on device, but + // the callback shows that the button is actually supported. we should update the state + // quietly without duplicate notifying the confirmation of button availability. + LogUtils.w( + TAG, + "A11y button availability is changed after it's reported as NOT_SUPPORTED. " + + "Update state from NOT_SUPPORTED to SUPPORTED."); + mButtonState = SUPPORTED; + } + break; + case PENDING: + if (available) { + // Available is a strong signal, we can confirm the availability immediately. + mHandler.confirmAccessibilityButtonSupportability(true); + } else { + // Unavailable is a weak signal, we should post delay to confirm the unavailability in + // case that something will be changed during the delay timeout. + mHandler.postDelayedConfirmAccessibilityButtonSupportability(TIMEOUT); + } + break; + case SUPPORTED: + default: + // Do nothing. + break; + } + } + + /** + * A {@link WeakReferenceHandler} to handle the callback for button click actions and button + * support confirmation. + */ + private static final class AccessibilityButtonCallBackHandler + extends WeakReferenceHandler { + private static final int MSG_BUTTON_CLICKED = 0; + private static final int MSG_CONFIRM_BUTTON_NOT_SUPPORTED = 1; + private static final int MSG_CONFIRM_BUTTON_SUPPORTED = 2; + private static final int MSG_CONFIRM_BUTTON_SUPPORTABILITY_DELAYED = 3; + + // Whether we have already notified the confirmation of button support. + private boolean mHasNotifiedSupportability = false; + + public AccessibilityButtonCallBackHandler(AccessibilityButtonMonitor parent) { + super(parent); + } + + @Override + protected void handleMessage(Message msg, AccessibilityButtonMonitor parent) { + if (parent == null) { + return; + } + switch (msg.what) { + case MSG_BUTTON_CLICKED: + parent.mCallback.onAccessibilityButtonClicked(); + break; + case MSG_CONFIRM_BUTTON_NOT_SUPPORTED: + parent.mButtonState = NOT_SUPPORTED; + // Make sure that we only notify once. + if (!mHasNotifiedSupportability) { + LogUtils.d(TAG, "Notify that a11y button is not supported."); + mHasNotifiedSupportability = true; + parent.mCallback.onConfirmSupportability(false); + } + break; + case MSG_CONFIRM_BUTTON_SUPPORTED: + parent.mButtonState = SUPPORTED; + // Make sure that we only notify once. + if (!mHasNotifiedSupportability) { + LogUtils.d(TAG, "Notify that a11y button is supported."); + parent.mCallback.onConfirmSupportability(true); + mHasNotifiedSupportability = true; + } + break; + case MSG_CONFIRM_BUTTON_SUPPORTABILITY_DELAYED: + boolean isAvailable; + if (BuildVersionUtils.isAtLeastOMR1()) { + isAvailable = AccessibilityManager.isAccessibilityButtonSupported(); + } else { + isAvailable = + AccessibilityServiceCompatUtils.isAccessibilityButtonAvailableCompat( + parent.mService.getAccessibilityButtonController()); + } + parent.mButtonState = isAvailable ? SUPPORTED : NOT_SUPPORTED; + if (!mHasNotifiedSupportability) { + LogUtils.d( + TAG, + "Delayed. Notify that a11y button is %s.", + (isAvailable ? "supported" : "not supported")); + parent.mCallback.onConfirmSupportability(isAvailable); + mHasNotifiedSupportability = true; + } + break; + default: + break; + } + } + + private void postDelayedConfirmAccessibilityButtonSupportability(long delay) { + LogUtils.d(TAG, "Post delay to confirm supportability."); + removeMessages(MSG_CONFIRM_BUTTON_SUPPORTED); + removeMessages(MSG_CONFIRM_BUTTON_NOT_SUPPORTED); + removeMessages(MSG_CONFIRM_BUTTON_SUPPORTABILITY_DELAYED); + sendEmptyMessageDelayed(MSG_CONFIRM_BUTTON_SUPPORTABILITY_DELAYED, delay); + } + + private void confirmAccessibilityButtonSupportability(boolean isSupported) { + removeMessages(MSG_CONFIRM_BUTTON_SUPPORTED); + removeMessages(MSG_CONFIRM_BUTTON_NOT_SUPPORTED); + removeMessages(MSG_CONFIRM_BUTTON_SUPPORTABILITY_DELAYED); + obtainMessage(isSupported ? MSG_CONFIRM_BUTTON_SUPPORTED : MSG_CONFIRM_BUTTON_NOT_SUPPORTED) + .sendToTarget(); + } + + private void notifyButtonClicked() { + obtainMessage(MSG_BUTTON_CLICKED).sendToTarget(); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/caption/ImageCaptionStorage.java b/utils/src/main/java/com/google/android/accessibility/utils/caption/ImageCaptionStorage.java new file mode 100644 index 0000000..2957048 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/caption/ImageCaptionStorage.java @@ -0,0 +1,361 @@ +/* + * Copyright (C) 2021 Google Inc. + * + * 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.google.android.accessibility.utils.caption; + +import android.text.TextUtils; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.AccessibilityNode; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils.ViewResourceName; +import com.google.android.accessibility.utils.screenunderstanding.IconAnnotationsDetector; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.common.collect.Maps; +import java.util.HashMap; +import java.util.Locale; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** Stores and retrieves image caption results. */ +public class ImageCaptionStorage { + + private static final String TAG = "ImageCaptionStorage"; + private static final int RESULT_CAPACITY = 500; + + private final LimitedCapacityCache imageNodes; + private @MonotonicNonNull IconAnnotationsDetector iconAnnotationsDetector; + + public ImageCaptionStorage() { + this(RESULT_CAPACITY); + } + + @VisibleForTesting + public ImageCaptionStorage(int capacity) { + imageNodes = new LimitedCapacityCache(capacity); + } + + @VisibleForTesting + public int getImageNodeSize() { + return imageNodes.size(); + } + + /** Removes all cached {@link ImageNode}s. */ + public void clearImageNodesCache() { + imageNodes.clear(); + } + + /** Sets the {@link IconAnnotationsDetector} for retrieving labels of detected icons. */ + public void setIconAnnotationsDetector(IconAnnotationsDetector iconAnnotationsDetector) { + this.iconAnnotationsDetector = iconAnnotationsDetector; + } + + /** + * Retrieves the localized label of the detected icon which matches the specified node. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + @Nullable + public CharSequence getDetectedIconLabel(Locale locale, AccessibilityNodeInfoCompat node) { + return (iconAnnotationsDetector == null) + ? null + : iconAnnotationsDetector.getIconLabel(locale, node); + } + + /** + * Retrieves image caption results for the specified node. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + @Nullable + public ImageNode getCaptionResults(AccessibilityNodeInfoCompat node) { + AccessibilityNode wrapNode = AccessibilityNode.obtainCopy(node); + try { + @Nullable ImageNode imageNode = findImageNode(wrapNode); + if (imageNode == null || !imageNode.isIconLabelStable() || !imageNode.isValid()) { + return null; + } + return imageNode; + } finally { + AccessibilityNode.recycle("ImageManager.getNodeText()", wrapNode); + } + } + + /** + * Stores the OCR result for the specified node in the cache. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public void updateCharacterCaptionResult(AccessibilityNode node, CharSequence result) { + if (!ImageCaptionStorage.isStorable(node) || TextUtils.isEmpty(result)) { + LogUtils.v(TAG, "Character caption result (" + result + ") should not be stored."); + return; + } + + // Always creating a new ImageNode here to avoid searching twice. Because it's necessary to find + // the ImageNode in LimitedCapacityCache.put(). + @Nullable ImageNode imageNode = ImageNode.create(node); + if (imageNode == null) { + return; + } + imageNode.setOcrText(result); + imageNodes.put(imageNode); + } + + /** + * Stores the label of the detected icons for the specified node in the cache. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public void updateDetectedIconLabel(AccessibilityNode node, CharSequence detectedIconLabel) { + if (!ImageCaptionStorage.isStorable(node) || TextUtils.isEmpty(detectedIconLabel)) { + LogUtils.v(TAG, "DetectedIconLabel (" + detectedIconLabel + ") should not be stored."); + return; + } + + @Nullable ImageNode imageNode = ImageNode.create(node); + if (imageNode == null) { + return; + } + imageNode.setDetectedIconLabel(detectedIconLabel); + imageNodes.put(imageNode); + } + + /** + * Marks the OCR text and the detected icon label for the specific node as invalid in the cache. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public void invalidateCaptionForNode(AccessibilityNode node) { + if (!ImageCaptionStorage.isStorable(node)) { + return; + } + + @Nullable final ViewResourceName viewResourceName = node.getPackageNameAndViewId(); + if (viewResourceName != null) { + imageNodes.invalidateImageNode(viewResourceName); + } + } + + /** + * Checks if node has a resource name with a package name and is not in the collection. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public static boolean isStorable(AccessibilityNode node) { + @Nullable final ViewResourceName viewResourceName = node.getPackageNameAndViewId(); + return viewResourceName != null + // The resource ID of most elements in a collection are the same, so they can't be stored. + && !node.isInCollection(); + } + + /** + * Retrieves the related {@link ImageNode} for the specified node. The returned ImageNode will be + * regarded as the newest element. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + @Nullable + private ImageNode findImageNode(AccessibilityNode node) { + if (!isStorable(node)) { + return null; + } + + @Nullable final ViewResourceName viewResourceName = node.getPackageNameAndViewId(); + if (viewResourceName == null) { + return null; + } + + return imageNodes.get(viewResourceName); + } + + /** + * A limited capacity cache for storing {@link ImageNode}s. ImageNodes are stored in a Map to + * quickly search via the key which includes packageName and viewName. Key nodes sort in the order + * of inserted time. + */ + private static final class LimitedCapacityCache { + private final int capacity; + /** The key node for the first inserted ImageNode. */ + private Node firstOldestKey = null; + /** The key node for the last inserted ImageNode. */ + private Node lastNewestKey = null; + + private final HashMap imageNodes; + + public LimitedCapacityCache(int capacity) { + this.capacity = capacity; + this.imageNodes = Maps.newHashMapWithExpectedSize(capacity); + } + + /** Removes all {@link ImageNode}s in the cache. */ + public synchronized void clear() { + imageNodes.clear(); + } + + /** + * Finds the ImageNode by its view resource name and sets the ImageNode to invalid when the + * ImageNode is not {@code null}. + */ + public synchronized void invalidateImageNode(ViewResourceName viewResourceName) { + ImageAndListNode imageAndKeyNode = imageNodes.get(viewResourceName); + if (imageAndKeyNode != null) { + imageAndKeyNode.imageNode.setValid(false); + } + } + + /** + * Returns a copy of ImageNode which has the same view resource name as input-arguments. The + * returned ImageNode will be regarded as the newest element. + */ + @Nullable + private synchronized ImageNode get(ViewResourceName viewResourceName) { + ImageAndListNode imageAndKeyNode = imageNodes.get(viewResourceName); + if (imageAndKeyNode == null) { + return null; + } + moveToLast(imageAndKeyNode); + // Returns a copy to prevent the data in the cache being changed by the outer class directly. + return ImageNode.copy(imageAndKeyNode.imageNode); + } + + /** Adds the specified ImageNode and its key to the cache. */ + private synchronized void add(ImageNode imageNode) { + // Removes the oldest ImageNode because cache is full. + while (imageNodes.size() >= capacity) { + // Removes the first inserted key. + Node oldestNode = firstOldestKey; + firstOldestKey = oldestNode.next; + oldestNode.unlink(); + + LogUtils.v(TAG, "add() cache is full, remove " + oldestNode.data); + imageNodes.remove(oldestNode.data); + } + + // Add a key. + ViewResourceName viewResourceName = imageNode.viewResourceName(); + Node keyNode = new Node<>(viewResourceName); + if (firstOldestKey == null) { + firstOldestKey = keyNode; + } else { + lastNewestKey.insertNextNode(keyNode); + } + lastNewestKey = keyNode; + + LogUtils.v(TAG, "add() " + imageNode); + imageNodes.put(viewResourceName, new ImageAndListNode(keyNode, imageNode)); + } + + /** Replaces the specified ImageNode and moves the corresponding key to last / newest. */ + public synchronized void put(ImageNode imageNode) { + if (firstOldestKey == null) { + add(imageNode); + return; + } + + // Checks if the specified ImageNode exists. + ViewResourceName viewResourceName = imageNode.viewResourceName(); + ImageAndListNode oldImage = imageNodes.get(viewResourceName); + if (oldImage == null) { + add(imageNode); + return; + } + + moveToLast(oldImage); + if (!oldImage.imageNode.isIconLabelStable()) { + return; + } + + LogUtils.v(TAG, "put() " + imageNode); + if (!TextUtils.isEmpty(imageNode.getOcrText())) { + oldImage.imageNode.setValid(true); + oldImage.imageNode.setOcrText(imageNode.getOcrText()); + } + if (!TextUtils.isEmpty(imageNode.getDetectedIconLabel())) { + // Checks whether detected icon labels are different for the same view id + CharSequence oldIconLabel = oldImage.imageNode.getDetectedIconLabel(); + if ((oldIconLabel != null) + && !TextUtils.equals(oldIconLabel, imageNode.getDetectedIconLabel())) { + oldImage.imageNode.setIconLabelStable(false); + return; + } + oldImage.imageNode.setValid(true); + oldImage.imageNode.setDetectedIconLabel(imageNode.getDetectedIconLabel()); + } + } + + /** Moves the specified keyNode to last / newest. */ + private synchronized void moveToLast(ImageAndListNode imageAndListNode) { + Node keyNode = imageAndListNode.keyNode; + if (imageNodes.size() == 1 || keyNode == lastNewestKey) { + return; + } + if (keyNode == firstOldestKey) { + firstOldestKey = keyNode.next; + } + keyNode.unlink(); + lastNewestKey.insertNextNode(keyNode); + lastNewestKey = keyNode; + } + + /** The number of ImageNode in the cache. */ + public synchronized int size() { + return imageNodes.size(); + } + } + + private static class Node { + @Nullable private Node previous; + private final E data; + @Nullable private Node next; + + private Node(E data) { + this.data = data; + } + + private void unlink() { + if (previous != null) { + previous.next = next; + } + if (next != null) { + next.previous = previous; + } + previous = null; + next = null; + } + + private void insertNextNode(Node newNextNode) { + Node oldNextNode = next; + next = newNextNode; + newNextNode.previous = this; + newNextNode.next = oldNextNode; + if (oldNextNode != null) { + oldNextNode.previous = newNextNode; + } + } + } + + /** Stores ImageNode and a reference to the corresponding key node for quick removal. */ + private static class ImageAndListNode { + private final Node keyNode; + + private final ImageNode imageNode; + + private ImageAndListNode(Node keyNode, ImageNode imageNode) { + this.keyNode = keyNode; + this.imageNode = imageNode; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/caption/ImageCaptionUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/caption/ImageCaptionUtils.java new file mode 100644 index 0000000..c8df539 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/caption/ImageCaptionUtils.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2021 Google Inc. + * + * 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.google.android.accessibility.utils.caption; + +import android.content.Context; +import android.graphics.Rect; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.accessibility.utils.FeatureSupport; +import com.google.android.accessibility.utils.Role; +import com.google.android.accessibility.utils.Role.RoleName; + +/** Utility class for image captions. */ +public class ImageCaptionUtils { + + /** The height restriction is used to avoid the cases when an OpenGL view is one big leaf node. */ + @VisibleForTesting static final int MAX_CAPTION_ABLE_LEAF_VIEW_HEIGHT_IN_DP = 150; + + private static final boolean ENABLE_CAPTION_FOR_LEAF_VIEW = true; + + private ImageCaptionUtils() {} + + /** + * Checks if the node needs image captions. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public static boolean needImageCaption( + Context context, @Nullable AccessibilityNodeInfoCompat node) { + return isCaptionable(context, node) + && TextUtils.isEmpty(AccessibilityNodeInfoUtils.getNodeText(node)); + } + + /** + * Checks if the node can be captioned. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + public static boolean isCaptionable(Context context, @Nullable AccessibilityNodeInfoCompat node) { + if (node == null || !FeatureSupport.canTakeScreenShotByAccessibilityService()) { + return false; + } + + @RoleName int role = Role.getRole(node); + if (role == Role.ROLE_IMAGE || role == Role.ROLE_IMAGE_BUTTON) { + return true; + } else if (ENABLE_CAPTION_FOR_LEAF_VIEW && (node.getChildCount() == 0)) { + Rect rect = new Rect(); + node.getBoundsInScreen(rect); + if (!rect.isEmpty() + && rect.height() + <= context.getResources().getDisplayMetrics().density + * MAX_CAPTION_ABLE_LEAF_VIEW_HEIGHT_IN_DP) { + return true; + } + } + + return false; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/caption/ImageNode.java b/utils/src/main/java/com/google/android/accessibility/utils/caption/ImageNode.java new file mode 100644 index 0000000..2ded6b8 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/caption/ImageNode.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2021 Google Inc. + * + * 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.google.android.accessibility.utils.caption; + +import android.text.TextUtils; +import com.google.android.accessibility.utils.AccessibilityNode; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils.ViewResourceName; +import com.google.android.accessibility.utils.StringBuilderUtils; +import com.google.auto.value.AutoValue; +import java.util.Objects; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** The identifiable information of an image node and the result of image captions. */ +@AutoValue +public abstract class ImageNode { + public abstract ViewResourceName viewResourceName(); + + private @Nullable CharSequence ocrText; + + private @Nullable CharSequence detectedIconLabel; + + /** + * When isValid is false, it means the view has been clicked and the icon inside the view may + * change. When an ImageNode is not valid, the imageNode can become valid again. + */ + private boolean isValid = true; + + /** + * Whether the same icon label result has always been detected for this view resource name when + * speech locale keeps unchanged. Once an ImageNode becomes unstable for icon label, this + * ImageNode will never become stable for icon label again. + */ + private boolean isIconLabelStable = true; + + /** + * Creates an instance of {@link ImageNode} without the results of image captions. + * + *

Note: Caller is responsible for recycling the node-argument. + */ + static @Nullable ImageNode create(AccessibilityNode node) { + final @Nullable ViewResourceName viewResourceName = node.getPackageNameAndViewId(); + if (viewResourceName == null) { + return null; + } + + // The resource ID of most elements in a collection are the same, so they can't be stored. + return new AutoValue_ImageNode(viewResourceName); + } + + /** Returns a copy of the ImageNode-argument. */ + static ImageNode copy(ImageNode imageNode) { + ImageNode copy = new AutoValue_ImageNode(imageNode.viewResourceName()); + copy.setOcrText(imageNode.ocrText); + copy.setDetectedIconLabel(imageNode.detectedIconLabel); + copy.isValid = imageNode.isValid; + copy.isIconLabelStable = imageNode.isIconLabelStable; + return copy; + } + + public @Nullable CharSequence getOcrText() { + return ocrText; + } + + public @Nullable CharSequence getDetectedIconLabel() { + return detectedIconLabel; + } + + public void setOcrText(@Nullable CharSequence ocrText) { + this.ocrText = ocrText; + } + + public void setDetectedIconLabel(@Nullable CharSequence detectedIconLabel) { + this.detectedIconLabel = detectedIconLabel; + } + + @Override + public final boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof ImageNode)) { + return false; + } + ImageNode imageNode = (ImageNode) object; + return viewResourceName().equals(imageNode.viewResourceName()) + && TextUtils.equals(ocrText, imageNode.getOcrText()) + && TextUtils.equals(detectedIconLabel, imageNode.getDetectedIconLabel()) + && (isValid == imageNode.isValid) + && (isIconLabelStable == imageNode.isIconLabelStable); + } + + @Override + public final int hashCode() { + return Objects.hash(viewResourceName(), getOcrText(), getDetectedIconLabel(), isValid); + } + + @Override + public final String toString() { + return "ImageNode= " + + StringBuilderUtils.joinFields( + StringBuilderUtils.optionalSubObj("viewResourceName", viewResourceName()), + StringBuilderUtils.optionalTag("isIconLabelStable", isIconLabelStable), + StringBuilderUtils.optionalTag("isValid", isValid), + StringBuilderUtils.optionalText("ocrText", ocrText), + StringBuilderUtils.optionalText("detectedIconLabel", detectedIconLabel)); + } + + boolean isValid() { + return isValid; + } + + void setValid(boolean valid) { + this.isValid = valid; + } + + boolean isIconLabelStable() { + return isIconLabelStable; + } + + void setIconLabelStable(boolean stable) { + this.isIconLabelStable = stable; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/compat/AppOpsManagerCompatUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/compat/AppOpsManagerCompatUtils.java new file mode 100644 index 0000000..1ee009f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/compat/AppOpsManagerCompatUtils.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2018 Google Inc. + * + * 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.google.android.accessibility.utils.compat; + +import android.app.AppOpsManager; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +/** Compatibility utilities for interacting with {@link AppOpsManager} */ +public final class AppOpsManagerCompatUtils { + private static final Class CLASS = AppOpsManager.class; + + private static final Method METHOD_checkOpNoThrow = + CompatUtils.getMethod(CLASS, "checkOpNoThrow", int.class, int.class, String.class); + + private static final Field FIELD_OP_PROJECT_MEDIA = + CompatUtils.getField(CLASS, "OP_PROJECT_MEDIA"); + + private static final int OP_CODE_UNRESOLVED = -1; + + public static final int OP_PROJECT_MEDIA = + (Integer) CompatUtils.getFieldValue(null, OP_CODE_UNRESOLVED, FIELD_OP_PROJECT_MEDIA); + + private AppOpsManagerCompatUtils() { + // Not instantiable + } + + /** + * Like {@link AppOpsManager#checkOp(String,int,String)} but instead of throwing a {@link + * SecurityException} it returns {@link AppOpsManager#MODE_ERRORED}. + * + * @param manager The {@link AppOpsManager} instance to invoke + * @param op The operation to check. One of the OP_* constants + * @param uid The user id of the application attempting to perform the operation + * @param pName The package name of the application attempting to perform the operation + * @return {@link AppOpsManager#MODE_ALLOWED} if the operation is allowed, {@link + * AppOpsManager#MODE_IGNORED} if it is not allowed and should be silently ignored (without + * causing the app to crash), {@link AppOpsManager#MODE_ERRORED} if a {@link + * SecurityException} would have been through if invoked via {@link + * AppOpsManager#checkOp(String, int, String)}, or {@code -1} if another error occurs + */ + public static int checkOpNoThrow(AppOpsManager manager, int op, int uid, String pName) { + return (Integer) CompatUtils.invoke(manager, -1, METHOD_checkOpNoThrow, op, uid, pName); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/compat/CompatUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/compat/CompatUtils.java new file mode 100644 index 0000000..790c863 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/compat/CompatUtils.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2011 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.google.android.accessibility.utils.compat; + +import android.text.TextUtils; +import android.util.Log; +import androidx.annotation.Nullable; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import org.checkerframework.checker.nullness.qual.PolyNull; + +public class CompatUtils { + private static final String TAG = CompatUtils.class.getSimpleName(); + + /** Whether to log debug output. */ + private static final boolean DEBUG = false; + + @Nullable + public static Class getClass(String className) { + if (TextUtils.isEmpty(className)) { + return null; + } + + try { + return Class.forName(className); + } catch (ClassNotFoundException e) { + if (DEBUG) { + e.printStackTrace(); + } + } + + return null; + } + + @Nullable + public static Method getMethod(Class targetClass, String name, Class... parameterTypes) { + if ((targetClass == null) || TextUtils.isEmpty(name)) { + return null; + } + + try { + return targetClass.getDeclaredMethod(name, parameterTypes); + } catch (Exception e) { + if (DEBUG) { + e.printStackTrace(); + } + } + + return null; + } + + @Nullable + public static Field getField(Class targetClass, String name) { + if ((targetClass == null) || (TextUtils.isEmpty(name))) { + return null; + } + + try { + return targetClass.getDeclaredField(name); + } catch (Exception e) { + if (DEBUG) { + e.printStackTrace(); + } + } + + return null; + } + + public static @PolyNull Object invoke( + Object receiver, @PolyNull Object defaultValue, Method method, Object... args) { + if (method == null) { + return defaultValue; + } + + try { + return method.invoke(receiver, args); + } catch (Exception e) { + Log.e(TAG, "Exception in invoke: " + e.getClass().getSimpleName()); + + if (DEBUG) { + e.printStackTrace(); + } + } + + return defaultValue; + } + + public static Object getFieldValue(Object receiver, Object defaultValue, Field field) { + if (field == null) { + return defaultValue; + } + + try { + return field.get(receiver); + } catch (Exception e) { + if (DEBUG) { + e.printStackTrace(); + } + } + + return defaultValue; + } + + private CompatUtils() { + // This class is non-instantiable. + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/compat/media/AudioManagerCompatUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/compat/media/AudioManagerCompatUtils.java new file mode 100644 index 0000000..63281b3 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/compat/media/AudioManagerCompatUtils.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * 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.google.android.accessibility.utils.compat.media; + +import android.media.AudioManager; +import com.google.android.accessibility.utils.compat.CompatUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.lang.reflect.Method; + +public class AudioManagerCompatUtils { + private static final Method METHOD_forceVolumeControlStream = + CompatUtils.getMethod(AudioManager.class, "forceVolumeControlStream", int.class); + + private static final String TAG = "AudioManagerCompatUtils"; + + /** + * Broadcast intent when the volume for a particular stream type changes. Includes the stream, the + * new volume and previous volumes + * + * @see #EXTRA_VOLUME_STREAM_TYPE + * @see #EXTRA_VOLUME_STREAM_VALUE + * @see #EXTRA_PREV_VOLUME_STREAM_VALUE + */ + public static final String VOLUME_CHANGED_ACTION = "android.media.VOLUME_CHANGED_ACTION"; + + /** The stream type for the volume changed intent. */ + public static final String EXTRA_VOLUME_STREAM_TYPE = "android.media.EXTRA_VOLUME_STREAM_TYPE"; + + /** The stream type alias for the volume changed intent. */ + public static final String EXTRA_VOLUME_STREAM_TYPE_ALIAS = + "android.media.EXTRA_VOLUME_STREAM_TYPE_ALIAS"; + + /** The volume associated with the stream for the volume changed intent. */ + public static final String EXTRA_VOLUME_STREAM_VALUE = "android.media.EXTRA_VOLUME_STREAM_VALUE"; + + /** The previous volume associated with the stream for the volume changed intent. */ + public static final String EXTRA_PREV_VOLUME_STREAM_VALUE = + "android.media.EXTRA_PREV_VOLUME_STREAM_VALUE"; + + /** + * Broadcast intent when the volume set to mute or unmute. Includes the stream type, the current + * mute state + * + * @see EXTRA_VOLUME_STREAM_TYPE + * @see EXTRA_STREAM_VOLUME_MUTED + */ + public static final String STREAM_MUTE_CHANGED_ACTION = + "android.media.STREAM_MUTE_CHANGED_ACTION"; + + /** The mute statefor the stream mute changed intent. */ + public static final String EXTRA_STREAM_VOLUME_MUTED = "android.media.EXTRA_STREAM_VOLUME_MUTED"; + + private AudioManagerCompatUtils() { + // This class is non-instantiable. + } + + /** + * Forces the stream controlled by hard volume keys specifying streamType == -1 releases control + * to the logic. + * + *

Warning: This is a private API, and it may not exist in API 16+. + */ + public static void forceVolumeControlStream(AudioManager receiver, int streamType) { + CompatUtils.invoke(receiver, null, METHOD_forceVolumeControlStream, streamType); + } + + /** + * Wraps {@link AudioManager#adjustStreamVolume(int, int, int)} to handle exception. + * + * @see AudioManager#adjustStreamVolume(int, int, int). + *

Post N, adjustStreamVolume can throw a SecurityException when changing the stream volume + * would change the DnD mode and the caller doesn't have + * android.Manifest.permission.MANAGE_NOTIFICATIONS, which is a signature permission. Hence + * Talkback catches the exception to avoid crashing. + */ + public static void adjustStreamVolume( + AudioManager audioManager, int streamType, int direction, int flags, String source) { + try { + if (audioManager != null) { + audioManager.adjustStreamVolume(streamType, direction, flags); + } + } catch (SecurityException e) { + LogUtils.e(TAG, "Error while adjusting stream volume: %s", e); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/compat/media/AudioSystemCompatUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/compat/media/AudioSystemCompatUtils.java new file mode 100644 index 0000000..982e946 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/compat/media/AudioSystemCompatUtils.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * 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.google.android.accessibility.utils.compat.media; + +import com.google.android.accessibility.utils.compat.CompatUtils; +import java.lang.reflect.Method; + +public class AudioSystemCompatUtils { + private static final Class CLASS_AudioSystem = + CompatUtils.getClass("android.media.AudioSystem"); + private static final Method METHOD_isSourceActive = + CompatUtils.getMethod(CLASS_AudioSystem, "isSourceActive", int.class); + + /** + * Calls into AudioSystem to check the current status of an input source. + * + *

This is only available on API 17+ and will always return false if invoked on earlier + * platforms. + * + * @param source The source ID to query. Expects constants from {@code MediaRecorder.AudioSource} + * @return {@code true} if the input source is active, {@code false} otherwise. + */ + public static boolean isSourceActive(int source) { + return (Boolean) CompatUtils.invoke(null, false, METHOD_isSourceActive, source); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/compat/provider/SettingsCompatUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/compat/provider/SettingsCompatUtils.java new file mode 100644 index 0000000..c906cc0 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/compat/provider/SettingsCompatUtils.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2011 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.google.android.accessibility.utils.compat.provider; + +import android.content.Context; +import android.provider.Settings; + +/** TODO: Figure out why this is separate from SecureSettingsUtils, or merge them. */ +public class SettingsCompatUtils { + private SettingsCompatUtils() { + // This class is non-instantiable. + } + + /** TODO: Figure out why this inner class is needed, or merge it with outer class. */ + public static class SecureCompatUtils { + private SecureCompatUtils() { + // This class is non-instantiable. + } + + /** Whether to speak passwords while in accessibility mode. */ + public static final String ACCESSIBILITY_SPEAK_PASSWORD = "speak_password"; + + /** + * Stores the default TTS locales on a per engine basis. Stored as a comma separated list of + * values, each value being of the form {@code engine_name:locale} for example, {@code + * com.foo.ttsengine:eng-USA,com.bar.ttsengine:esp-ESP}. Apps should never need to read this + * setting directly, and can query the TextToSpeech framework classes for the locale that is in + * use. + */ + public static final String TTS_DEFAULT_LOCALE = "tts_default_locale"; + + /** + * Returns whether to speak passwords while in accessibility mode. + * + * @param context The parent context. + * @return {@code true} if passwords should always be spoken aloud. + */ + public static boolean shouldSpeakPasswords(Context context) { + return (Settings.Secure.getInt(context.getContentResolver(), ACCESSIBILITY_SPEAK_PASSWORD, 0) + == 1); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/compat/speech/tts/TextToSpeechCompatUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/compat/speech/tts/TextToSpeechCompatUtils.java new file mode 100644 index 0000000..0965ef0 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/compat/speech/tts/TextToSpeechCompatUtils.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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.google.android.accessibility.utils.compat.speech.tts; + +import android.speech.tts.TextToSpeech; +import com.google.android.accessibility.utils.compat.CompatUtils; +import java.lang.reflect.Method; + +public class TextToSpeechCompatUtils { + private static final Method METHOD_getCurrentEngine = + CompatUtils.getMethod(TextToSpeech.class, "getCurrentEngine"); + + private TextToSpeechCompatUtils() { + // This class is non-instantiable. + } + + /** @return the engine currently in use by this TextToSpeech instance. */ + public static String getCurrentEngine(TextToSpeech receiver) { + return (String) CompatUtils.invoke(receiver, null, METHOD_getCurrentEngine); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/compat/view/InputDeviceCompatUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/compat/view/InputDeviceCompatUtils.java new file mode 100644 index 0000000..0b8983f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/compat/view/InputDeviceCompatUtils.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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.google.android.accessibility.utils.compat.view; + +public class InputDeviceCompatUtils { + /** + * The input source is a pointing device associated with a display. Examples: {@link + * #SOURCE_TOUCHSCREEN}, {@link #SOURCE_MOUSE}. A {@link android.view.MotionEvent} should be + * interpreted as absolute coordinates in display units according to the {@link android.view.View} + * hierarchy. Pointer down/up indicated when the finger touches the display or when the selection + * button is pressed/released. + */ + private static final int SOURCE_CLASS_POINTER = 0x00000002; + + /** + * The input source is a mouse pointing device. This code is also used for other mouse-like + * pointing devices such as trackpads and trackpoints. + * + * @see #SOURCE_CLASS_POINTER + */ + public static final int SOURCE_MOUSE = 0x00002000 | SOURCE_CLASS_POINTER; + + /** + * The input source is a touch screen pointing device. + * + * @see #SOURCE_CLASS_POINTER + */ + public static final int SOURCE_TOUCHSCREEN = 0x00001000 | SOURCE_CLASS_POINTER; + + /** The input source is unknown. */ + public static final int SOURCE_UNKNOWN = 0x00000000; + + private InputDeviceCompatUtils() { + // This class is non-instantiable. + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/compat/view/MotionEventCompatUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/compat/view/MotionEventCompatUtils.java new file mode 100644 index 0000000..8f2834e --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/compat/view/MotionEventCompatUtils.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2011 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.google.android.accessibility.utils.compat.view; + +import android.os.SystemClock; +import android.view.MotionEvent; +import com.google.android.accessibility.utils.compat.CompatUtils; +import java.lang.reflect.Method; + +/** Compat utility class for motion event. */ +public class MotionEventCompatUtils { + private static final Class CLASS_MotionEvent = MotionEvent.class; + private static final Method METHOD_getSource = CompatUtils.getMethod(CLASS_MotionEvent, + "getSource"); + private static final Method METHOD_setSource = CompatUtils.getMethod(CLASS_MotionEvent, + "setSource", int.class); + private static final Method METHOD_setDownTime = CompatUtils.getMethod(CLASS_MotionEvent, + "setDownTime", long.class); + + public static final int ACTION_HOVER_MOVE = 0x7; + public static final int ACTION_HOVER_ENTER = 0x9; + public static final int ACTION_HOVER_EXIT = 0xA; + + private static long sPreviousDownTime = 0; + + private MotionEventCompatUtils() { + // This class is non-instantiable. + } + + /** + * Gets the source of the event. + * + * @return The event source or {@link InputDeviceCompatUtils#SOURCE_UNKNOWN} if unknown. + */ + public static int getSource(MotionEvent event) { + return (Integer) + CompatUtils.invoke(event, InputDeviceCompatUtils.SOURCE_UNKNOWN, METHOD_getSource); + } + + /** + * Modifies the source of the event. + *

+ * Introduced in API Level 12. This method has no effect when called using + * earlier API levels. + *

+ * + * @param event The event to modify. + * @param source The new source. + */ + public static void setSource(MotionEvent event, int source) { + CompatUtils.invoke(event, null, METHOD_setSource, source); + } + + /** + * Sets the time (in ms) when the user originally pressed down to start a + * stream of position events. + *

+ * Not a public API. This method has no effect when called using unsupported + * API levels. + *

+ * + * @param event The event to modify. + */ + public static void setDownTime(MotionEvent event, long downTime) { + CompatUtils.invoke(event, null, METHOD_setDownTime, downTime); + } + + /** + * Converts a hover {@link MotionEvent} to touch event by changing its + * action and source. Returns an modified clone of the original event. + *

+ * The following types are affected: + *

    + *
  • {@link MotionEventCompatUtils#ACTION_HOVER_ENTER} + *
  • {@link MotionEventCompatUtils#ACTION_HOVER_MOVE} + *
  • {@link MotionEventCompatUtils#ACTION_HOVER_EXIT} + *
+ * + * @param hoverEvent The hover event to convert. + * @return a touch event + */ + public static MotionEvent convertHoverToTouch(MotionEvent hoverEvent) { + final MotionEvent touchEvent = MotionEvent.obtain(hoverEvent); + + long downTime = sPreviousDownTime; + + switch (touchEvent.getAction()) { + case MotionEventCompatUtils.ACTION_HOVER_ENTER: + touchEvent.setAction(MotionEvent.ACTION_DOWN); + sPreviousDownTime = SystemClock.uptimeMillis(); + downTime = sPreviousDownTime; + break; + case MotionEventCompatUtils.ACTION_HOVER_MOVE: + touchEvent.setAction(MotionEvent.ACTION_MOVE); + break; + case MotionEventCompatUtils.ACTION_HOVER_EXIT: + touchEvent.setAction(MotionEvent.ACTION_UP); + sPreviousDownTime = 0; + break; + default: + downTime = touchEvent.getDownTime(); + } + + MotionEventCompatUtils.setSource(touchEvent, InputDeviceCompatUtils.SOURCE_MOUSE); + MotionEventCompatUtils.setDownTime(touchEvent, downTime); + + return touchEvent; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/feedbackpolicy/AbstractAccessibilityHintsManager.java b/utils/src/main/java/com/google/android/accessibility/utils/feedbackpolicy/AbstractAccessibilityHintsManager.java new file mode 100644 index 0000000..e6652cd --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/feedbackpolicy/AbstractAccessibilityHintsManager.java @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2020 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.google.android.accessibility.utils.feedbackpolicy; + +import static android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED; +import static android.view.accessibility.AccessibilityEvent.TYPE_VIEW_FOCUSED; + +import android.view.accessibility.AccessibilityEvent; +import androidx.annotation.VisibleForTesting; +import androidx.core.view.accessibility.AccessibilityEventCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.output.FeedbackItem; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Manages accessibility hints. When a node is accessibility-focused or a hint about screen, the + * hint will be queued after a short delay and this delay must be implemented at inherited class. + */ +public abstract class AbstractAccessibilityHintsManager { + + /** Timeout before reading a hint. */ + public static final long DELAY_HINT = 400; // ms + + protected final HintInfo hintInfo; + + public AbstractAccessibilityHintsManager() { + hintInfo = new HintInfo(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // Abstract function. Inherited class has to implement these functions to process speaking hints. + + /** Starts the hint timeout. */ + protected abstract void startHintDelay(); + + /** Removes the hint timeout and completion action. Call this for every event. */ + protected abstract void cancelHintDelay(); + + /////////////////////////////////////////////////////////////////////////////////////////////// + // Virtual function. Inherited class may or may not override these functions. + + /** + * Cancels the pending accessibility hint if the hint source is null or if the event that + * triggered the hint was not a view getting focused or accessibility focused. + * + * @return {@code true} if the pending accessibility hint was canceled, {@code false} otherwise. + */ + @VisibleForTesting + public boolean cancelA11yHintBasedOnEventType() { + if (hintInfo.getPendingHintSource() == null + || (hintInfo.getPendingHintEventType() != TYPE_VIEW_FOCUSED + && hintInfo.getPendingHintEventType() != TYPE_VIEW_ACCESSIBILITY_FOCUSED)) { + cancelHintDelay(); + hintInfo.clear(); + return true; + } + return false; + } + + /** + * Should be called when the window state changes. This method will cancel the pending hint if + * deemed appropriate based on the window event. + */ + public void onScreenStateChanged() { + cancelA11yHintBasedOnEventType(); + } + + /** Posts a hint about screen. The hint will be spoken after the next utterance is completed. */ + public void postHintForScreen(CharSequence hint) { + cancelHintDelay(); + hintInfo.clear(); + + hintInfo.setPendingScreenHint(hint); + + startHintDelay(); + } + + /** + * Posts a hint about node. The hint will be spoken after the next utterance is completed. Caller + * keeps ownership of node. + */ + public void postHintForNode(AccessibilityEvent event, AccessibilityNodeInfoCompat node) { + postHintForNode( + event, + node, + /* forceFeedbackEvenIfAudioPlaybackActive= */ false, + /* forceFeedbackEvenIfMicrophoneActive= */ false); + } + + /** + * Posts a hint about node with customized flag {@link + * HintInfo#nodeHintForceFeedbackEvenIfMicrophoneActive} and {@link + * HintInfo#nodeHintForceFeedbackEvenIfAudioPlaybackActive}. The hint will be spoken after the + * next utterance is completed. Caller keeps ownership of node. + * + * @param event accessibility event + * @param node AccessibilityNodeInfoCompat which keeps the hint information + * @param forceFeedbackEvenIfAudioPlaybackActive force to speak the hint when audio playback + * actives + * @param forceFeedbackEvenIfMicrophoneActive force to speak the hint when micro phone actives + */ + public void postHintForNode( + AccessibilityEvent event, + AccessibilityNodeInfoCompat node, + boolean forceFeedbackEvenIfAudioPlaybackActive, + boolean forceFeedbackEvenIfMicrophoneActive) { + cancelHintDelay(); + hintInfo.clear(); + + // Store info about event that caused pending hint. + hintInfo.setPendingHintSource(node); + // The hint for a node is usually posted when the node is getting accessibility focus, thus + // the default value for the hint event type should be TYPE_VIEW_ACCESSIBILITY_FOCUSED. + int eventType = + (event != null) + ? event.getEventType() + : AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED; + hintInfo.setPendingHintEventType(eventType); + hintInfo.setNodeHintForceFeedbackEvenIfAudioPlaybackActive( + forceFeedbackEvenIfAudioPlaybackActive); + hintInfo.setNodeHintForceFeedbackEvenIfMicrophoneActive(forceFeedbackEvenIfMicrophoneActive); + + startHintDelay(); + } + + /** + * Posts a hint about selector (quick menu). The hint will be spoken after the next utterance is + * completed. + */ + public void postHintForSelector(CharSequence hint) { + cancelHintDelay(); + hintInfo.clear(); + hintInfo.setPendingSelectorHint(hint); + startHintDelay(); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // Inner class for hint data + + /** Data-structure that holds a variety of hint data. */ + protected static class HintInfo { + /** The source node whose hint will be read by the utterance complete action. */ + @Nullable private AccessibilityNodeInfoCompat pendingHintSource; + /** + * Whether the current hint is a forced feedback. Set to {@code true} if the hint corresponds to + * accessibility focus that was not genenerated from unknown source for audioplayback and + * microphone active. Set to false if ssb is active. + * + * @see FeedbackItem#FLAG_FORCE_FEEDBACK_EVEN_IF_AUDIO_PLAYBACK_ACTIVE + * @see FeedbackItem#FLAG_FORCE_FEEDBACK_EVEN_IF_MICROPHONE_ACTIVE + * @see FeedbackItem#FLAG_FORCE_FEEDBACK_EVEN_IF_SSB_ACTIVE + */ + private boolean nodeHintForceFeedbackEvenIfAudioPlaybackActive = true; + + private boolean nodeHintForceFeedbackEvenIfMicrophoneActive = true; + + /** The event type for the hint source node. */ + private int pendingHintEventType; + + /** A hint about screen whose hint will be read by the utterance complete action. */ + @Nullable private CharSequence pendingScreenHint; + + /** + * A hint about selector (quick menu) whose hint will be read by the utterance complete action. + */ + @Nullable private CharSequence pendingSelectorHint; + + public HintInfo() {} + + /** + * Sets whether the hint for the hint source node is a forced feedback when audio playback is + * active. + */ + public void setNodeHintForceFeedbackEvenIfAudioPlaybackActive( + boolean nodeHintForceFeedbackAudioPlaybackActive) { + this.nodeHintForceFeedbackEvenIfAudioPlaybackActive = + nodeHintForceFeedbackAudioPlaybackActive; + } + + public boolean getNodeHintForceFeedbackEvenIfAudioPlaybackActive() { + return nodeHintForceFeedbackEvenIfAudioPlaybackActive; + } + + /** + * Sets whether the hint for the hint source node is a forced feedback when microphone is + * active. + */ + public void setNodeHintForceFeedbackEvenIfMicrophoneActive( + boolean nodeHintForcedFeedbackMicrophoneActive) { + this.nodeHintForceFeedbackEvenIfMicrophoneActive = nodeHintForcedFeedbackMicrophoneActive; + } + + public boolean getNodeHintForceFeedbackEvenIfMicrophoneActive() { + return nodeHintForceFeedbackEvenIfMicrophoneActive; + } + + /** + * Sets accessibility event type. The default value for the hint event type should be + * TYPE_VIEW_ACCESSIBILITY_FOCUSED + */ + public void setPendingHintEventType(int hintEventType) { + pendingHintEventType = hintEventType; + } + + public int getPendingHintEventType() { + return pendingHintEventType; + } + + /** Sets hint source node. Caller keeps ownership of hintSource. */ + public void setPendingHintSource(AccessibilityNodeInfoCompat hintSource) { + pendingHintSource = hintSource; + } + + public @Nullable AccessibilityNodeInfoCompat getPendingHintSource() { + return pendingHintSource; + } + + /** Sets a hint about screen. */ + public void setPendingScreenHint(CharSequence screenHint) { + pendingScreenHint = screenHint; + } + + public @Nullable CharSequence getPendingScreenHint() { + return pendingScreenHint; + } + + /** Sets a hint about selector (quick menu). */ + public void setPendingSelectorHint(@Nullable CharSequence selectorHint) { + pendingSelectorHint = selectorHint; + } + + @Nullable + public CharSequence getPendingSelectorHint() { + return pendingSelectorHint; + } + + /** Clears hint data */ + public void clear() { + // Clears hint source node and related. + pendingHintSource = null; + nodeHintForceFeedbackEvenIfAudioPlaybackActive = true; + nodeHintForceFeedbackEvenIfMicrophoneActive = true; + + // Clears a hint about screen. + pendingScreenHint = null; + + // Clears a hint about selector (quick menu). + pendingSelectorHint = null; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/feedbackpolicy/ScreenFeedbackManager.java b/utils/src/main/java/com/google/android/accessibility/utils/feedbackpolicy/ScreenFeedbackManager.java new file mode 100644 index 0000000..4c08bb2 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/feedbackpolicy/ScreenFeedbackManager.java @@ -0,0 +1,619 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.feedbackpolicy; + +import static com.google.android.accessibility.utils.AccessibilityEventUtils.WINDOW_ID_NONE; + +import android.accessibilityservice.AccessibilityService; +import android.content.Context; +import android.os.Bundle; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.view.accessibility.AccessibilityEvent; +import com.google.android.accessibility.utils.AccessibilityEventListener; +import com.google.android.accessibility.utils.FeatureSupport; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.PureFunction; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.ReadOnly; +import com.google.android.accessibility.utils.StringBuilderUtils; +import com.google.android.accessibility.utils.WindowUtils; +import com.google.android.accessibility.utils.input.WindowEventInterpreter; +import com.google.android.accessibility.utils.input.WindowEventInterpreter.Announcement; +import com.google.android.accessibility.utils.output.FeedbackController; +import com.google.android.accessibility.utils.output.FeedbackItem; +import com.google.android.accessibility.utils.output.SpeechController; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.checkerframework.checker.initialization.qual.UnderInitialization; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Generates speech for window events. Customized by SwitchAccess and TalkBack. + * + *

The overall design is to have 3 stages, similar to Compositor: + * + *

    + *
  1. Event interpretation, which outputs a complete description of the event that can be logged + * to tell us all we need to know about what happened. + *
  2. Feedback rules, which are stateless (aka static) and independent of the android operating + * system version. The feedback can be logged to tell us all we need to know about what + * talkback is trying to do in response to the event. This happens in composeFeedback(). + *
  3. Feedback methods, which provide a simple interface for speaking and acting on the + * user-interface. + *
+ */ +public class ScreenFeedbackManager + implements AccessibilityEventListener, + WindowEventInterpreter.WindowEventHandler { + + private static final String TAG = "ScreenFeedbackManager"; + + /** Event types that are handled by ScreenFeedbackManager. */ + private static final int MASK_EVENTS_HANDLED_BY_SCREEN_FEEDBACK_MANAGER = + AccessibilityEvent.TYPE_WINDOWS_CHANGED | AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED; + + private final AllContext allContext; // Wrapper around various context and preference data. + private final WindowEventInterpreter interpreter; + private boolean listeningToInterpreter = false; + protected FeedbackComposer feedbackComposer; + + // Context used by this class. + protected final AccessibilityService service; + private final boolean isArc; + protected final @Nullable AbstractAccessibilityHintsManager accessibilityHintsManager; + private final @Nullable SpeechController speechController; + private final @Nullable FeedbackController feedbackController; + private final boolean isScreenOrientationLandscape; + + public ScreenFeedbackManager( + AccessibilityService service, + @Nullable AbstractAccessibilityHintsManager hintsManager, + @Nullable SpeechController speechController, + @Nullable FeedbackController feedbackController, + boolean screenOrientationLandscape) { + interpreter = new WindowEventInterpreter(service); + allContext = getAllContext(service, createPreferences()); + feedbackComposer = createComposer(); + + this.service = service; + isArc = FeatureSupport.isArc(); + + accessibilityHintsManager = hintsManager; + this.speechController = speechController; + this.feedbackController = feedbackController; + isScreenOrientationLandscape = screenOrientationLandscape; + } + + /** Allow overriding preference creation. */ + protected @Nullable UserPreferences createPreferences( + @UnderInitialization ScreenFeedbackManager this) { + return null; + } + + /** Allow overriding feedback composition. */ + protected FeedbackComposer createComposer(@UnderInitialization ScreenFeedbackManager this) { + return new FeedbackComposer(); + } + + public void clearScreenState() { + getInterpreter().clearScreenState(); + } + + @Override + public int getEventTypes() { + return MASK_EVENTS_HANDLED_BY_SCREEN_FEEDBACK_MANAGER; + } + + @Override + public void onAccessibilityEvent(AccessibilityEvent event, EventId eventId) { + // Skip the delayed interpret if doesn't allow the announcement. + getInterpreter().interpret(event, eventId, allowAnnounce(event)); + } + + protected WindowEventInterpreter getInterpreter() { + // Interpreter requires an initialized listener, so add listener on-demand. + if (!listeningToInterpreter) { + interpreter.addPriorityListener(this); + listeningToInterpreter = true; + } + return interpreter; + } + + // Inherited class needs to override this function if inherited class has own speaker system + protected void checkSpeaker() { + if (speechController == null) { + throw new IllegalStateException(); + } + } + + protected void speak( + CharSequence utterance, + @Nullable CharSequence hint, + @Nullable EventId eventId, + boolean forceFeedbackEvenIfAudioPlaybackActive, + boolean forceFeedbackEvenIfMicrophoneActive, + boolean forceFeedbackEvenIfSsbActive, + boolean sourceIsVolumeControl) { + if ((hint != null) && (accessibilityHintsManager != null)) { + accessibilityHintsManager.postHintForScreen(hint); + } + + if (feedbackController != null) { + feedbackController.playActionCompletionFeedback(); + } + + if (speechController != null) { + int flags = + (forceFeedbackEvenIfAudioPlaybackActive + ? FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_AUDIO_PLAYBACK_ACTIVE + : 0) + | FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_PHONE_CALL_ACTIVE + | (forceFeedbackEvenIfMicrophoneActive + ? FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_MICROPHONE_ACTIVE + : 0) + | (forceFeedbackEvenIfSsbActive + ? FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_SSB_ACTIVE + : 0) + | (sourceIsVolumeControl ? FeedbackItem.FLAG_SOURCE_IS_VOLUME_CONTROL : 0); + speechController.speak( + utterance, /* Text */ + SpeechController.QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH, /* QueueMode */ + flags, + new Bundle(), /* SpeechParams */ + eventId); + } + } + + /** + * Returns the context data for feedback generation. + * + * @param context The context from which information about the screen will be retrieved. + * @param preferences The {@link UserPreferences} object which contains user preferences related + * to the current accessibility service. + * @return The {@link AllContext} object which contains the context data for feedback generation. + */ + protected AllContext getAllContext( + @UnderInitialization ScreenFeedbackManager this, + Context context, + @Nullable UserPreferences preferences) { + DeviceInfo deviceInfo = new DeviceInfo(); + AllContext allContext = new AllContext(deviceInfo, context, preferences); + return allContext; + } + + @Override + public void handle( + WindowEventInterpreter.EventInterpretation interpretation, @Nullable EventId eventId) { + if (interpretation == null) { + return; + } + + boolean doFeedback = customHandle(interpretation, eventId); + if (!doFeedback) { + return; + } + + // Generate feedback from interpreted event. + Feedback feedback = + feedbackComposer.composeFeedback(allContext, interpretation, /* logDepth= */ 0); + LogUtils.v(TAG, "feedback=%s", feedback); + + if (!feedback.isEmpty() && (accessibilityHintsManager != null)) { + accessibilityHintsManager.onScreenStateChanged(); + } + + // This will throw exception if has no any speaker. Default is SpeechController. Inherited class + // needs to override this function if inherited class has own speaker system. + checkSpeaker(); + + // Speak each feedback part. + @Nullable Announcement announcement = interpretation.getAnnouncement(); + boolean sourceIsVolumeControl = + (announcement != null) && announcement.isFromVolumeControlPanel(); + for (FeedbackPart feedbackPart : feedback.getParts()) { + speak( + feedbackPart.getSpeech(), + feedbackPart.getHint(), + eventId, + feedbackPart.getForceFeedbackEvenIfAudioPlaybackActive(), + feedbackPart.getForceFeedbackEvenIfMicrophoneActive(), + feedbackPart.getForceFeedbackEvenIfSsbActive(), + sourceIsVolumeControl); + } + } + + /** Allow overriding the condition to skip announcing the window-change event. */ + protected boolean allowAnnounce(AccessibilityEvent event) { + return true; + } + + /** Allow overriding handling of interpreted event, and return whether to compose speech. */ + protected boolean customHandle( + WindowEventInterpreter.EventInterpretation interpretation, @Nullable EventId eventId) { + return true; + } + + /** Inner class used for speech feedback generation. */ + @PureFunction + protected static class FeedbackComposer { + public FeedbackComposer() { + super(); + } + + /** Compose speech feedback for fully interpreted window event, statelessly. */ + public Feedback composeFeedback( + AllContext allContext, + WindowEventInterpreter.EventInterpretation interpretation, + final int logDepth) { + + logCompose(logDepth, "composeFeedback", "interpretation=%s", interpretation); + + Feedback feedback = new Feedback(); + // Compose feedback for announcement. + Announcement announcement = interpretation.getAnnouncement(); + if (announcement != null) { + logCompose(logDepth, "composeFeedback", "announcement"); + feedback.addPart( + new FeedbackPart(announcement.text()) + .earcon(true) + .forceFeedbackEvenIfAudioPlaybackActive(!announcement.isFromVolumeControlPanel()) + .forceFeedbackEvenIfMicrophoneActive(!announcement.isFromVolumeControlPanel()) + .forceFeedbackEvenIfSsbActive(announcement.isFromInputMethodEditor())); + } + + // Compose feedback for IME window + if (interpretation.getInputMethodChanged()) { + logCompose(logDepth, "composeFeedback", "input method"); + String inputMethodFeedback; + if (interpretation.getInputMethod().getId() == WINDOW_ID_NONE) { + inputMethodFeedback = allContext.getContext().getString(R.string.hide_keyboard_window); + } else { + inputMethodFeedback = + allContext + .getContext() + .getString( + R.string.show_keyboard_window, + interpretation.getInputMethod().getTitleForFeedback()); + } + feedback.addPart( + new FeedbackPart(inputMethodFeedback) + .earcon(true) + .forceFeedbackEvenIfAudioPlaybackActive(true) + .forceFeedbackEvenIfMicrophoneActive(true)); + } + + // Generate spoken feedback for main window changes. + CharSequence utterance = ""; + CharSequence hint = null; + if (interpretation.getMainWindowsChanged()) { + if (interpretation.getAccessibilityOverlay().getId() != WINDOW_ID_NONE) { + logCompose(logDepth, "composeFeedback", "accessibility overlay"); + // Case where accessibility overlay is shown. Use separated logic for accessibility + // overlay not to say out of split screen mode, e.g. accessibility overlay is shown when + // user is in split screen mode. + utterance = interpretation.getAccessibilityOverlay().getTitleForFeedback(); + } else if (interpretation.getWindowA().getId() != WINDOW_ID_NONE) { + if (interpretation.getWindowB().getId() == WINDOW_ID_NONE) { + // Single window mode. + logCompose(logDepth, "composeFeedback", "single window mode"); + utterance = interpretation.getWindowA().getTitleForFeedback(); + + if (allContext.getDeviceInfo().isArc()) { + logCompose(logDepth, "composeFeedback", "device is ARC"); + // If windowIdABefore was WINDOW_ID_NONE, we consider it as the focus comes into Arc + // window. + utterance = + formatAnnouncementForArc(allContext.getContext(), utterance, logDepth + 1); + + // When focus goes into Arc, append hint. + if (interpretation.getWindowA().getOldId() == WINDOW_ID_NONE) { + hint = getHintForArc(allContext, logDepth + 1); + } + } + } else { + // Split screen mode. + logCompose(logDepth, "composeFeedback", "split screen mode"); + int feedbackTemplate; + if (allContext.getDeviceInfo().isScreenOrientationLandscape()) { + if (allContext.getDeviceInfo().isScreenLayoutRTL()) { + + feedbackTemplate = R.string.template_split_screen_mode_landscape_rtl; + } else { + feedbackTemplate = R.string.template_split_screen_mode_landscape_ltr; + } + } else { + feedbackTemplate = R.string.template_split_screen_mode_portrait; + } + + utterance = + allContext + .getContext() + .getString( + feedbackTemplate, + interpretation.getWindowA().getTitleForFeedback(), + interpretation.getWindowB().getTitleForFeedback()); + } + } + } + + // Append picture-in-picture window description. + if ((interpretation.getMainWindowsChanged() || interpretation.getPicInPicChanged()) + && interpretation.getPicInPic().getId() != WINDOW_ID_NONE + && interpretation.getAccessibilityOverlay().getId() == WINDOW_ID_NONE) { + logCompose(logDepth, "composeFeedback", "picture-in-picture"); + CharSequence picInPicWindowTitle = interpretation.getPicInPic().getTitleForFeedback(); + if (picInPicWindowTitle == null) { + picInPicWindowTitle = ""; // Notify that pic-in-pic exists, even if title unavailable. + } + utterance = + appendTemplate( + allContext.getContext(), + utterance, + R.string.template_overlay_window, + picInPicWindowTitle, + logDepth + 1); + } + + // Custom the feedback if the composer needs. + feedback = customizeFeedback(allContext, feedback, interpretation, logDepth); + + // Return feedback. + if (!TextUtils.isEmpty(utterance)) { + feedback.addPart( + new FeedbackPart(utterance) + .hint(hint) + .clearQueue(true) + .forceFeedbackEvenIfAudioPlaybackActive(true) + .forceFeedbackEvenIfMicrophoneActive(true)); + } + feedback.setReadOnly(); + return feedback; + } + + private CharSequence appendTemplate( + Context context, + @Nullable CharSequence text, + int templateResId, + CharSequence templateArg, + final int logDepth) { + logCompose(logDepth, "appendTemplate", "templateArg=%s", templateArg); + CharSequence templatedText = context.getString(templateResId, templateArg); + SpannableStringBuilder builder = new SpannableStringBuilder((text == null) ? "" : text); + StringBuilderUtils.appendWithSeparator(builder, templatedText); + return builder; + } + + /** Returns the announcement that should be spoken for an Arc window. */ + protected @Nullable CharSequence formatAnnouncementForArc( + Context context, @Nullable CharSequence title, final int logDepth) { + return title; + } + + /** Returns the hint that should be spoken for Arc. */ + protected CharSequence getHintForArc(AllContext allContext, final int logDepth) { + return ""; + } + + /** Returns the customized feedback */ + protected Feedback customizeFeedback( + AllContext allContext, + Feedback feedback, + WindowEventInterpreter.EventInterpretation interpretation, + final int logDepth) { + return feedback; + } + } + + // ///////////////////////////////////////////////////////////////////////////////////// + // Inner classes for feedback generation context + + /** Wrapper around various context data for feedback generation. */ + public static class AllContext { + private final DeviceInfo deviceInfo; + private final Context context; + private final @Nullable UserPreferences preferences; + + public AllContext( + DeviceInfo deviceInfoArg, Context contextArg, @Nullable UserPreferences preferencesArg) { + deviceInfo = deviceInfoArg; + context = contextArg; + preferences = preferencesArg; + } + + public DeviceInfo getDeviceInfo() { + return deviceInfo; + } + + public Context getContext() { + return context; + } + + public @Nullable UserPreferences getUserPreferences() { + return preferences; + } + } + + /** A source of data about the device running talkback. */ + protected class DeviceInfo { + public boolean isArc() { + return isArc; + } + + public boolean isSplitScreenModeAvailable() { + return getInterpreter().isSplitScreenModeAvailable(); + } + + public boolean isScreenOrientationLandscape() { + return isScreenOrientationLandscape; + } + + public boolean isScreenLayoutRTL() { + return WindowUtils.isScreenLayoutRTL(service); + } + }; + + /** Read-only interface to user preferences. */ + public interface UserPreferences { + @Nullable + String keyComboResIdToString(int keyComboId); + } + + // ///////////////////////////////////////////////////////////////////////////////////// + // Inner class: speech output + + /** Data container specifying speech, earcons, feedback timing, etc. */ + protected static class Feedback extends ReadOnly { + private final List parts = new ArrayList<>(); + + public void addPart(FeedbackPart part) { + checkIsWritable(); + parts.add(part); + } + + public List getParts() { + return isWritable() ? parts : Collections.unmodifiableList(parts); + } + + public boolean isEmpty() { + return parts.isEmpty(); + } + + @Override + public String toString() { + StringBuilder strings = new StringBuilder(); + for (FeedbackPart part : parts) { + strings.append("[" + part + "] "); + } + return strings.toString(); + } + } + + /** Data container used by Feedback, with a builder-style interface. */ + protected static class FeedbackPart { + private final CharSequence speech; + private @Nullable CharSequence hint; + private boolean playEarcon = false; + private boolean clearQueue = false; + // Follows REFERTO. + private boolean forceFeedbackEvenIfAudioPlaybackActive = false; + private boolean forceFeedbackEvenIfMicrophoneActive = false; + private boolean forceFeedbackEvenIfSsbActive = false; + + public FeedbackPart(CharSequence speech) { + this.speech = speech; + } + + public FeedbackPart hint(@Nullable CharSequence hint) { + this.hint = hint; + return this; + } + + public FeedbackPart earcon(boolean playEarcon) { + this.playEarcon = playEarcon; + return this; + } + + public FeedbackPart clearQueue(boolean clear) { + clearQueue = clear; + return this; + } + + public FeedbackPart forceFeedbackEvenIfAudioPlaybackActive(boolean force) { + forceFeedbackEvenIfAudioPlaybackActive = force; + return this; + } + + public FeedbackPart forceFeedbackEvenIfMicrophoneActive(boolean force) { + forceFeedbackEvenIfMicrophoneActive = force; + return this; + } + + public FeedbackPart forceFeedbackEvenIfSsbActive(boolean force) { + forceFeedbackEvenIfSsbActive = force; + return this; + } + + public CharSequence getSpeech() { + return speech; + } + + public @Nullable CharSequence getHint() { + return hint; + } + + public boolean getPlayEarcon() { + return playEarcon; + } + + public boolean getClearQueue() { + return clearQueue; + } + + public boolean getForceFeedbackEvenIfAudioPlaybackActive() { + return forceFeedbackEvenIfAudioPlaybackActive; + } + + public boolean getForceFeedbackEvenIfMicrophoneActive() { + return forceFeedbackEvenIfMicrophoneActive; + } + + public boolean getForceFeedbackEvenIfSsbActive() { + return forceFeedbackEvenIfSsbActive; + } + + @Override + public String toString() { + return StringBuilderUtils.joinFields( + formatString(speech).toString(), + (hint == null ? "" : " hint:" + formatString(hint)), + StringBuilderUtils.optionalTag(" PlayEarcon", playEarcon), + StringBuilderUtils.optionalTag(" ClearQueue", clearQueue), + StringBuilderUtils.optionalTag( + "forceFeedbackEvenIfAudioPlaybackActive", forceFeedbackEvenIfAudioPlaybackActive), + StringBuilderUtils.optionalTag( + " forceFeedbackEvenIfMicrophoneActive", forceFeedbackEvenIfMicrophoneActive), + StringBuilderUtils.optionalTag( + " forceFeedbackEvenIfSsbActive", forceFeedbackEvenIfSsbActive)); + } + } + + // ///////////////////////////////////////////////////////////////////////////////////// + // Logging functions + + private static CharSequence formatString(CharSequence text) { + return (text == null) ? "null" : String.format("\"%s\"", text); + } + + @FormatMethod + protected static void logCompose( + final int depth, String methodName, @FormatString String format, Object... args) { + + // Compute indentation. + char[] indentChars = new char[depth * 2]; + Arrays.fill(indentChars, ' '); + String indent = new String(indentChars); + + // Log message. + LogUtils.v(TAG, "%s%s() %s", indent, methodName, String.format(format, args)); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/feedbackpolicy/ScrollFeedbackManager.java b/utils/src/main/java/com/google/android/accessibility/utils/feedbackpolicy/ScrollFeedbackManager.java new file mode 100644 index 0000000..daa5852 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/feedbackpolicy/ScrollFeedbackManager.java @@ -0,0 +1,432 @@ +/* + * Copyright (C) 2018 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.google.android.accessibility.utils.feedbackpolicy; + +import android.content.Context; +import android.os.Bundle; +import android.os.Message; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityRecord; +import androidx.annotation.VisibleForTesting; +import androidx.core.view.accessibility.AccessibilityEventCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.AccessibilityEventListener; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.accessibility.utils.Performance; +import com.google.android.accessibility.utils.Performance.EventIdAnd; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.Role; +import com.google.android.accessibility.utils.StringBuilderUtils; +import com.google.android.accessibility.utils.WeakReferenceHandler; +import com.google.android.accessibility.utils.output.FailoverTextToSpeech.SpeechParam; +import com.google.android.accessibility.utils.output.FeedbackItem; +import com.google.android.accessibility.utils.output.SpeechController; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.HashMap; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Manages scroll position feedback. If a VIEW_SCROLLED event passes through this processor and no + * further events are received for a specified duration, a "scroll position" message is spoken. + */ +public class ScrollFeedbackManager implements AccessibilityEventListener { + + private static final String TAG = "ScrollFeedbackManager"; + + /** Default pitch adjustment for text event feedback. */ + private static final float DEFAULT_PITCH = 1.2f; + + /** Default rate adjustment for text event feedback. */ + private static final float DEFAULT_RATE = 1.0f; + + /** Delay before reading a scroll position notification. */ + @VisibleForTesting public static final long DELAY_SCROLL_FEEDBACK = 1000; + + /** Delay before reading a page position notification. */ + @VisibleForTesting public static final long DELAY_PAGE_FEEDBACK = 500; + + /** Event types that are handled by ScrollPositionInterpreter. */ + private static final int MASK_EVENTS_HANDLED_BY_PROCESSOR_SCROLL_POSITION = + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED + | AccessibilityEvent.TYPE_VIEW_SCROLLED + | AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED + | AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED + | AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED; + + private final HashMap cachedFromValues = new HashMap<>(); + private final HashMap cachedItemCounts = new HashMap<>(); + private final Bundle speechParams = new Bundle(); + private ScrollPositionHandler handler; + + private final Context context; + private final SpeechController speechController; + + public ScrollFeedbackManager(SpeechController speechController, Context context) { + if (speechController == null) { + throw new IllegalStateException(); + } + this.context = context; + this.speechController = speechController; + speechParams.putFloat(SpeechParam.PITCH, DEFAULT_PITCH); + speechParams.putFloat(SpeechParam.RATE, DEFAULT_RATE); + } + + private ScrollPositionHandler getHandler() { + // Handler requires an initialized this-argument, so create handler on-demand. + if (handler == null) { + handler = new ScrollPositionHandler(this); + } + return handler; + } + + @Override + public int getEventTypes() { + return MASK_EVENTS_HANDLED_BY_PROCESSOR_SCROLL_POSITION; + } + + @Override + public void onAccessibilityEvent( + AccessibilityEvent event, Performance.@Nullable EventId eventId) { + if (shouldIgnoreEvent(event)) { + return; + } + + switch (event.getEventType()) { + case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED: + // Window state changes clear the cache. + cachedFromValues.clear(); + cachedItemCounts.clear(); + getHandler().cancelScrollFeedback(); + break; + case AccessibilityEvent.TYPE_VIEW_SCROLLED: + case AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED: + getHandler().postScrollFeedback(event, eventId); + break; + default: // fall out + } + } + + private boolean shouldIgnoreEvent(AccessibilityEvent event) { + switch (event.getEventType()) { + case AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED: + case AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED: + return true; + case AccessibilityEventCompat.TYPE_WINDOW_CONTENT_CHANGED: + case AccessibilityEventCompat.TYPE_VIEW_SCROLLED: + return shouldIgnoreWindowContentChangedOrViewScrolledEvent(event); + default: + return false; + } + } + + /** + * Returns whether a WINDOW_CONTENT_CHANGED or VIEW_SCROLLED event should be ignored when + * generating scroll position feedback. + * + * @param event The event from which information about the scroll position will be retrieved + * @return {@code true} if the event should be ignored + */ + protected boolean shouldIgnoreWindowContentChangedOrViewScrolledEvent(AccessibilityEvent event) { + return isDuplicateScrollEventOrAutoScroll(event); + } + + /** + * Returns whether the event is a duplicate of the previous event, or the event is triggered by + * auto-scroll. + * + * @param event The event from which information about the scroll position will be retrieved + * @return {@code true} if the event is a duplicate of the previous event, or triggered by + * auto-scroll + */ + protected boolean isDuplicateScrollEventOrAutoScroll(AccessibilityEvent event) { + int eventType = event.getEventType(); + if ((eventType != AccessibilityEventCompat.TYPE_WINDOW_CONTENT_CHANGED) + && (eventType != AccessibilityEventCompat.TYPE_VIEW_SCROLLED)) { + return false; + } + + final int fromIndex = event.getFromIndex() + 1; + final int itemCount = event.getItemCount(); + if (itemCount <= 0 || fromIndex <= 0) { + return true; + } + + EventId eventId; + try { + eventId = new EventId(event); + } catch (Exception e) { + return true; + } + + final Integer cachedFromIndex = cachedFromValues.get(eventId); + final Integer cachedItemCount = cachedItemCounts.get(eventId); + + if ((cachedFromIndex != null) + && (cachedFromIndex == fromIndex) + && (cachedItemCount != null) + && (cachedItemCount == itemCount)) { + // The from index hasn't changed, which means the event is coming + // from a re-layout or resize and should not be spoken. + return true; + } + + // The behavior of put() for an existing key is unspecified, so we can't + // recycle the old or new key nodes. + cachedFromValues.put(eventId, fromIndex); + cachedItemCounts.put(eventId, itemCount); + + return false; + } + + /** + * Given an {@link AccessibilityEvent}, speaks a scroll position. + * + * @param event The source event. + */ + private void handleScrollFeedback( + AccessibilityEvent event, Performance.@Nullable EventId eventId) { + final CharSequence text; + final int flags; + AccessibilityNodeInfo source = event.getSource(); + + boolean isVisibleToUser = source != null && source.isVisibleToUser(); + + if (Role.getRole(source) == Role.ROLE_PAGER) { + text = getDescriptionForPageEvent(event, source); + flags = FeedbackItem.FLAG_FORCE_FEEDBACK; + } else { + text = getDescriptionForScrollEvent(event); + flags = 0; + } + + if (source != null) { + source.recycle(); + } + + if (TextUtils.isEmpty(text)) { + return; + } + + // don't pronounce non-visible nodes + if (!isVisibleToUser) { + return; + } + + // Use QUEUE mode so that we don't interrupt more important messages. + speechController.speak( + text, /* Text */ + SpeechController.QUEUE_MODE_QUEUE, /* QueueMode */ + flags, /* Flags */ + speechParams, /* SpeechParams */ + eventId); + } + + private @Nullable CharSequence getDescriptionForScrollEvent(AccessibilityEvent event) { + // If the from index or item count are invalid, don't announce anything. + final int fromIndex = (event.getFromIndex() + 1); + final int itemCount = event.getItemCount(); + if ((fromIndex <= 0) || (itemCount <= 0)) { + return null; + } + + // If the to and from indices are the same, or if the to index is + // invalid, only announce the item at the from index. + final int toIndex = event.getToIndex() + 1; + if ((fromIndex == toIndex) || (toIndex <= 0) || (toIndex > itemCount)) { + return context.getString(R.string.template_scroll_from_count, fromIndex, itemCount); + } + + // Announce the range of visible items. + return context.getString(R.string.template_scroll_from_to_count, fromIndex, toIndex, itemCount); + } + + private @Nullable CharSequence getDescriptionForPageEvent( + AccessibilityEvent event, AccessibilityNodeInfo source) { + final int fromIndex = (event.getFromIndex() + 1); + final int itemCount = event.getItemCount(); + if ((fromIndex <= 0) || (itemCount <= 0)) { + return null; + } + + CharSequence pageTitle = getSelectedPageTitle(source); + if (!TextUtils.isEmpty(pageTitle)) { + CharSequence count = + context.getString(R.string.template_viewpager_index_count_short, fromIndex, itemCount); + + SpannableStringBuilder output = new SpannableStringBuilder(); + StringBuilderUtils.appendWithSeparator(output, pageTitle, count); + return output; + } + + return context.getString(R.string.template_viewpager_index_count, fromIndex, itemCount); + } + + private static @Nullable CharSequence getSelectedPageTitle(AccessibilityNodeInfo node) { + // We need to refresh() after the scroll to get an accurate page title + if (node == null) { + return null; + } + + AccessibilityNodeInfoCompat nodeCompat = AccessibilityNodeInfoUtils.toCompat(node); + nodeCompat.refresh(); + + int numChildren = nodeCompat.getChildCount(); // Not the number of pages! + CharSequence title = null; + for (int i = 0; i < numChildren; ++i) { + AccessibilityNodeInfoCompat child = nodeCompat.getChild(i); + if (child != null) { + try { + if (child.isVisibleToUser()) { + if (title == null) { + // Try to roughly match RulePagerPage, which uses getNodeText + // (but completely matching all the time is not critical). + title = AccessibilityNodeInfoUtils.getNodeText(child); + } else { + // Multiple visible children, abort. + return null; + } + } + } finally { + child.recycle(); + } + } + } + + return title; + } + + /** A handler for initializing and canceling feedback for scrolling. */ + private static class ScrollPositionHandler extends WeakReferenceHandler { + /** Message identifier for a scroll position notification. */ + private static final int SCROLL_FEEDBACK = 1; + + public ScrollPositionHandler(ScrollFeedbackManager parent) { + super(parent); + } + + @Override + public void handleMessage(Message msg, ScrollFeedbackManager parent) { + @SuppressWarnings("unchecked") + final EventIdAnd eventAndId = (EventIdAnd) msg.obj; + final AccessibilityEvent event = eventAndId.object; + switch (msg.what) { + case SCROLL_FEEDBACK: + parent.handleScrollFeedback(event, eventAndId.eventId); + break; + default: // fall out + } + + event.recycle(); + } + + /** Posts the delayed scroll position feedback. Call this for every VIEW_SCROLLED event. */ + private void postScrollFeedback( + AccessibilityEvent event, Performance.@Nullable EventId eventId) { + cancelScrollFeedback(); + AccessibilityEvent eventClone; + try { + eventClone = AccessibilityEvent.obtain(event); + } catch (NullPointerException e) { + LogUtils.i( + TAG, + "A NullPointerException is expected to be thrown in the Robolectric tests when we try" + + " to create a clone of the mocking AccessibilityEvent instance. This exception" + + " should never occur when the program is running on actual Android devices."); + eventClone = event; + } + + final EventIdAnd eventAndId = + new EventIdAnd(eventClone, eventId); + final Message msg = obtainMessage(SCROLL_FEEDBACK, eventAndId); + + AccessibilityNodeInfo source = event.getSource(); + if (Role.getRole(source) == Role.ROLE_PAGER) { + sendMessageDelayed(msg, DELAY_PAGE_FEEDBACK); + } else { + sendMessageDelayed(msg, DELAY_SCROLL_FEEDBACK); + } + if (source != null) { + source.recycle(); + } + } + + /** Removes any pending scroll position feedback. Call this for every event. */ + private void cancelScrollFeedback() { + removeMessages(SCROLL_FEEDBACK); + } + } + + private static class EventId { + public long nodeId; + public int windowId; + private final int hashcode; + + private static Method getSourceNodeIdMethod; + private static final String TAG = "EventId"; + + static { + try { + getSourceNodeIdMethod = AccessibilityRecord.class.getDeclaredMethod("getSourceNodeId"); + getSourceNodeIdMethod.setAccessible(true); + } catch (NoSuchMethodException e) { + LogUtils.d(TAG, "Error setting up fields: " + e.toString()); + e.printStackTrace(); + } + } + + public EventId(long nodeId, int windowId) { + this.nodeId = nodeId; + this.windowId = windowId; + this.hashcode = toHashcode(nodeId, windowId); + } + + private static int toHashcode(long nodeId, int windowId) { + return (int) (nodeId ^ (nodeId >>> 32)) + windowId * 7; + } + + public EventId(AccessibilityEvent event) + throws InvocationTargetException, IllegalAccessException { + @Nullable Object nodeId = getSourceNodeIdMethod.invoke(event); + this.nodeId = (nodeId == null) ? 0 : (long) nodeId; + this.windowId = event.getWindowId(); + this.hashcode = toHashcode(this.nodeId, this.windowId); + } + + @Override + public boolean equals(@Nullable Object other) { + if (other == this) { + return true; + } + if (!(other instanceof EventId)) { + return false; + } + + EventId otherId = (EventId) other; + return windowId == otherId.windowId && nodeId == otherId.nodeId; + } + + @Override + public int hashCode() { + return hashcode; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcher.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcher.java new file mode 100644 index 0000000..26b98ce --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcher.java @@ -0,0 +1,454 @@ +/* + * Copyright (C) 2021 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.google.android.accessibility.utils.gestures; + +import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_DOUBLE_TAP; +import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD; +import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SINGLE_TAP; +import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_DOWN; +import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_LEFT; +import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_RIGHT; +import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_UP; +import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_TRIPLE_TAP; +import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD; +import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_DOUBLE_TAP; +import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_DOUBLE_TAP_AND_HOLD; +import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SINGLE_TAP; +import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD; +import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_DOWN; +import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_LEFT; +import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_RIGHT; +import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_UP; +import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_TRIPLE_TAP; +import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD; +import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_DOUBLE_TAP; +import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_DOUBLE_TAP_AND_HOLD; +import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SINGLE_TAP; +import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_DOWN; +import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_LEFT; +import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_RIGHT; +import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_UP; +import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_TRIPLE_TAP; +import static android.accessibilityservice.AccessibilityService.GESTURE_DOUBLE_TAP; +import static android.accessibilityservice.AccessibilityService.GESTURE_DOUBLE_TAP_AND_HOLD; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_LEFT; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_RIGHT; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_UP; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_DOWN; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_RIGHT; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_UP; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_DOWN; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_LEFT; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_UP; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_DOWN; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_LEFT; +import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_RIGHT; + +import android.os.Build; +import android.os.Handler; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * This class describes a common base for gesture matchers. A gesture matcher checks a series of + * motion events against a single gesture. Coordinating the individual gesture matchers is done by + * the GestureManifold. To create a new Gesture, extend this class and override the onDown, onMove, + * onUp, etc methods as necessary. If you don't override a method your matcher will do nothing in + * response to that type of event. Finally, be sure to give your gesture a name by overriding + * getGestureName(). + * + * @hide + */ +@RequiresApi(Build.VERSION_CODES.S) +public abstract class GestureMatcher { + // Potential states for this individual gesture matcher. + /** + * In STATE_CLEAR, this matcher is accepting new motion events but has not formally signaled that + * there is enough data to judge that a gesture has started. + */ + public static final int STATE_CLEAR = 0; + /** + * In STATE_GESTURE_STARTED, this matcher continues to accept motion events and it has signaled to + * the listener that what looks like the specified gesture has started. + */ + public static final int STATE_GESTURE_STARTED = 1; + /** + * In STATE_GESTURE_COMPLETED, this matcher has successfully matched the specified gesture. and + * will not accept motion events until it is cleared. + */ + public static final int STATE_GESTURE_COMPLETED = 2; + /** + * In STATE_GESTURE_CANCELED, this matcher will not accept new motion events because it is + * impossible that this set of motion events will match the specified gesture. + */ + public static final int STATE_GESTURE_CANCELED = 3; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATE_CLEAR, STATE_GESTURE_STARTED, STATE_GESTURE_COMPLETED, STATE_GESTURE_CANCELED}) + @interface State {} + + @State private int state = STATE_CLEAR; + + @IntDef({ + GESTURE_2_FINGER_SINGLE_TAP, + GESTURE_2_FINGER_DOUBLE_TAP, + GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD, + GESTURE_2_FINGER_TRIPLE_TAP, + GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD, + GESTURE_3_FINGER_SINGLE_TAP, + GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD, + GESTURE_3_FINGER_DOUBLE_TAP, + GESTURE_3_FINGER_DOUBLE_TAP_AND_HOLD, + GESTURE_3_FINGER_TRIPLE_TAP, + GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD, + GESTURE_DOUBLE_TAP, + GESTURE_DOUBLE_TAP_AND_HOLD, + GESTURE_SWIPE_UP, + GESTURE_SWIPE_UP_AND_LEFT, + GESTURE_SWIPE_UP_AND_DOWN, + GESTURE_SWIPE_UP_AND_RIGHT, + GESTURE_SWIPE_DOWN, + GESTURE_SWIPE_DOWN_AND_LEFT, + GESTURE_SWIPE_DOWN_AND_UP, + GESTURE_SWIPE_DOWN_AND_RIGHT, + GESTURE_SWIPE_LEFT, + GESTURE_SWIPE_LEFT_AND_UP, + GESTURE_SWIPE_LEFT_AND_RIGHT, + GESTURE_SWIPE_LEFT_AND_DOWN, + GESTURE_SWIPE_RIGHT, + GESTURE_SWIPE_RIGHT_AND_UP, + GESTURE_SWIPE_RIGHT_AND_LEFT, + GESTURE_SWIPE_RIGHT_AND_DOWN, + GESTURE_2_FINGER_SWIPE_DOWN, + GESTURE_2_FINGER_SWIPE_LEFT, + GESTURE_2_FINGER_SWIPE_RIGHT, + GESTURE_2_FINGER_SWIPE_UP, + GESTURE_3_FINGER_SWIPE_DOWN, + GESTURE_3_FINGER_SWIPE_LEFT, + GESTURE_3_FINGER_SWIPE_RIGHT, + GESTURE_3_FINGER_SWIPE_UP, + GESTURE_4_FINGER_DOUBLE_TAP, + GESTURE_4_FINGER_DOUBLE_TAP_AND_HOLD, + GESTURE_4_FINGER_SINGLE_TAP, + GESTURE_4_FINGER_SWIPE_DOWN, + GESTURE_4_FINGER_SWIPE_LEFT, + GESTURE_4_FINGER_SWIPE_RIGHT, + GESTURE_4_FINGER_SWIPE_UP, + GESTURE_4_FINGER_TRIPLE_TAP + }) + @Retention(RetentionPolicy.SOURCE) + @interface GestureId {} + + // The id number of the gesture that gets passed to accessibility services. + @GestureId private final int gestureId; + // handler for asynchronous operations like timeouts + private final Handler handler; + + private StateChangeListener listener = null; + + // Use this to transition to new states after a delay. + // e.g. cancel or complete after some timeout. + // Convenience functions for tapTimeout and doubleTapTimeout are already defined here. + protected final DelayedTransition delayedTransition; + + protected GestureMatcher(int gestureId, Handler handler, StateChangeListener listener) { + this.gestureId = gestureId; + this.handler = handler; + delayedTransition = new DelayedTransition(); + this.listener = listener; + } + + /** + * Resets all state information for this matcher. Subclasses that include their own state + * information should override this method to reset their own state information and call + * super.clear(). + */ + public void clear() { + state = STATE_CLEAR; + cancelPendingTransitions(); + } + + public final int getState() { + return state; + } + + /** + * Transitions to a new state and notifies any listeners. Note that any pending transitions are + * canceled. + */ + private void setState(@State int state, MotionEvent event) { + this.state = state; + cancelPendingTransitions(); + if (listener != null) { + listener.onStateChanged(gestureId, state, event); + } + } + + /** Indicates that there is evidence to suggest that this gesture has started. */ + protected final void startGesture(MotionEvent event) { + setState(STATE_GESTURE_STARTED, event); + } + + /** Indicates this stream of motion events can no longer match this gesture. */ + protected final void cancelGesture(MotionEvent event) { + setState(STATE_GESTURE_CANCELED, event); + } + + /** Indicates this gesture is completed. */ + protected final void completeGesture(MotionEvent event) { + setState(STATE_GESTURE_COMPLETED, event); + } + + public final void setListener(@NonNull StateChangeListener listener) { + this.listener = listener; + } + + public int getGestureId() { + return gestureId; + } + + /** + * Process a motion event and attempt to match it to this gesture. + * + * @param event the event as passed in from the event stream. + * @return the state of this matcher. + */ + public final int onMotionEvent(MotionEvent event) { + if (state == STATE_GESTURE_CANCELED || state == STATE_GESTURE_COMPLETED) { + return state; + } + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + onDown(event); + break; + case MotionEvent.ACTION_POINTER_DOWN: + onPointerDown(event); + break; + case MotionEvent.ACTION_MOVE: + onMove(event); + break; + case MotionEvent.ACTION_POINTER_UP: + onPointerUp(event); + break; + case MotionEvent.ACTION_UP: + onUp(event); + break; + default: + // Cancel because of invalid event. + setState(STATE_GESTURE_CANCELED, event); + break; + } + return state; + } + + /** + * Matchers override this method to respond to ACTION_DOWN events. ACTION_DOWN events indicate the + * first finger has touched the screen. If not overridden the default response is to do nothing. + */ + protected void onDown(MotionEvent event) {} + + /** + * Matchers override this method to respond to ACTION_POINTER_DOWN events. ACTION_POINTER_DOWN + * indicates that more than one finger has touched the screen. If not overridden the default + * response is to do nothing. + * + * @param event the event as passed in from the event stream. + */ + protected void onPointerDown(MotionEvent event) {} + + /** + * Matchers override this method to respond to ACTION_MOVE events. ACTION_MOVE indicates that one + * or fingers has moved. If not overridden the default response is to do nothing. + * + * @param event the event as passed in from the event stream. + */ + protected void onMove(MotionEvent event) {} + + /** + * Matchers override this method to respond to ACTION_POINTER_UP events. ACTION_POINTER_UP + * indicates that a finger has lifted from the screen but at least one finger continues to touch + * the screen. If not overridden the default response is to do nothing. + * + * @param event the event as passed in from the event stream. + */ + protected void onPointerUp(MotionEvent event) {} + + /** + * Matchers override this method to respond to ACTION_UP events. ACTION_UP indicates that there + * are no more fingers touching the screen. If not overridden the default response is to do + * nothing. + * + * @param event the event as passed in from the event stream. + */ + protected void onUp(MotionEvent event) {} + + /** Cancels this matcher after the tap timeout. Any pending state transitions are removed. */ + protected void cancelAfterTapTimeout(MotionEvent event) { + cancelAfter(ViewConfiguration.getTapTimeout(), event); + } + + /** Cancels this matcher after the double tap timeout. Any pending cancelations are removed. */ + protected final void cancelAfterDoubleTapTimeout(MotionEvent event) { + cancelAfter(ViewConfiguration.getDoubleTapTimeout(), event); + } + + /** + * Cancels this matcher after the specified timeout. Any pending cancelations are removed. Used to + * prevent this matcher from accepting motion events until it is cleared. + */ + protected final void cancelAfter(long timeout, MotionEvent event) { + delayedTransition.cancel(); + delayedTransition.post(STATE_GESTURE_CANCELED, timeout, event); + } + + /** Cancels any delayed transitions between states scheduled for this matcher. */ + protected final void cancelPendingTransitions() { + delayedTransition.cancel(); + } + + /** + * Signals that this gesture has been completed after the tap timeout has expired. Used to ensure + * that there is no conflict with another gesture or for gestures that explicitly require a hold. + */ + protected final void completeAfterLongPressTimeout(MotionEvent event) { + completeAfter(ViewConfiguration.getLongPressTimeout(), event); + } + + /** + * Signals that this gesture has been completed after the tap timeout has expired. Used to ensure + * that there is no conflict with another gesture or for gestures that explicitly require a hold. + */ + protected final void completeAfterTapTimeout(MotionEvent event) { + completeAfter(ViewConfiguration.getTapTimeout(), event); + } + + /** + * Signals that this gesture has been completed after the specified timeout has expired. Used to + * ensure that there is no conflict with another gesture or for gestures that explicitly require a + * hold. + */ + protected final void completeAfter(long timeout, MotionEvent event) { + delayedTransition.cancel(); + delayedTransition.post(STATE_GESTURE_COMPLETED, timeout, event); + } + + /** + * Signals that this gesture has been completed after the double-tap timeout has expired. Used to + * ensure that there is no conflict with another gesture or for gestures that explicitly require a + * hold. + */ + protected final void completeAfterDoubleTapTimeout(MotionEvent event) { + completeAfter(ViewConfiguration.getDoubleTapTimeout(), event); + } + + static String getStateSymbolicName(@State int state) { + switch (state) { + case STATE_CLEAR: + return "STATE_CLEAR"; + case STATE_GESTURE_STARTED: + return "STATE_GESTURE_STARTED"; + case STATE_GESTURE_COMPLETED: + return "STATE_GESTURE_COMPLETED"; + case STATE_GESTURE_CANCELED: + return "STATE_GESTURE_CANCELED"; + default: + return "Unknown state: " + state; + } + } + + /** + * Returns a readable name for this matcher that can be displayed to the user and in system logs. + */ + protected abstract String getGestureName(); + + /** + * Returns a String representation of this matcher. Each matcher can override this method to add + * extra state information to the string representation. + */ + @Override + public String toString() { + return getGestureName() + ":" + getStateSymbolicName(state); + } + + /** This class allows matchers to transition between states on a delay. */ + protected final class DelayedTransition implements Runnable { + + private static final String LOG_TAG = "GestureMatcher.DelayedTransition"; + int targetState; + MotionEvent event; + + public void cancel() { + // Avoid meaningless debug messages. + if (isPending()) { + LogUtils.v( + LOG_TAG, + "%s: canceling delayed transition to %s", + getGestureName(), + getStateSymbolicName(targetState)); + } + handler.removeCallbacks(this); + } + + public void post(int state, long delay, MotionEvent event) { + this.targetState = state; + this.event = event; + handler.postDelayed(this, delay); + LogUtils.v( + LOG_TAG, + "%s: posting delayed transition to %s", + getGestureName(), + getStateSymbolicName(targetState)); + } + + public boolean isPending() { + return handler.hasCallbacks(this); + } + + public void forceSendAndRemove() { + if (isPending()) { + run(); + cancel(); + } + } + + @Override + public void run() { + LogUtils.v( + LOG_TAG, + "%s: executing delayed transition to %s", + getGestureName(), + getStateSymbolicName(targetState)); + setState(targetState, event); + } + } + + /** Interface to allow a class to listen for state changes in a specific gesture matcher */ + public interface StateChangeListener { + + void onStateChanged(int gestureId, int state, MotionEvent event); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureUtils.java new file mode 100644 index 0000000..9c51174 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureUtils.java @@ -0,0 +1,131 @@ +package com.google.android.accessibility.utils.gestures; + +import android.graphics.PointF; +import android.view.MotionEvent; + +/** Some helper functions for gesture detection. */ +public final class GestureUtils { + + public static final int MM_PER_CM = 10; + public static final float CM_PER_INCH = 2.54f; + + private GestureUtils() { + /* cannot be instantiated */ + } + + public static boolean isMultiTap( + MotionEvent firstUp, MotionEvent secondUp, int multiTapTimeSlop, int multiTapDistanceSlop) { + if (firstUp == null || secondUp == null) { + return false; + } + return eventsWithinTimeAndDistanceSlop( + firstUp, secondUp, multiTapTimeSlop, multiTapDistanceSlop); + } + + private static boolean eventsWithinTimeAndDistanceSlop( + MotionEvent first, MotionEvent second, int timeout, int distance) { + if (isTimedOut(first, second, timeout)) { + return false; + } + final double deltaMove = distance(first, second); + if (deltaMove >= distance) { + return false; + } + return true; + } + + public static double distance(MotionEvent first, MotionEvent second) { + return dist(first.getX(), first.getY(), second.getX(), second.getY()); + } + + /** + * Returns the minimum distance between {@code pointerDown} and each pointer of {@link + * MotionEvent}. + * + * @param pointerDown The action pointer location of the {@link MotionEvent} with {@link + * MotionEvent#ACTION_DOWN} or {@link MotionEvent#ACTION_POINTER_DOWN} + * @param moveEvent The {@link MotionEvent} with {@link MotionEvent#ACTION_MOVE} + * @return the movement of the pointer. + */ + public static double distanceClosestPointerToPoint(PointF pointerDown, MotionEvent moveEvent) { + float movement = Float.MAX_VALUE; + for (int i = 0; i < moveEvent.getPointerCount(); i++) { + final float moveDelta = + dist(pointerDown.x, pointerDown.y, moveEvent.getX(i), moveEvent.getY(i)); + if (movement > moveDelta) { + movement = moveDelta; + } + } + return movement; + } + + public static boolean isTimedOut(MotionEvent firstUp, MotionEvent secondUp, int timeout) { + final long deltaTime = secondUp.getEventTime() - firstUp.getEventTime(); + return (deltaTime >= timeout); + } + + /** + * Determines whether a two pointer gesture is a dragging one. + * + * @return True if the gesture is a dragging one. + */ + public static boolean isDraggingGesture( + float firstPtrDownX, + float firstPtrDownY, + float secondPtrDownX, + float secondPtrDownY, + float firstPtrX, + float firstPtrY, + float secondPtrX, + float secondPtrY, + float maxDraggingAngleCos) { + + // Check if the pointers are moving in the same direction. + final float firstDeltaX = firstPtrX - firstPtrDownX; + final float firstDeltaY = firstPtrY - firstPtrDownY; + + if (firstDeltaX == 0 && firstDeltaY == 0) { + return true; + } + + final float firstMagnitude = (float) Math.hypot(firstDeltaX, firstDeltaY); + final float firstXNormalized = + (firstMagnitude > 0) ? firstDeltaX / firstMagnitude : firstDeltaX; + final float firstYNormalized = + (firstMagnitude > 0) ? firstDeltaY / firstMagnitude : firstDeltaY; + + final float secondDeltaX = secondPtrX - secondPtrDownX; + final float secondDeltaY = secondPtrY - secondPtrDownY; + + if (secondDeltaX == 0 && secondDeltaY == 0) { + return true; + } + + final float secondMagnitude = (float) Math.hypot(secondDeltaX, secondDeltaY); + final float secondXNormalized = + (secondMagnitude > 0) ? secondDeltaX / secondMagnitude : secondDeltaX; + final float secondYNormalized = + (secondMagnitude > 0) ? secondDeltaY / secondMagnitude : secondDeltaY; + + final float angleCos = + firstXNormalized * secondXNormalized + firstYNormalized * secondYNormalized; + + if (angleCos < maxDraggingAngleCos) { + return false; + } + + return true; + } + + /** Gets the index of the pointer that went up or down from a motion event. */ + public static int getActionIndex(MotionEvent event) { + return (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) + >> MotionEvent.ACTION_POINTER_INDEX_SHIFT; + } + + public static float dist(float x1, float y1, float x2, float y2) { + final float x = (x2 - x1); + final float y = (y2 - y1); + return (float) Math.hypot(x, y); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTap.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTap.java new file mode 100644 index 0000000..980d18c --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTap.java @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2020 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.google.android.accessibility.utils.gestures; + +import android.content.Context; +import android.graphics.PointF; +import android.os.Handler; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.Arrays; + +/** + * This class matches multi-finger multi-tap gestures. The number of fingers and the number of taps + * for each instance is specified in the constructor. + */ +class MultiFingerMultiTap extends GestureMatcher { + + // The target number of taps. + final int mTargetTapCount; + // The target number of fingers. + final int mTargetFingerCount; + // The acceptable distance between two taps of a finger. + private int doubleTapSlop; + private int doubleTapTimeout; + private int tapTimeout; + // The acceptable distance the pointer can move and still count as a tap. + private int touchSlop; + // A tap counts when target number of fingers are down and up once. + protected int completedTapCount; + // A flag set to true when target number of fingers have touched down at once before. + // Used to indicate what next finger action should be. Down when false and lift when true. + protected boolean isTargetFingerCountReached = false; + // Store initial down points for slop checking and update when next down if is inside slop. + private PointF[] bases; + // The points in bases that already have slop checked when onDown or onPointerDown. + // It prevents excluded points matched multiple times by other pointers from next check. + private ArrayList excludedPointsForDownSlopChecked; + private long lastDownTime; + private long lastUpTime; + + /** + * @throws IllegalArgumentException if fingers is less than 2 + * or taps is not positive. + */ + MultiFingerMultiTap( + Context context, + int fingers, + int taps, + int gestureId, + GestureMatcher.StateChangeListener listener) { + super(gestureId, new Handler(context.getMainLooper()), listener); + mTargetTapCount = taps; + mTargetFingerCount = fingers; + doubleTapSlop = ViewConfiguration.get(context).getScaledDoubleTapSlop() * fingers; + doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout(); + tapTimeout = ViewConfiguration.getTapTimeout(); + touchSlop = ViewConfiguration.get(context).getScaledTouchSlop() * fingers; + + bases = new PointF[mTargetFingerCount]; + for (int i = 0; i < bases.length; i++) { + bases[i] = new PointF(); + } + excludedPointsForDownSlopChecked = new ArrayList<>(mTargetFingerCount); + clear(); + } + + @Override + public void clear() { + completedTapCount = 0; + isTargetFingerCountReached = false; + for (int i = 0; i < bases.length; i++) { + bases[i].set(Float.NaN, Float.NaN); + } + excludedPointsForDownSlopChecked.clear(); + lastDownTime = Long.MAX_VALUE; + lastUpTime = Long.MAX_VALUE; + super.clear(); + } + + @Override + protected void onDown(MotionEvent event) { + // Before the matcher state transit to completed, + // Cancel when an additional down arrived after reaching the target number of taps. + if (completedTapCount == mTargetTapCount) { + cancelGesture(event); + return; + } + long timeDelta = event.getEventTime() - lastUpTime; + if (timeDelta > doubleTapTimeout) { + cancelGesture(event); + return; + } + lastDownTime = event.getEventTime(); + if (completedTapCount == 0) { + initBaseLocation(event); + return; + } + // As fingers go up and down, their pointer ids will not be the same. + // Therefore we require that a given finger be in slop range of any one + // of the fingers from the previous tap. + final PointF nearest = findNearestPoint(event, doubleTapSlop, true); + if (nearest != null) { + // Update pointer location to nearest one as a new base for next slop check. + final int index = event.getActionIndex(); + nearest.set(event.getX(index), event.getY(index)); + } else { + cancelGesture(event); + } + } + + @Override + protected void onUp(MotionEvent event) { + long timeDelta = event.getEventTime() - lastDownTime; + if (timeDelta > tapTimeout) { + cancelGesture(event); + return; + } + final PointF nearest = findNearestPoint(event, touchSlop, false); + if ((getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) && null != nearest) { + // Increase current tap count when the user have all fingers lifted + // within the tap timeout since the target number of fingers are down. + if (isTargetFingerCountReached) { + completedTapCount++; + isTargetFingerCountReached = false; + excludedPointsForDownSlopChecked.clear(); + } + + // Start gesture detection here to avoid the conflict to 2nd finger double tap + // that never actually started gesture detection. + if (completedTapCount == 1) { + startGesture(event); + } + if (completedTapCount == mTargetTapCount) { + // Done. + completeAfterDoubleTapTimeout(event); + } + } else { + // Either too many taps or nonsensical event stream. + cancelGesture(event); + } + } + + @Override + protected void onMove(MotionEvent event) { + // Outside the touch slop + if (null == findNearestPoint(event, touchSlop, false)) { + // cancelGesture(event); + } + } + + @Override + protected void onPointerDown(MotionEvent event) { + // Reset timeout to ease the use for some people + // with certain impairments to get all their fingers down. + long timeDelta = event.getEventTime() - lastDownTime; + if (timeDelta > tapTimeout) { + cancelGesture(event); + return; + } + lastDownTime = event.getEventTime(); + final int currentFingerCount = event.getPointerCount(); + // Accept down only before target number of fingers are down + // or the finger count is not more than target. + if ((currentFingerCount > mTargetFingerCount) || isTargetFingerCountReached) { + isTargetFingerCountReached = false; + cancelGesture(event); + return; + } + + final PointF nearest; + if (completedTapCount == 0) { + nearest = initBaseLocation(event); + } else { + nearest = findNearestPoint(event, doubleTapSlop, true); + } + if ((getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) && nearest != null) { + // The user have all fingers down within the tap timeout since first finger down, + // setting the timeout for fingers to be lifted. + if (currentFingerCount == mTargetFingerCount) { + isTargetFingerCountReached = true; + } + // Update pointer location to nearest one as a new base for next slop check. + final int index = event.getActionIndex(); + nearest.set(event.getX(index), event.getY(index)); + } else { + cancelGesture(event); + } + } + + @Override + protected void onPointerUp(MotionEvent event) { + // Accept up only after target number of fingers are down. + if (!isTargetFingerCountReached) { + cancelGesture(event); + return; + } + + if (getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) { + // Needs more fingers lifted within the tap timeout + // after reaching the target number of fingers are down. + long timeDelta = event.getEventTime() - lastDownTime; + if (timeDelta > tapTimeout) { + cancelGesture(event); + return; + } + lastUpTime = event.getEventTime(); + } else { + cancelGesture(event); + } + } + + @Override + public String getGestureName() { + final StringBuilder builder = new StringBuilder(); + builder.append(mTargetFingerCount).append("-Finger "); + if (mTargetTapCount == 1) { + builder.append("Single"); + } else if (mTargetTapCount == 2) { + builder.append("Double"); + } else if (mTargetTapCount == 3) { + builder.append("Triple"); + } else if (mTargetTapCount > 3) { + builder.append(mTargetTapCount); + } + return builder.append(" Tap").toString(); + } + + private PointF initBaseLocation(MotionEvent event) { + final int index = event.getActionIndex(); + final int baseIndex = event.getPointerCount() - 1; + final PointF p = bases[baseIndex]; + if (Float.isNaN(p.x) && Float.isNaN(p.y)) { + p.set(event.getX(index), event.getY(index)); + } + return p; + } + + /** + * Find the nearest location to the given event in the bases. If no one found, it could be not + * inside {@code slop}, filtered or empty bases. When {@code filterMatched} is true, if the + * location of given event matches one of the points in {@link #mExcludedPointsForDownSlopChecked} + * it would be ignored. Otherwise, the location will be added to {@link + * #mExcludedPointsForDownSlopChecked}. + * + * @param event to find nearest point in bases. + * @param slop to check to the given location of the event. + * @param filterMatched true to exclude points already matched other pointers. + * @return the point in bases closed to the location of the given event. + */ + @Nullable + private PointF findNearestPoint(MotionEvent event, float slop, boolean filterMatched) { + float moveDelta = Float.MAX_VALUE; + PointF nearest = null; + for (int i = 0; i < bases.length; i++) { + final PointF p = bases[i]; + if (Float.isNaN(p.x) && Float.isNaN(p.y)) { + continue; + } + if (filterMatched && excludedPointsForDownSlopChecked.contains(p)) { + continue; + } + final int index = event.getActionIndex(); + final float dX = p.x - event.getX(index); + final float dY = p.y - event.getY(index); + if (dX == 0 && dY == 0) { + if (filterMatched) { + excludedPointsForDownSlopChecked.add(p); + } + return p; + } + final float delta = (float) Math.hypot(dX, dY); + if (moveDelta > delta) { + moveDelta = delta; + nearest = p; + } + } + if (moveDelta < slop) { + if (filterMatched) { + excludedPointsForDownSlopChecked.add(nearest); + } + return nearest; + } + return null; + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder(super.toString()); + if (getState() != STATE_GESTURE_CANCELED) { + builder.append(", CompletedTapCount: "); + builder.append(completedTapCount); + builder.append(", IsTargetFingerCountReached: "); + builder.append(isTargetFingerCountReached); + builder.append(", Bases: "); + builder.append(Arrays.toString(bases)); + builder.append(", ExcludedPointsForDownSlopChecked: "); + builder.append(excludedPointsForDownSlopChecked.toString()); + } + return builder.toString(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTapAndHold.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTapAndHold.java new file mode 100644 index 0000000..68bb15f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTapAndHold.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2020 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.google.android.accessibility.utils.gestures; + +import android.content.Context; +import android.view.MotionEvent; + +/** + * This class matches gestures of the form multi-finger multi-tap and hold. The number of fingers + * and taps for each instance is specified in the constructor. + */ +class MultiFingerMultiTapAndHold extends MultiFingerMultiTap { + + MultiFingerMultiTapAndHold( + Context context, + int fingers, + int taps, + int gestureId, + GestureMatcher.StateChangeListener listener) { + super(context, fingers, taps, gestureId, listener); + } + + @Override + protected void onPointerDown(MotionEvent event) { + super.onPointerDown(event); + if (isTargetFingerCountReached && completedTapCount + 1 == mTargetTapCount) { + completeAfterLongPressTimeout(event); + } + } + + @Override + protected void onUp(MotionEvent event) { + if (completedTapCount + 1 == mTargetTapCount) { + // Calling super.onUp would complete the multi-tap version of this. + cancelGesture(event); + } else { + super.onUp(event); + cancelAfterDoubleTapTimeout(event); + } + } + + @Override + public String getGestureName() { + final StringBuilder builder = new StringBuilder(); + builder.append(mTargetFingerCount).append("-Finger "); + if (mTargetTapCount == 1) { + builder.append("Single"); + } else if (mTargetTapCount == 2) { + builder.append("Double"); + } else if (mTargetTapCount == 3) { + builder.append("Triple"); + } else if (mTargetTapCount > 3) { + builder.append(mTargetTapCount); + } + return builder.append(" Tap and hold").toString(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerSwipe.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerSwipe.java new file mode 100644 index 0000000..907bd17 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerSwipe.java @@ -0,0 +1,435 @@ +/* + * Copyright (C) 2020 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.google.android.accessibility.utils.gestures; + +import static android.view.MotionEvent.INVALID_POINTER_ID; + +import android.content.Context; +import android.graphics.PointF; +import android.os.Handler; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * This class is responsible for matching one-finger swipe gestures. Each instance matches one swipe + * gesture. A swipe is specified as a series of one or more directions e.g. left, left and up, etc. + * At this time swipes with more than two directions are not supported. + */ +class MultiFingerSwipe extends GestureMatcher { + + // Direction constants. + public static final int LEFT = 0; + public static final int RIGHT = 1; + public static final int UP = 2; + public static final int DOWN = 3; + + // Buffer for storing points for gesture detection. + private final List> strokeBuffers; + + // The swipe direction for this matcher. + private int targetDirection; + private int[] pointerIds; + // The starting point of each finger's path in the gesture. + private PointF[] base; + // The most recent entry in each finger's gesture path. + private PointF[] previousGesturePoint; + private int targetFingerCount; + private int currentFingerCount; + // Whether the appropriate number of fingers have gone down at some point. This is reset only on + // clear. + private boolean targetFingerCountReached = false; + // Constants for sampling motion event points. + // We sample based on a minimum distance between points, primarily to improve accuracy by + // reducing noisy minor changes in direction. + private static final float MIN_CM_BETWEEN_SAMPLES = 0.25f; + private final float minPixelsBetweenSamplesX; + private final float minPixelsBetweenSamplesY; + // The minmimum distance the finger must travel before we evaluate the initial direction of the + // swipe. + // Anything less is still considered a touch. + private int touchSlop; + + MultiFingerSwipe( + Context context, + int fingerCount, + int direction, + int gesture, + GestureMatcher.StateChangeListener listener) { + super(gesture, new Handler(context.getMainLooper()), listener); + targetFingerCount = fingerCount; + pointerIds = new int[targetFingerCount]; + base = new PointF[targetFingerCount]; + previousGesturePoint = new PointF[targetFingerCount]; + strokeBuffers = new ArrayList<>(); + for (int i = 0; i < targetFingerCount; ++i) { + strokeBuffers.add(new ArrayList()); + } + targetDirection = direction; + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + // Calculate gesture sampling interval. + final float pixelsPerCmX = displayMetrics.xdpi / GestureUtils.CM_PER_INCH; + final float pixelsPerCmY = displayMetrics.ydpi / GestureUtils.CM_PER_INCH; + minPixelsBetweenSamplesX = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmX; + minPixelsBetweenSamplesY = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmY; + touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + clear(); + } + + @Override + public void clear() { + targetFingerCountReached = false; + currentFingerCount = 0; + for (int i = 0; i < targetFingerCount; ++i) { + pointerIds[i] = INVALID_POINTER_ID; + if (base[i] == null) { + base[i] = new PointF(); + } + base[i].x = Float.NaN; + base[i].y = Float.NaN; + if (previousGesturePoint[i] == null) { + previousGesturePoint[i] = new PointF(); + } + previousGesturePoint[i].x = Float.NaN; + previousGesturePoint[i].y = Float.NaN; + strokeBuffers.get(i).clear(); + } + super.clear(); + } + + @Override + protected void onDown(MotionEvent event) { + if (currentFingerCount > 0) { + cancelGesture(event); + return; + } + currentFingerCount = 1; + final int actionIndex = event.getActionIndex(); + final int pointerId = event.getPointerId(actionIndex); + int pointerIndex = event.getPointerCount() - 1; + if (pointerId < 0) { + // Nonsensical pointer id. + cancelGesture(event); + return; + } + if (pointerIds[pointerIndex] != INVALID_POINTER_ID) { + // Inconsistent event stream. + cancelGesture(event); + return; + } + pointerIds[pointerIndex] = pointerId; + if (Float.isNaN(base[pointerIndex].x) && Float.isNaN(base[pointerIndex].y)) { + final float x = event.getX(actionIndex); + final float y = event.getY(actionIndex); + if (x < 0f || y < 0f) { + cancelGesture(event); + return; + } + base[pointerIndex].x = x; + base[pointerIndex].y = y; + previousGesturePoint[pointerIndex].x = x; + previousGesturePoint[pointerIndex].y = y; + } else { + // This event doesn't make sense in the middle of a gesture. + cancelGesture(event); + return; + } + } + + @Override + protected void onPointerDown(MotionEvent event) { + if (event.getPointerCount() > targetFingerCount) { + cancelGesture(event); + return; + } + currentFingerCount += 1; + if (currentFingerCount != event.getPointerCount()) { + cancelGesture(event); + return; + } + if (currentFingerCount == targetFingerCount) { + targetFingerCountReached = true; + } + final int actionIndex = event.getActionIndex(); + final int pointerId = event.getPointerId(actionIndex); + if (pointerId < 0) { + // Nonsensical pointer id. + cancelGesture(event); + return; + } + int pointerIndex = currentFingerCount - 1; + if (pointerIds[pointerIndex] != INVALID_POINTER_ID) { + // Inconsistent event stream. + cancelGesture(event); + return; + } + pointerIds[pointerIndex] = pointerId; + if (Float.isNaN(base[pointerIndex].x) && Float.isNaN(base[pointerIndex].y)) { + final float x = event.getX(actionIndex); + final float y = event.getY(actionIndex); + if (x < 0f || y < 0f) { + cancelGesture(event); + return; + } + base[pointerIndex].x = x; + base[pointerIndex].y = y; + previousGesturePoint[pointerIndex].x = x; + previousGesturePoint[pointerIndex].y = y; + } else { + cancelGesture(event); + return; + } + } + + @Override + protected void onPointerUp(MotionEvent event) { + if (!targetFingerCountReached) { + cancelGesture(event); + return; + } + currentFingerCount -= 1; + final int actionIndex = event.getActionIndex(); + final int pointerId = event.getPointerId(actionIndex); + if (pointerId < 0) { + // Nonsensical pointer id. + cancelGesture(event); + return; + } + final int pointerIndex = Arrays.binarySearch(pointerIds, pointerId); + if (pointerIndex < 0) { + cancelGesture(event); + return; + } + final float x = event.getX(actionIndex); + final float y = event.getY(actionIndex); + if (x < 0f || y < 0f) { + cancelGesture(event); + return; + } + final float dX = Math.abs(x - previousGesturePoint[pointerIndex].x); + final float dY = Math.abs(y - previousGesturePoint[pointerIndex].y); + if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { + strokeBuffers.get(pointerIndex).add(new PointF(x, y)); + } + // We will evaluate all the paths on ACTION_UP. + } + + @Override + protected void onMove(MotionEvent event) { + for (int pointerIndex = 0; pointerIndex < targetFingerCount; ++pointerIndex) { + if (pointerIds[pointerIndex] == INVALID_POINTER_ID) { + // Fingers have started to move before the required number of fingers are down. + // However, they can still move less than the touch slop and still be considered + // touching, not moving. + // So we just ignore fingers that haven't been assigned a pointer id and process + // those who have. + continue; + } + LogUtils.v(getGestureName(), "Processing move on finger %d", pointerIndex); + int index = event.findPointerIndex(pointerIds[pointerIndex]); + if (index < 0) { + // This finger is not present in this event. It could have gone up just before this + // movement. + LogUtils.v(getGestureName(), "Finger %d not found in this event. skipping.", pointerIndex); + continue; + } + final float x = event.getX(index); + final float y = event.getY(index); + if (x < 0f || y < 0f) { + cancelGesture(event); + return; + } + final float dX = Math.abs(x - previousGesturePoint[pointerIndex].x); + final float dY = Math.abs(y - previousGesturePoint[pointerIndex].y); + final double moveDelta = + Math.hypot(Math.abs(x - base[pointerIndex].x), Math.abs(y - base[pointerIndex].y)); + LogUtils.v(getGestureName(), "moveDelta%g", moveDelta); + if (getState() == STATE_CLEAR) { + if (moveDelta < (targetFingerCount * touchSlop)) { + // This still counts as a touch not a swipe. + continue; + } + // First, make sure we have the right number of fingers down. + if (currentFingerCount != targetFingerCount) { + cancelGesture(event); + return; + } + // Then, make sure the pointer is going in the right direction. + int direction = toDirection(x - base[pointerIndex].x, y - base[pointerIndex].y); + if (direction != targetDirection) { + cancelGesture(event); + return; + } + // This is confirmed to be some kind of swipe so start tracking points. + startGesture(event); + for (int i = 0; i < targetFingerCount; ++i) { + strokeBuffers.get(i).add(new PointF(base[i])); + } + } else if (getState() == STATE_GESTURE_STARTED) { + // Cancel if the finger starts to go the wrong way. + // Note that this only works because this matcher assumes one direction. + int direction = toDirection(x - base[pointerIndex].x, y - base[pointerIndex].y); + if (direction != targetDirection) { + cancelGesture(event); + return; + } + if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { + // Sample every 2.5 MM in order to guard against minor variations in path. + previousGesturePoint[pointerIndex].x = x; + previousGesturePoint[pointerIndex].y = y; + strokeBuffers.get(pointerIndex).add(new PointF(x, y)); + } + } + } + } + + @Override + protected void onUp(MotionEvent event) { + if (getState() != STATE_GESTURE_STARTED) { + cancelGesture(event); + return; + } + currentFingerCount = 0; + final int actionIndex = event.getActionIndex(); + final int pointerId = event.getPointerId(actionIndex); + final int pointerIndex = Arrays.binarySearch(pointerIds, pointerId); + if (pointerIndex < 0) { + cancelGesture(event); + return; + } + final float x = event.getX(actionIndex); + final float y = event.getY(actionIndex); + if (x < 0f || y < 0f) { + cancelGesture(event); + return; + } + final float dX = Math.abs(x - previousGesturePoint[pointerIndex].x); + final float dY = Math.abs(y - previousGesturePoint[pointerIndex].y); + if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { + strokeBuffers.get(pointerIndex).add(new PointF(x, y)); + } + recognizeGesture(event); + } + + /** + * Looks at the sequence of motions in mStrokeBuffer, classifies the gesture, then transitions to + * the complete or cancel state depending on the result. + */ + private void recognizeGesture(MotionEvent event) { + // Check the path of each finger against the specified direction. + // Note that we sample every 2.5 MMm, and the direction matching is extremely tolerant (each + // direction has a 90-degree arch of tolerance) meaning that minor perpendicular movements + // should not create false negatives. + for (int i = 0; i < targetFingerCount; ++i) { + LogUtils.v(getGestureName(), "Recognizing finger: %d", i); + if (strokeBuffers.get(i).size() < 2) { + Log.d(getGestureName(), "Too few points."); + cancelGesture(event); + return; + } + List path = strokeBuffers.get(i); + + LogUtils.v(getGestureName(), "path= %s", path.toString()); + // Classify line segments, and call Listener callbacks. + if (!recognizeGesturePath(event, path)) { + cancelGesture(event); + return; + } + } + // If we reach this point then all paths match. + completeGesture(event); + } + + /** + * Tests the path of a given finger against the direction specified in this matcher. + * + * @return True if the path matches the specified direction for this matcher, otherwise false. + */ + private boolean recognizeGesturePath(MotionEvent event, List path) { + for (int i = 0; i < path.size() - 1; ++i) { + PointF start = path.get(i); + PointF end = path.get(i + 1); + + float dX = end.x - start.x; + float dY = end.y - start.y; + int direction = toDirection(dX, dY); + if (direction != targetDirection) { + LogUtils.v( + getGestureName(), + "Found direction %s when expecting %s" + + directionToString(direction) + + directionToString(this.targetDirection)); + return false; + } + } + LogUtils.v(getGestureName(), "Completed."); + return true; + } + + private static int toDirection(float dX, float dY) { + if (Math.abs(dX) > Math.abs(dY)) { + // Horizontal + return (dX < 0) ? LEFT : RIGHT; + } else { + // Vertical + return (dY < 0) ? UP : DOWN; + } + } + + public static String directionToString(int direction) { + switch (direction) { + case LEFT: + return "left"; + case RIGHT: + return "right"; + case UP: + return "up"; + case DOWN: + return "down"; + default: + return "Unknown Direction"; + } + } + + @Override + protected String getGestureName() { + StringBuilder builder = new StringBuilder(); + builder.append(targetFingerCount).append("-finger "); + builder.append("Swipe ").append(directionToString(targetDirection)); + return builder.toString(); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(super.toString()); + if (getState() != STATE_GESTURE_CANCELED) { + builder + .append(", mBase: ") + .append(Arrays.toString(base)) + .append(", mMinPixelsBetweenSamplesX:") + .append(minPixelsBetweenSamplesX) + .append(", mMinPixelsBetweenSamplesY:") + .append(minPixelsBetweenSamplesY); + } + return builder.toString(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTap.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTap.java new file mode 100644 index 0000000..0f36f83 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTap.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2019 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.google.android.accessibility.utils.gestures; + +import android.content.Context; +import android.os.Handler; +import android.view.MotionEvent; +import android.view.ViewConfiguration; + +/** + * This class matches multi-tap gestures. The number of taps for each instance is specified in the + * constructor. + * + * @hide + */ +public class MultiTap extends GestureMatcher { + + // Maximum reasonable number of taps. + public static final int MAX_TAPS = 10; + final int targetTaps; + // The acceptable distance between two taps + int doubleTapSlop; + // The acceptable distance the pointer can move and still count as a tap. + int touchSlop; + int tapTimeout; + int doubleTapTimeout; + int currentTaps; + float baseX; + float baseY; + long lastDownTime; + long lastUpTime; + + public MultiTap( + Context context, int taps, int gesture, GestureMatcher.StateChangeListener listener) { + super(gesture, new Handler(context.getMainLooper()), listener); + targetTaps = taps; + doubleTapSlop = ViewConfiguration.get(context).getScaledDoubleTapSlop(); + touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + tapTimeout = ViewConfiguration.getTapTimeout(); + doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout(); + clear(); + } + + @Override + public void clear() { + currentTaps = 0; + baseX = Float.NaN; + baseY = Float.NaN; + lastDownTime = Long.MAX_VALUE; + lastUpTime = Long.MAX_VALUE; + super.clear(); + } + + @Override + protected void onDown(MotionEvent event) { + long time = event.getEventTime(); + long timeDelta = time - lastUpTime; + if (timeDelta > doubleTapTimeout) { + cancelGesture(event); + return; + } + lastDownTime = time; + if (Float.isNaN(baseX) && Float.isNaN(baseY)) { + baseX = event.getX(); + baseY = event.getY(); + } + if (!isInsideSlop(event, doubleTapSlop)) { + cancelGesture(event); + } + baseX = event.getX(); + baseY = event.getY(); + if (currentTaps + 1 == targetTaps) { + // Start gesture detecting on down of final tap. + // Note that if this instance is matching double tap, + // and the service is not requesting to handle double tap, GestureManifold will + // ignore this. + startGesture(event); + } + } + + @Override + protected void onUp(MotionEvent event) { + long time = event.getEventTime(); + long timeDelta = time - lastDownTime; + if (timeDelta > tapTimeout) { + cancelGesture(event); + return; + } + lastUpTime = time; + if (!isInsideSlop(event, touchSlop)) { + cancelGesture(event); + } + if (getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) { + currentTaps++; + if (currentTaps == targetTaps) { + // Done. + completeGesture(event); + return; + } + // Needs more taps. + } else { + // Either too many taps or nonsensical event stream. + cancelGesture(event); + } + } + + @Override + protected void onMove(MotionEvent event) { + if (!isInsideSlop(event, touchSlop)) { + cancelGesture(event); + } + } + + @Override + protected void onPointerDown(MotionEvent event) { + cancelGesture(event); + } + + @Override + protected void onPointerUp(MotionEvent event) { + cancelGesture(event); + } + + @Override + public String getGestureName() { + switch (targetTaps) { + case 2: + return "Double Tap"; + case 3: + return "Triple Tap"; + default: + return Integer.toString(targetTaps) + " Taps"; + } + } + + private boolean isInsideSlop(MotionEvent event, int slop) { + final float deltaX = baseX - event.getX(); + final float deltaY = baseY - event.getY(); + if (deltaX == 0 && deltaY == 0) { + return true; + } + final double moveDelta = Math.hypot(deltaX, deltaY); + return moveDelta <= slop; + } + + @Override + public String toString() { + return super.toString() + + ", Taps:" + + currentTaps + + ", mBaseX: " + + Float.toString(baseX) + + ", mBaseY: " + + Float.toString(baseY); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTapAndHold.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTapAndHold.java new file mode 100644 index 0000000..107ab5a --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTapAndHold.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2019 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.google.android.accessibility.utils.gestures; + +import android.content.Context; +import android.view.MotionEvent; + +/** + * This class matches gestures of the form multi-tap and hold. The number of taps for each instance + * is specified in the constructor. + * + * @hide + */ +public class MultiTapAndHold extends MultiTap { + + public MultiTapAndHold( + Context context, int taps, int gesture, GestureMatcher.StateChangeListener listener) { + super(context, taps, gesture, listener); + } + + @Override + protected void onDown(MotionEvent event) { + super.onDown(event); + if (currentTaps + 1 == targetTaps) { + completeAfterLongPressTimeout(event); + } + } + + @Override + protected void onUp(MotionEvent event) { + super.onUp(event); + cancelAfterDoubleTapTimeout(event); + } + + @Override + public String getGestureName() { + switch (targetTaps) { + case 2: + return "Double Tap and Hold"; + case 3: + return "Triple Tap and Hold"; + default: + return Integer.toString(targetTaps) + " Taps and Hold"; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerMultiTap.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerMultiTap.java new file mode 100644 index 0000000..addb090 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerMultiTap.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2019 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.google.android.accessibility.utils.gestures; + +import static android.view.MotionEvent.INVALID_POINTER_ID; + +import android.content.Context; +import android.os.Handler; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import com.google.android.libraries.accessibility.utils.log.LogUtils; + +/** + * This class matches second-finger multi-tap gestures. A second-finger multi-tap gesture is where + * one finger is held down and a second finger executes the taps. The number of taps for each + * instance is specified in the constructor. + */ +class SecondFingerMultiTap extends GestureMatcher { + private final int targetTaps; + private int doubleTapSlop; + private int touchSlop; + private int tapTimeout; + private int doubleTapTimeout; + private int currentTaps; + private int secondFingerPointerId; + float baseX; + float baseY; + long lastDownTime; + long lastUpTime; + + SecondFingerMultiTap( + Context context, int taps, int gesture, GestureMatcher.StateChangeListener listener) { + super(gesture, new Handler(context.getMainLooper()), listener); + targetTaps = taps; + doubleTapSlop = ViewConfiguration.get(context).getScaledDoubleTapSlop(); + + touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + tapTimeout = ViewConfiguration.getTapTimeout(); + doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout(); + clear(); + } + + @Override + public void clear() { + currentTaps = 0; + baseX = Float.NaN; + baseY = Float.NaN; + secondFingerPointerId = INVALID_POINTER_ID; + lastDownTime = Long.MAX_VALUE; + lastUpTime = Long.MAX_VALUE; + super.clear(); + } + + @Override + protected void onPointerDown(MotionEvent event) { + if (event.getPointerCount() > 2) { + cancelGesture(event); + return; + } + // Second finger has gone down. + int index = event.getActionIndex(); + secondFingerPointerId = event.getPointerId(index); + long time = event.getEventTime(); + long timeDelta = time - lastUpTime; + if (timeDelta > doubleTapTimeout) { + cancelGesture(event); + return; + } + lastDownTime = time; + if (Float.isNaN(baseX) && Float.isNaN(baseY)) { + baseX = event.getX(index); + baseY = event.getY(index); + } + if (!isSecondFingerInsideSlop(event, doubleTapSlop)) { + cancelGesture(event); + } + baseX = event.getX(); + baseY = event.getY(); + } + + @Override + protected void onPointerUp(MotionEvent event) { + if (event.getPointerCount() > 2) { + cancelGesture(event); + return; + } + long time = event.getEventTime(); + long timeDelta = time - lastDownTime; + if (timeDelta > tapTimeout) { + cancelGesture(event); + return; + } + lastUpTime = time; + if (!isSecondFingerInsideSlop(event, touchSlop)) { + cancelGesture(event); + } + if (getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) { + currentTaps++; + if (currentTaps == targetTaps) { + // Done. + completeGesture(event); + return; + } + } else { + // Nonsensical event stream. + cancelGesture(event); + } + } + + @Override + protected void onMove(MotionEvent event) { + switch (event.getPointerCount()) { + case 1: + // We don't need to track anything about one-finger movements. + break; + case 2: + if (!isSecondFingerInsideSlop(event, touchSlop)) { + cancelGesture(event); + } + break; + default: + // More than two fingers means we stop tracking. + cancelGesture(event); + break; + } + } + + @Override + protected void onUp(MotionEvent event) { + // Cancel early when possible, or it will take precedence over two-finger double tap. + cancelGesture(event); + } + + @Override + public String getGestureName() { + switch (targetTaps) { + case 2: + return "Second Finger Double Tap"; + case 3: + return "Second Finger Triple Tap"; + default: + return "Second Finger " + Integer.toString(targetTaps) + " Taps"; + } + } + + private boolean isSecondFingerInsideSlop(MotionEvent event, int slop) { + int pointerIndex = event.findPointerIndex(secondFingerPointerId); + if (pointerIndex == -1) { + LogUtils.e(getGestureName(), "Unable to find pointer."); + return false; + } + final float deltaX = baseX - event.getX(pointerIndex); + final float deltaY = baseY - event.getY(pointerIndex); + if (deltaX == 0 && deltaY == 0) { + return true; + } + final double moveDelta = Math.hypot(deltaX, deltaY); + LogUtils.v(getGestureName(), "moveDelta: %g", moveDelta); + return moveDelta <= slop; + } + + @Override + public String toString() { + return super.toString() + + ", Taps:" + + currentTaps + + ", mBaseX: " + + Float.toString(baseX) + + ", mBaseY: " + + Float.toString(baseY); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/Swipe.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/Swipe.java new file mode 100644 index 0000000..8fd4a05 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/Swipe.java @@ -0,0 +1,392 @@ +/* + * Copyright (C) 2021 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.google.android.accessibility.utils.gestures; + +import static com.google.android.accessibility.utils.gestures.GestureUtils.MM_PER_CM; + +import android.content.Context; +import android.graphics.PointF; +import android.os.Handler; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.ArrayList; + +/** + * This class is responsible for matching one-finger swipe gestures. Each instance matches one swipe + * gesture. A swipe is specified as a series of one or more directions e.g. left, left and up, etc. + * At this time swipes with more than two directions are not supported. + */ +class Swipe extends GestureMatcher { + + // Direction constants. + public static final int LEFT = 0; + public static final int RIGHT = 1; + public static final int UP = 2; + public static final int DOWN = 3; + // This is the calculated movement threshold used track if the user is still + // moving their finger. + private final float gestureDetectionThresholdPixels; + + // Buffer for storing points for gesture detection. + private final ArrayList strokeBuffer = new ArrayList<>(100); + + // Constants for sampling motion event points. + // We sample based on a minimum distance between points, primarily to improve accuracy by + // reducing noisy minor changes in direction. + private static final float MIN_CM_BETWEEN_SAMPLES = 0.25f; + + // Distance a finger must travel before we decide if it is a gesture or not. + public static final int GESTURE_CONFIRM_CM = 1; + + // Time threshold used to determine if an interaction is a gesture or not. + // If the first movement of 1cm takes longer than this value, we assume it's + // a slow movement, and therefore not a gesture. + // + // This value was determined by measuring the time for the first 1cm + // movement when gesturing, and touch exploring. Based on user testing, + // all gestures started with the initial movement taking less than 100ms. + // When touch exploring, the first movement almost always takes longer than + // 200ms. + public static final long MAX_TIME_TO_START_SWIPE_MS = 150 * GESTURE_CONFIRM_CM; + + // Time threshold used to determine if a gesture should be cancelled. If + // the finger takes more than this time to move to the next sample point, the ongoing gesture + // is cancelled. + public static final long MAX_TIME_TO_CONTINUE_SWIPE_MS = 350 * GESTURE_CONFIRM_CM; + + private int[] directions; + private float baseX; + private float baseY; + private long baseTime; + private float previousGestureX; + private float previousGestureY; + private final float minPixelsBetweenSamplesX; + private final float minPixelsBetweenSamplesY; + // The minmimum distance the finger must travel before we evaluate the initial direction of the + // swipe. + // Anything less is still considered a touch. + private int touchSlop; + + // Constants for separating gesture segments + private static final float ANGLE_THRESHOLD = 0.0f; + + Swipe(Context context, int direction, int gesture, GestureMatcher.StateChangeListener listener) { + this(context, new int[] {direction}, gesture, listener); + } + + Swipe( + Context context, + int direction1, + int direction2, + int gesture, + GestureMatcher.StateChangeListener listener) { + this(context, new int[] {direction1, direction2}, gesture, listener); + } + + private Swipe( + Context context, int[] directions, int gesture, GestureMatcher.StateChangeListener listener) { + super(gesture, new Handler(context.getMainLooper()), listener); + this.directions = directions; + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + gestureDetectionThresholdPixels = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, MM_PER_CM, displayMetrics) + * GESTURE_CONFIRM_CM; + // Calculate minimum gesture velocity + final float pixelsPerCmX = displayMetrics.xdpi / 2.54f; + final float pixelsPerCmY = displayMetrics.ydpi / 2.54f; + minPixelsBetweenSamplesX = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmX; + minPixelsBetweenSamplesY = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmY; + touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + clear(); + } + + @Override + public void clear() { + baseX = Float.NaN; + baseY = Float.NaN; + baseTime = 0; + previousGestureX = Float.NaN; + previousGestureY = Float.NaN; + strokeBuffer.clear(); + super.clear(); + } + + @Override + protected void onDown(MotionEvent event) { + if (Float.isNaN(baseX) && Float.isNaN(baseY)) { + baseX = event.getX(); + baseY = event.getY(); + baseTime = event.getEventTime(); + previousGestureX = baseX; + previousGestureY = baseY; + } + // Otherwise do nothing because this event doesn't make sense in the middle of a gesture. + } + + @Override + protected void onMove(MotionEvent event) { + final float x = event.getX(); + final float y = event.getY(); + final long time = event.getEventTime(); + final float dX = Math.abs(x - previousGestureX); + final float dY = Math.abs(y - previousGestureY); + final double moveDelta = Math.hypot(Math.abs(x - baseX), Math.abs(y - baseY)); + final long timeDelta = time - baseTime; + LogUtils.v( + getGestureName(), + "moveDelta: %g, mGestureDetectionThreshold: %g", + moveDelta, + gestureDetectionThresholdPixels); + if (getState() == STATE_CLEAR) { + if (moveDelta < touchSlop) { + // This still counts as a touch not a swipe. + return; + } else if (strokeBuffer.size() == 0) { + // First, make sure the pointer is going in the right direction. + int direction = toDirection(x - baseX, y - baseY); + if (direction != directions[0]) { + cancelGesture(event); + return; + } + // This is confirmed to be some kind of swipe so start tracking points. + strokeBuffer.add(new PointF(baseX, baseY)); + } + } + if (moveDelta > gestureDetectionThresholdPixels) { + // This is a gesture, not touch exploration. + baseX = x; + baseY = y; + baseTime = time; + startGesture(event); + } else if (getState() == STATE_CLEAR) { + if (timeDelta > MAX_TIME_TO_START_SWIPE_MS) { + // The user isn't moving fast enough. + cancelGesture(event); + return; + } + } else if (getState() == STATE_GESTURE_STARTED) { + if (timeDelta > MAX_TIME_TO_CONTINUE_SWIPE_MS) { + cancelGesture(event); + return; + } + } + if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { + // At this point gesture detection has started and we are sampling points. + previousGestureX = x; + previousGestureY = y; + strokeBuffer.add(new PointF(x, y)); + } + } + + @Override + protected void onUp(MotionEvent event) { + if (getState() != STATE_GESTURE_STARTED) { + cancelGesture(event); + return; + } + + final float x = event.getX(); + final float y = event.getY(); + final float dX = Math.abs(x - previousGestureX); + final float dY = Math.abs(y - previousGestureY); + if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { + strokeBuffer.add(new PointF(x, y)); + } + recognizeGesture(event); + } + + @Override + protected void onPointerDown(MotionEvent event) { + cancelGesture(event); + } + + @Override + protected void onPointerUp(MotionEvent event) { + cancelGesture(event); + } + + /** + * Looks at the sequence of motions in mStrokeBuffer, classifies the gesture, then calls Listener + * callbacks for success or failure. + * + * @param event The raw motion event to pass to the listener callbacks. + */ + private void recognizeGesture(MotionEvent event) { + if (strokeBuffer.size() < 2) { + cancelGesture(event); + return; + } + + // Look at mStrokeBuffer and extract 2 line segments, delimited by near-perpendicular + // direction change. + // Method: for each sampled motion event, check the angle of the most recent motion vector + // versus the preceding motion vector, and segment the line if the angle is about + // 90 degrees. + + ArrayList path = new ArrayList<>(); + PointF lastDelimiter = strokeBuffer.get(0); + path.add(lastDelimiter); + + float dX = 0; // Sum of unit vectors from last delimiter to each following point + float dY = 0; + int count = 0; // Number of points since last delimiter + float length = 0; // Vector length from delimiter to most recent point + + PointF next = null; + for (int i = 1; i < strokeBuffer.size(); ++i) { + next = strokeBuffer.get(i); + if (count > 0) { + // Average of unit vectors from delimiter to following points + float currentDX = dX / count; + float currentDY = dY / count; + + // newDelimiter is a possible new delimiter, based on a vector with length from + // the last delimiter to the previous point, but in the direction of the average + // unit vector from delimiter to previous points. + // Using the averaged vector has the effect of "squaring off the curve", + // creating a sharper angle between the last motion and the preceding motion from + // the delimiter. In turn, this sharper angle achieves the splitting threshold + // even in a gentle curve. + PointF newDelimiter = + new PointF(length * currentDX + lastDelimiter.x, length * currentDY + lastDelimiter.y); + + // Unit vector from newDelimiter to the most recent point + float nextDX = next.x - newDelimiter.x; + float nextDY = next.y - newDelimiter.y; + float nextLength = (float) Math.hypot(nextDX, nextDY); + nextDX = nextDX / nextLength; + nextDY = nextDY / nextLength; + + // Compare the initial motion direction to the most recent motion direction, + // and segment the line if direction has changed by about 90 degrees. + float dot = currentDX * nextDX + currentDY * nextDY; + if (dot < ANGLE_THRESHOLD) { + path.add(newDelimiter); + lastDelimiter = newDelimiter; + dX = 0; + dY = 0; + count = 0; + } + } + + // Vector from last delimiter to most recent point + float currentDX = next.x - lastDelimiter.x; + float currentDY = next.y - lastDelimiter.y; + length = (float) Math.hypot(currentDX, currentDY); + + // Increment sum of unit vectors from delimiter to each following point + count = count + 1; + dX = dX + currentDX / length; + dY = dY + currentDY / length; + } + + path.add(next); + LogUtils.v(getGestureName(), "path = %s", path.toString()); + // Classify line segments, and call Listener callbacks. + recognizeGesturePath(event, path); + } + + /** + * Classifies a pair of line segments, by direction. Calls Listener callbacks for success or + * failure. + * + * @param event The raw motion event to pass to the listener's onGestureCanceled method. + * @param path A sequence of motion line segments derived from motion points in mStrokeBuffer. + */ + private void recognizeGesturePath(MotionEvent event, ArrayList path) { + if (path.size() != directions.length + 1) { + cancelGesture(event); + return; + } + for (int i = 0; i < path.size() - 1; ++i) { + PointF start = path.get(i); + PointF end = path.get(i + 1); + + float dX = end.x - start.x; + float dY = end.y - start.y; + int direction = toDirection(dX, dY); + if (direction != directions[i]) { + LogUtils.v( + getGestureName(), + "Found direction %s when expecting %s", + directionToString(direction), + directionToString(directions[i])); + cancelGesture(event); + return; + } + } + LogUtils.v(getGestureName(), "Completed."); + completeGesture(event); + } + + private static int toDirection(float dX, float dY) { + if (Math.abs(dX) > Math.abs(dY)) { + // Horizontal + return (dX < 0) ? LEFT : RIGHT; + } else { + // Vertical + return (dY < 0) ? UP : DOWN; + } + } + + public static String directionToString(int direction) { + switch (direction) { + case LEFT: + return "left"; + case RIGHT: + return "right"; + case UP: + return "up"; + case DOWN: + return "down"; + default: + return "Unknown Direction"; + } + } + + @Override + protected String getGestureName() { + StringBuilder builder = new StringBuilder(); + builder.append("Swipe ").append(directionToString(directions[0])); + for (int i = 1; i < directions.length; ++i) { + builder.append(" and ").append(directionToString(directions[i])); + } + return builder.toString(); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(super.toString()); + if (getState() != STATE_GESTURE_CANCELED) { + builder + .append(", mBaseX: ") + .append(baseX) + .append(", mBaseY: ") + .append(baseY) + .append(", mGestureDetectionThreshold:") + .append(gestureDetectionThresholdPixels) + .append(", mMinPixelsBetweenSamplesX:") + .append(minPixelsBetweenSamplesX) + .append(", mMinPixelsBetweenSamplesY:") + .append(minPixelsBetweenSamplesY); + } + return builder.toString(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/input/CursorGranularity.java b/utils/src/main/java/com/google/android/accessibility/utils/input/CursorGranularity.java new file mode 100644 index 0000000..67ca2d6 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/input/CursorGranularity.java @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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.google.android.accessibility.utils.input; + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.WebInterfaceUtils; +import java.util.Arrays; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** List of different Granularities for node and cursor movement in TalkBack. */ +public enum CursorGranularity { + // 0 is the default Android value when you want something outside the bit mask + // TODO: If rewriting this as a class, use a constant for 0. + DEFAULT(R.string.granularity_default, 1, 0), + CHARACTER( + R.string.granularity_character, + 2, + AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER), + WORD(R.string.granularity_word, 3, AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_WORD), + LINE(R.string.granularity_line, 4, AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_LINE), + PARAGRAPH( + R.string.granularity_paragraph, + 5, + AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PARAGRAPH), + WEB_HEADING(R.string.granularity_web_heading, 6, 0), + WEB_LINK(R.string.granularity_web_link, 7, 0), + WEB_LIST(R.string.granularity_web_list, 8, 0), + WEB_CONTROL(R.string.granularity_web_control, 9, 0), + HEADING(R.string.granularity_native_heading, 10, 0), + CONTROL(R.string.granularity_native_control, 11, 0), + LINK(R.string.granularity_native_link, 12, 0), + WEB_LANDMARK(R.string.granularity_web_landmark, 13, 0), + WINDOWS(R.string.granularity_window, 14, 0); + + /** Used to represent a granularity with no framework value. */ + private static final int NO_VALUE = 0; + + /** The resource identifier for this granularity's user-visible name. */ + public final int resourceId; + + public final int id; + + /** + * The framework value for this granularity, passed as an argument to {@link + * AccessibilityNodeInfoCompat#ACTION_NEXT_AT_MOVEMENT_GRANULARITY}. + */ + public final int value; + + /** + * Constructs a new granularity with the specified system identifier. + * + * @param value The system identifier. See the GRANULARITY_ constants in {@link + * AccessibilityNodeInfoCompat} for a complete list. + */ + private CursorGranularity(int resourceId, int id, int value) { + this.resourceId = resourceId; + this.id = id; + this.value = value; + } + + /** + * Returns the granularity associated with a particular key. + * + * @param resourceId The key associated with a granularity. + * @return The granularity associated with the key, or {@code null} if the key is invalid. + */ + public static @Nullable CursorGranularity fromResourceId(int resourceId) { + for (CursorGranularity value : values()) { + if (value.resourceId == resourceId) { + return value; + } + } + + return null; + } + + public static @Nullable CursorGranularity fromId(int id) { + for (CursorGranularity value : values()) { + if (value.id == id) { + return value; + } + } + + return null; + } + + /** + * Populates {@code result} with the {@link CursorGranularity}s represented by the {@code bitmask} + * of granularity framework values. The {@link #DEFAULT} granularity is always returned as the + * first item in the list. + * + * @param bitmask A bit mask of granularity framework values. + * @param hasWebContent Whether the view has web content. + * @param result The list to populate with supported granularities. + */ + public static void extractFromMask( + int bitmask, + boolean hasWebContent, + String @Nullable [] supportedHtmlElements, + List result) { + result.clear(); + + for (CursorGranularity value : values()) { + if (value.value == NO_VALUE) { + continue; + } + + if ((bitmask & value.value) == value.value) { + result.add(value); + } + } + + if (hasWebContent) { + if (supportedHtmlElements == null) { + result.add(WEB_HEADING); + result.add(WEB_CONTROL); + result.add(WEB_LIST); + } else { + List elements = Arrays.asList(supportedHtmlElements); + if (elements.contains(WebInterfaceUtils.HTML_ELEMENT_MOVE_BY_HEADING)) { + result.add(WEB_HEADING); + } + if (elements.contains(WebInterfaceUtils.HTML_ELEMENT_MOVE_BY_CONTROL)) { + result.add(WEB_CONTROL); + } + if (elements.contains(WebInterfaceUtils.HTML_ELEMENT_MOVE_BY_LINK)) { + result.add(WEB_LINK); + } + } + } else { + result.add(HEADING); + result.add(CONTROL); + result.add(LINK); + } + result.add(WINDOWS); + result.add(DEFAULT); + } + + /** @return whether {@code granularity} is a web-specific granularity. */ + public boolean isWebGranularity() { + // For some reason R.string cannot be used in a switch statement + return resourceId == R.string.granularity_web_heading + || resourceId == R.string.granularity_web_link + || resourceId == R.string.granularity_web_list + || resourceId == R.string.granularity_web_control + || resourceId == R.string.granularity_web_landmark; + } + + /** + * @return whether {@code granularity} is a native macro granularity. Macro granularity refers to + * granularity which helps to navigate across multiple nodes in oppose to micro granularity + * (Characters, words, etc) which is used to navigate withing a node. + */ + public boolean isNativeMacroGranularity() { + return resourceId == R.string.granularity_native_heading + || resourceId == R.string.granularity_native_control + || resourceId == R.string.granularity_native_link; + } + + public boolean isMicroGranularity() { + return resourceId == R.string.granularity_character + || resourceId == R.string.granularity_word + || resourceId == R.string.granularity_line + || resourceId == R.string.granularity_paragraph; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/input/InputModeManager.java b/utils/src/main/java/com/google/android/accessibility/utils/input/InputModeManager.java new file mode 100644 index 0000000..4f4fa1d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/input/InputModeManager.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.input; + +import android.view.InputDevice; +import android.view.KeyEvent; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.ServiceKeyEventListener; + +/** + * InputModeManager manages input mode which user is currently using to interact with the service. + */ +public class InputModeManager implements ServiceKeyEventListener { + + /** Different input modes used to move accessibility focus or perform actions. */ + @IntDef({ + INPUT_MODE_UNKNOWN, + INPUT_MODE_TOUCH, + INPUT_MODE_KEYBOARD, + INPUT_MODE_TV_REMOTE, + // INPUT_MODE_NON_ALPHABETIC_KEYBOARD will be used for numeric keypads built into phones. + INPUT_MODE_NON_ALPHABETIC_KEYBOARD + }) + public @interface InputMode {} + + public static final int INPUT_MODE_UNKNOWN = -1; + public static final int INPUT_MODE_TOUCH = 0; + public static final int INPUT_MODE_KEYBOARD = 1; + public static final int INPUT_MODE_TV_REMOTE = 2; + public static final int INPUT_MODE_NON_ALPHABETIC_KEYBOARD = 3; + + @InputMode private int mInputMode = INPUT_MODE_UNKNOWN; + + public void clear() { + mInputMode = INPUT_MODE_UNKNOWN; + } + + // TODO: Refactor all places where setInputMode is called such that the input mode + // changes are done only in InputModeManager and setInputMode becomes a private method. + public void setInputMode(@InputMode int inputMode) { + if (inputMode == INPUT_MODE_UNKNOWN) { + return; + } + + mInputMode = inputMode; + } + + @InputMode + public int getInputMode() { + return mInputMode; + } + + @Override + public boolean onKeyEvent(KeyEvent event, EventId eventId) { + // Talkback needs to differentiate between a separate physical Keyboard and numeric keypads + // built into phones. Keyboard attached with the phones should not be treated as physical + // keyboards. + setInputMode( + isEventFromNonAlphabeticKeyboard(event) + ? INPUT_MODE_NON_ALPHABETIC_KEYBOARD + : INPUT_MODE_KEYBOARD); + return false; + } + + @Override + public boolean processWhenServiceSuspended() { + return false; + } + + public static String inputModeToString(@InputMode int inputMode) { + switch (inputMode) { + case INPUT_MODE_TOUCH: + return "INPUT_MODE_TOUCH"; + case INPUT_MODE_KEYBOARD: + return "INPUT_MODE_KEYBOARD"; + case INPUT_MODE_TV_REMOTE: + return "INPUT_MODE_TV_REMOTE"; + case INPUT_MODE_NON_ALPHABETIC_KEYBOARD: + return "INPUT_MODE_NON_ALPHABETIC_KEYBOARD"; + default: + return "INPUT_MODE_UNKNOWN"; + } + } + + // Checks if the event is from a non alphabetic keyboard, like the ones built into a phone. + private static boolean isEventFromNonAlphabeticKeyboard(@NonNull KeyEvent event) { + InputDevice inputDevice = InputDevice.getDevice(event.getDeviceId()); + return (inputDevice != null) + && (InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC == inputDevice.getKeyboardType()); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/input/TextCursorTracker.java b/utils/src/main/java/com/google/android/accessibility/utils/input/TextCursorTracker.java new file mode 100644 index 0000000..aaa144d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/input/TextCursorTracker.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.input; + +import android.annotation.TargetApi; +import android.view.accessibility.AccessibilityEvent; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.AccessibilityEventUtils; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.accessibility.utils.Performance.EventId; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * This class consumes AccessibilityEvents to track the current state of the text cursor, and + * provides an interface to query it. + */ +public class TextCursorTracker { + + public static final int NO_POSITION = -1; + + /** Event types that are handled by TextCursorTracker. */ + @TargetApi(16) + private static final int MASK_EVENTS_HANDLED_BY_TEXT_CURSOR_MANAGER = + AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED + | AccessibilityEvent.TYPE_VIEW_FOCUSED + | AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED; + + private @Nullable AccessibilityNodeInfoCompat mNode; + private int mCurrentCursorPosition = NO_POSITION; + private int mPreviousCursorPosition = NO_POSITION; + + public int getEventTypes() { + return MASK_EVENTS_HANDLED_BY_TEXT_CURSOR_MANAGER; + } + + public void onAccessibilityEvent(AccessibilityEvent event, @Nullable EventId eventId) { + switch (event.getEventType()) { + case AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED: + processTextSelectionChange(event); + break; + case AccessibilityEvent.TYPE_VIEW_FOCUSED: + processViewInputFocused(event); + break; + case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED: + // To get initial cursor position of EditText + processViewAccessibilityFocused(event); + break; + default: + break; + } + } + + private void processViewAccessibilityFocused(AccessibilityEvent event) { + AccessibilityNodeInfoCompat source = AccessibilityEventUtils.sourceCompat(event); + if (source == null || mNode != null || !source.isFocused() || !source.isEditable()) { + return; + } + clear(); + mNode = source; + mCurrentCursorPosition = source.unwrap().getTextSelectionStart(); + } + + /** + * Updates cursor position when an empty edit text is focused. + * + *

TalkBack will not receive the initial {@link + * AccessibilityEvent#TYPE_VIEW_TEXT_SELECTION_CHANGED} event when an empty edit text is focused, + * in which case we need to manually update the cursor index. + */ + private void processViewInputFocused(AccessibilityEvent event) { + @Nullable AccessibilityNodeInfoCompat source = AccessibilityEventUtils.sourceCompat(event); + if (AccessibilityNodeInfoUtils.isEmptyEditTextRegardlessOfHint(source)) { + clear(); + mNode = source; + mCurrentCursorPosition = 0; + } + } + + private void processTextSelectionChange(AccessibilityEvent event) { + @Nullable AccessibilityNodeInfoCompat compat = AccessibilityEventUtils.sourceCompat(event); + if (compat == null) { + clear(); + return; + } + + if ((mNode != null) && compat.equals(mNode)) { + mPreviousCursorPosition = mCurrentCursorPosition; + mCurrentCursorPosition = event.getToIndex(); + } else { + clear(); + mNode = compat; + mCurrentCursorPosition = event.getToIndex(); + } + } + + private void clear() { + if (mNode != null) { + mNode = null; + } + + mCurrentCursorPosition = NO_POSITION; + mPreviousCursorPosition = NO_POSITION; + } + + public @Nullable AccessibilityNodeInfoCompat getCurrentNode() { + return mNode; + } + + public int getCurrentCursorPosition() { + return mCurrentCursorPosition; + } + + public int getPreviousCursorPosition() { + return mPreviousCursorPosition; + } + + public void forceSetCursorPosition(int previousCursorPosition, int currentCursorPosition) { + mPreviousCursorPosition = previousCursorPosition; + mCurrentCursorPosition = currentCursorPosition; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventFilter.java b/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventFilter.java new file mode 100644 index 0000000..969378a --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventFilter.java @@ -0,0 +1,395 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.input; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.text.TextUtils; +import android.view.accessibility.AccessibilityEvent; +import androidx.annotation.IntDef; +import androidx.core.view.accessibility.AccessibilityEventCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityRecordCompat; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.input.TextEventInterpreter.InterpretationConsumer; +import com.google.android.accessibility.utils.monitor.VoiceActionDelegate; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Collection; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Determines whether events should be passed on to the compositor. Interprets text events into more + * specific event types, and extracts data from events. + */ +public class TextEventFilter { + + private static final String TAG = "TextEventFilter"; + + /////////////////////////////////////////////////////////////////////////////////// + // Constants + + /** Minimum delay between change and selection events. */ + private static final long TEXT_SELECTION_DELAY = 150; + + /** Minimum delay between change events without an intervening selection. */ + private static final long TEXT_CHANGED_DELAY = 150; + + /** + * Minimum delay between selection and movement at granularity events that could reflect the same + * cursor movement information. + */ + private static final long CURSOR_MOVEMENT_EVENTS_DELAY = 150; + + /** + * Keyboard echo preferences. These must be synchronized with @array/pref_keyboard_echo_values and + * + * @array/pref_keyboard_echo_entries in values/donottranslate.xml. + */ + public static final int PREF_ECHO_NONE = 2; + + public static final int PREF_ECHO_CHARACTERS = 1; + public static final int PREF_ECHO_WORDS = 3; + public static final int PREF_ECHO_CHARACTERS_AND_WORDS = 0; + + /** The options of keyboard echo type. */ + @IntDef({PREF_ECHO_NONE, PREF_ECHO_CHARACTERS, PREF_ECHO_WORDS, PREF_ECHO_CHARACTERS_AND_WORDS}) + @Retention(RetentionPolicy.SOURCE) + public @interface KeyboardEchoType {} + + @KeyboardEchoType private int onScreenKeyboardEcho = PREF_ECHO_CHARACTERS_AND_WORDS; + @KeyboardEchoType private int physicalKeyboardEcho = PREF_ECHO_CHARACTERS_AND_WORDS; + + private enum KeyboardType { + ON_SCREEN, + PHYSICAL + } + + private static final int PHYSICAL_KEY_TIMEOUT = 100; + + /////////////////////////////////////////////////////////////////////////////////// + // Member variables + + private final Context context; + private @Nullable VoiceActionDelegate voiceActionDelegate; + + private final TextEventHistory textEventHistory; + private long lastKeyEventTime = -1; + + @Nullable TextCursorTracker textCursorTracker; + + private final Collection listeners = new ArrayList<>(); + + // ///////////////////////////////////////////////////////////////////////////////// + // Construction + + public TextEventFilter( + Context context, + @Nullable TextCursorTracker textCursorTracker, + TextEventHistory textEventHistory) { + this.context = context; + this.textCursorTracker = textCursorTracker; + this.textEventHistory = textEventHistory; + } + + /////////////////////////////////////////////////////////////////////////////////// + // Methods + + public void setVoiceActionDelegate(@Nullable VoiceActionDelegate delegate) { + voiceActionDelegate = delegate; + } + + public void setOnScreenKeyboardEcho(@KeyboardEchoType int value) { + onScreenKeyboardEcho = value; + } + + public void setPhysicalKeyboardEcho(@KeyboardEchoType int value) { + physicalKeyboardEcho = value; + } + + public void setLastKeyEventTime(long time) { + lastKeyEventTime = time; + } + + public void addListener(InterpretationConsumer listener) { + listeners.add(listener); + } + + public void updateTextCursorTracker(AccessibilityEvent event, @Nullable EventId eventId) { + // TODO: Has to move TextCursorTracker, together with TextEventInterpreter, to + // pipeline. Also need to prepare a copy of TextEventInterpreter for switch-access. + if (textCursorTracker != null) { + // Prevent null check failure: + // go/nullness-faq#i-checked-that-a-nullable-field-is-non-null-but-the-checker-is-still-complaining-that-it-could-be-null + TextCursorTracker textCursorTrackerLocal = textCursorTracker; + if ((textCursorTrackerLocal.getEventTypes() & event.getEventType()) != 0) { + textCursorTrackerLocal.onAccessibilityEvent(event, eventId); + } + } + } + + /** Filters out some text-events, and sends remaining text-event-interpretations to listeners. */ + @SuppressLint("SwitchIntDef") // Switch includes both text-event and accessibility-event types. + public void filterAndSendInterpretation( + AccessibilityEvent event, + @Nullable EventId eventId, + @Nullable TextEventInterpretation textEventInterpreted) { + + int eventType = + (textEventInterpreted == null) ? event.getEventType() : textEventInterpreted.getEvent(); + + switch (eventType) { + + // For focus fallback events, drop events with a source node. + case AccessibilityEvent.TYPE_VIEW_FOCUSED: + { + // Update cursor position when an empty edit text is focused. TalkBack will not receive + // the initial {@link AccessibilityEvent#TYPE_VIEW_TEXT_SELECTION_CHANGED} event when an + // empty edit text is focused, in which case we need to manually update the index. + AccessibilityNodeInfoCompat source = + AccessibilityNodeInfoUtils.toCompat(event.getSource()); + try { + if (source != null) { + if (AccessibilityNodeInfoUtils.isEmptyEditTextRegardlessOfHint(source)) { + textEventHistory.setLastFromIndex(0); + textEventHistory.setLastToIndex(0); + textEventHistory.setLastNode(AccessibilityNodeInfoUtils.obtain(source.unwrap())); + } + } + } finally { + AccessibilityNodeInfoUtils.recycleNodes(source); + } + } + return; + + // Text input change + case AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED: + case TextEventInterpretation.TEXT_CLEAR: + case TextEventInterpretation.TEXT_REMOVE: + case TextEventInterpretation.TEXT_ADD: + case TextEventInterpretation.TEXT_REPLACE: + case TextEventInterpretation.TEXT_PASSWORD_ADD: + case TextEventInterpretation.TEXT_PASSWORD_REMOVE: + case TextEventInterpretation.TEXT_PASSWORD_REPLACE: + { + if (dropTextChangeEvent(event)) { + return; + } + // Update text change history. + textEventHistory.setTextChangesAwaitingSelection(1); + textEventHistory.setLastTextChangeTime(event.getEventTime()); + textEventHistory.setLastTextChangePackageName(event.getPackageName()); + if ((voiceActionDelegate != null) && voiceActionDelegate.isVoiceRecognitionActive()) { + LogUtils.d(TAG, "Drop TYPE_VIEW_TEXT_CHANGED event: Voice recognition is active."); + return; + } + } + break; + + // Text input selection change + case AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED: + case AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY: + case TextEventInterpretation.SELECTION_FOCUS_EDIT_TEXT: + case TextEventInterpretation.SELECTION_MOVE_CURSOR_TO_BEGINNING: + case TextEventInterpretation.SELECTION_MOVE_CURSOR_TO_END: + case TextEventInterpretation.SELECTION_MOVE_CURSOR_NO_SELECTION: + case TextEventInterpretation.SELECTION_MOVE_CURSOR_WITH_SELECTION: + case TextEventInterpretation.SELECTION_MOVE_CURSOR_SELECTION_CLEARED: + case TextEventInterpretation.SELECTION_CUT: + case TextEventInterpretation.SELECTION_PASTE: + case TextEventInterpretation.SELECTION_TEXT_TRAVERSAL: + case TextEventInterpretation.SELECTION_SELECT_ALL: + case TextEventInterpretation.SELECTION_SELECT_ALL_WITH_KEYBOARD: + case TextEventInterpretation.SELECTION_RESET_SELECTION: + { + if (shouldSkipCursorMovementEvent(event) || shouldDropTextSelectionEvent(event)) { + return; + } + // Update text selection history. + textEventHistory.setLastKeptTextSelection(event); + if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) { + textEventHistory.setLastFromIndex(event.getFromIndex()); + textEventHistory.setLastToIndex(event.getToIndex()); + } + if ((event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) + && (voiceActionDelegate != null) + && voiceActionDelegate.isVoiceRecognitionActive()) { + LogUtils.d( + TAG, "Drop TYPE_VIEW_TEXT_SELECTION_CHANGED event: Voice recognition is active."); + return; + } + } + break; + + default: + return; // Send only text-events to listeners / compositor. + } + + TextEventInterpreter.Interpretation interpretation = + new TextEventInterpreter.Interpretation(event, eventId, textEventInterpreted); + // Send interpretation to listeners. + for (InterpretationConsumer listener : listeners) { + listener.accept(interpretation); + } + } + + //////////////////////////////////////////////////////////////////////////////////////////// + // Methods for filtering text input events + + private boolean dropTextChangeEvent(AccessibilityEvent event) { + // Drop text change event if we're still waiting for a select event and + // the change occurred too soon after the previous change. + final long eventTime = event.getEventTime(); + if (textEventHistory.getTextChangesAwaitingSelection() > 0) { + + // If the state is still consistent, update the count and drop + // the event, except when running on locales that don't support + // text replacement due to character combination complexity. + final boolean hasDelayElapsed = + ((eventTime - textEventHistory.getLastTextChangeTime()) >= TEXT_CHANGED_DELAY); + final boolean hasPackageChanged = + !TextUtils.equals( + event.getPackageName(), textEventHistory.getLastTextChangePackageName()); + boolean canReplace = context.getResources().getBoolean(R.bool.supports_text_replacement); + if (!hasDelayElapsed && !hasPackageChanged && canReplace) { + textEventHistory.incrementTextChangesAwaitingSelection(1); + textEventHistory.setLastTextChangeTime(eventTime); + return true; + } + + // The state became inconsistent, so reset the counter. + textEventHistory.setTextChangesAwaitingSelection(0); + } + + return false; + } + + private KeyboardType getKeyboardType(long textEventTime) { + if (textEventTime - lastKeyEventTime < PHYSICAL_KEY_TIMEOUT) { + return KeyboardType.PHYSICAL; + } else { + return KeyboardType.ON_SCREEN; + } + } + + public boolean shouldEchoAddedText(long eventTime) { + return shouldEchoAddedText(getKeyboardType(eventTime)); + } + + private boolean shouldEchoAddedText(/* int changeType, */ KeyboardType keyboardType) { + if (keyboardType == KeyboardType.PHYSICAL) { + return physicalKeyboardEcho == PREF_ECHO_CHARACTERS + || physicalKeyboardEcho == PREF_ECHO_CHARACTERS_AND_WORDS; + } else if (keyboardType == KeyboardType.ON_SCREEN) { + return onScreenKeyboardEcho == PREF_ECHO_CHARACTERS + || onScreenKeyboardEcho == PREF_ECHO_CHARACTERS_AND_WORDS; + } + return false; + } + + public boolean shouldEchoInitialWords(long eventTime) { + return shouldEchoInitialWords(getKeyboardType(eventTime)); + } + + private boolean shouldEchoInitialWords(/* int changeType, */ KeyboardType keyboardType) { + if (keyboardType == KeyboardType.PHYSICAL) { + return physicalKeyboardEcho == PREF_ECHO_WORDS + || physicalKeyboardEcho == PREF_ECHO_CHARACTERS_AND_WORDS; + } else if (keyboardType == KeyboardType.ON_SCREEN) { + return onScreenKeyboardEcho == PREF_ECHO_WORDS + || onScreenKeyboardEcho == PREF_ECHO_CHARACTERS_AND_WORDS; + } + return false; + } + + private boolean shouldSkipCursorMovementEvent(AccessibilityEvent event) { + AccessibilityEvent lastKeptTextSelection = textEventHistory.getLastKeptTextSelection(); + if (lastKeptTextSelection == null) { + return false; + } + + // If event is at least X later than previous event, then keep it. + if (event.getEventTime() - lastKeptTextSelection.getEventTime() + > CURSOR_MOVEMENT_EVENTS_DELAY) { + textEventHistory.setLastKeptTextSelection(null); + return false; + } + + // If event has the same type as previous, it is from a different action, so keep it. + if (event.getEventType() == lastKeptTextSelection.getEventType()) { + return false; + } + + // If text-selection-change is followed by text-move-with-granularity, skip movement. + if (lastKeptTextSelection.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED + && event.getEventType() + == AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY) { + return true; + } + + return false; + } + + private boolean shouldDropTextSelectionEvent(AccessibilityEvent event) { + // Keep all events other than text-selection. + final int eventType = event.getEventType(); + if (eventType != AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) { + return false; + } + + // Drop selected events until we've matched the number of changed + // events. This prevents TalkBack from speaking automatic cursor + // movement events that result from typing. + if (textEventHistory.getTextChangesAwaitingSelection() > 0) { + final boolean hasDelayElapsed = + ((event.getEventTime() - textEventHistory.getLastTextChangeTime()) + >= TEXT_SELECTION_DELAY); + final boolean hasPackageChanged = + !TextUtils.equals( + event.getPackageName(), textEventHistory.getLastTextChangePackageName()); + + // If the state is still consistent, update the count and drop the event. + if (!hasDelayElapsed && !hasPackageChanged) { + textEventHistory.incrementTextChangesAwaitingSelection(-1); + textEventHistory.setLastFromIndex(event.getFromIndex()); + textEventHistory.setLastToIndex(event.getToIndex()); + textEventHistory.setLastNode(event.getSource()); + return true; + } + + // The state became inconsistent, so reset the counter. + textEventHistory.setTextChangesAwaitingSelection(0); + } + + // Drop selection events from views that don't have input focus. + final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event); + final AccessibilityNodeInfoCompat source = record.getSource(); + boolean isFocused = source != null && source.isFocused(); + AccessibilityNodeInfoUtils.recycleNodes(source); + if (!isFocused) { + LogUtils.d(TAG, "Dropped text-selection event from non-focused field"); + return true; + } + + return false; + } + +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventHistory.java b/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventHistory.java new file mode 100644 index 0000000..87d3f9c --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventHistory.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.input; + +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import com.google.android.accessibility.utils.AccessibilityEventUtils; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Wrapper around text event history data. This wrapper helps share data between EventFilter and + * TextEventInterpreter. + */ +public class TextEventHistory { + + private static final String TAG = "TextEventHistory"; + + //////////////////////////////////////////////////////////////////////////////////// + // Constants + + public static final int NO_INDEX = -1; + + //////////////////////////////////////////////////////////////////////////////////// + // Member data + + public boolean trace = false; + + // Event history used by text change events + private int mTextChangesAwaitingSelection = 0; + private long mLastTextChangeTime = -1; + private @Nullable CharSequence mLastTextChangePackageName; + private @Nullable AccessibilityEvent mLastKeptTextSelection; + + // Event history used by selection change events + private AccessibilityEvent mLastProcessedEvent; + private int mLastFromIndex = NO_INDEX; + private int mLastToIndex = NO_INDEX; + private @Nullable AccessibilityNodeInfo mLastNode; + + // ////////////////////////////////////////////////////////////////////////////////// + // Construction + + public TextEventHistory() {} + + //////////////////////////////////////////////////////////////////////////////////// + // Methods to get/set member data + + public void setTextChangesAwaitingSelection(int changes) { + mTextChangesAwaitingSelection = changes; + traceSet("TextChangesAwaitingSelection", changes); + } + + public void incrementTextChangesAwaitingSelection(int increment) { + mTextChangesAwaitingSelection += increment; + traceSet("TextChangesAwaitingSelection", mTextChangesAwaitingSelection); + } + + public int getTextChangesAwaitingSelection() { + return mTextChangesAwaitingSelection; + } + + public void setLastTextChangeTime(long time) { + mLastTextChangeTime = time; + traceSet("LastTextChangeTime", time); + } + + public long getLastTextChangeTime() { + return mLastTextChangeTime; + } + + public void setLastTextChangePackageName(CharSequence name) { + mLastTextChangePackageName = name; + traceSet("LastTextChangePackageName", name); + } + + public @Nullable CharSequence getLastTextChangePackageName() { + return mLastTextChangePackageName; + } + + /** Caller must recycle event. */ + public void setLastKeptTextSelection(@Nullable AccessibilityEvent event) { + mLastKeptTextSelection = AccessibilityEventUtils.replaceWithCopy(mLastKeptTextSelection, event); + traceSet("LastKeptTextSelection", "(object)"); + } + + public @Nullable AccessibilityEvent getLastKeptTextSelection() { + return mLastKeptTextSelection; + } + + public void setLastFromIndex(int index) { + mLastFromIndex = index; + traceSet("LastFromIndex", index); + } + + public int getLastFromIndex() { + return mLastFromIndex; + } + + public void setLastToIndex(int index) { + mLastToIndex = index; + traceSet("LastToIndex", index); + } + + public int getLastToIndex() { + return mLastToIndex; + } + + /** TextEventHistory will recycle newNode. */ + public void setLastNode(@Nullable AccessibilityNodeInfo newNode) { + try { + AccessibilityNodeInfoUtils.recycleNodes(mLastNode); + mLastNode = newNode; + newNode = null; + traceSet("LastNode", "(object)"); + } finally { + AccessibilityNodeInfoUtils.recycleNodes(newNode); + } + } + + public @Nullable AccessibilityNodeInfo getLastNode() { + return mLastNode; + } + + /** Caller must recycle newEvent. */ + // incompatible types in assignment. + @SuppressWarnings("nullness:assignment") + public void setLastProcessedEvent(AccessibilityEvent newEvent) { + mLastProcessedEvent = AccessibilityEventUtils.replaceWithCopy(mLastProcessedEvent, newEvent); + traceSet("LastProcessedEvent", "(object)"); + } + + //////////////////////////////////////////////////////////////////////////////////// + // Methods to log set operations + + private void traceSet(String member, T value) { + if (!trace) { + return; + } + LogUtils.v(TAG, "set %s = %s", member, value == null ? "" : value.toString()); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventInterpretation.java b/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventInterpretation.java new file mode 100644 index 0000000..a4260d5 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventInterpretation.java @@ -0,0 +1,290 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.input; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import com.google.android.accessibility.utils.ReadOnly; +import com.google.android.accessibility.utils.StringBuilderUtils; +import java.lang.annotation.Retention; + +/** + * Data structure containing a more specific event type, along with extracted data from the event. + */ +public class TextEventInterpretation extends ReadOnly { + + //////////////////////////////////////////////////////////////////////////////////// + // Constants + + // Text-events start outside the range of AccessibilityEvent.getEventType() + private static final int AFTER_ACCESSIBILITY_EVENTS = 0x40000001; + + public static final int TEXT_CLEAR = AFTER_ACCESSIBILITY_EVENTS + 1; + public static final int TEXT_REMOVE = AFTER_ACCESSIBILITY_EVENTS + 2; + public static final int TEXT_ADD = AFTER_ACCESSIBILITY_EVENTS + 3; + public static final int TEXT_REPLACE = AFTER_ACCESSIBILITY_EVENTS + 4; + public static final int TEXT_PASSWORD_ADD = AFTER_ACCESSIBILITY_EVENTS + 5; + public static final int TEXT_PASSWORD_REMOVE = AFTER_ACCESSIBILITY_EVENTS + 6; + public static final int TEXT_PASSWORD_REPLACE = AFTER_ACCESSIBILITY_EVENTS + 7; + public static final int CHANGE_INVALID = AFTER_ACCESSIBILITY_EVENTS + 8; + public static final int SELECTION_FOCUS_EDIT_TEXT = AFTER_ACCESSIBILITY_EVENTS + 9; + public static final int SELECTION_MOVE_CURSOR_TO_BEGINNING = AFTER_ACCESSIBILITY_EVENTS + 10; + public static final int SELECTION_MOVE_CURSOR_TO_END = AFTER_ACCESSIBILITY_EVENTS + 11; + public static final int SELECTION_MOVE_CURSOR_NO_SELECTION = AFTER_ACCESSIBILITY_EVENTS + 12; + public static final int SELECTION_MOVE_CURSOR_WITH_SELECTION = AFTER_ACCESSIBILITY_EVENTS + 13; + public static final int SELECTION_MOVE_CURSOR_SELECTION_CLEARED = AFTER_ACCESSIBILITY_EVENTS + 14; + public static final int SELECTION_CUT = AFTER_ACCESSIBILITY_EVENTS + 15; + public static final int SELECTION_PASTE = AFTER_ACCESSIBILITY_EVENTS + 16; + public static final int SELECTION_TEXT_TRAVERSAL = AFTER_ACCESSIBILITY_EVENTS + 17; + public static final int SELECTION_SELECT_ALL = AFTER_ACCESSIBILITY_EVENTS + 18; + public static final int SELECTION_SELECT_ALL_WITH_KEYBOARD = AFTER_ACCESSIBILITY_EVENTS + 19; + public static final int SELECTION_RESET_SELECTION = AFTER_ACCESSIBILITY_EVENTS + 20; + + public static final int AFTER_TEXT_EVENTS = AFTER_ACCESSIBILITY_EVENTS + 100; + + /** Identity numbers for interpreted text events. */ + @IntDef({ + TEXT_CLEAR, + TEXT_REMOVE, + TEXT_ADD, + TEXT_REPLACE, + TEXT_PASSWORD_ADD, + TEXT_PASSWORD_REMOVE, + TEXT_PASSWORD_REPLACE, + CHANGE_INVALID, + SELECTION_FOCUS_EDIT_TEXT, + SELECTION_MOVE_CURSOR_TO_BEGINNING, + SELECTION_MOVE_CURSOR_TO_END, + SELECTION_MOVE_CURSOR_NO_SELECTION, + SELECTION_MOVE_CURSOR_WITH_SELECTION, + SELECTION_MOVE_CURSOR_SELECTION_CLEARED, + SELECTION_CUT, + SELECTION_PASTE, + SELECTION_TEXT_TRAVERSAL, + SELECTION_SELECT_ALL, + SELECTION_SELECT_ALL_WITH_KEYBOARD, + SELECTION_RESET_SELECTION, + }) + @Retention(SOURCE) + public @interface TextEvent {} + + //////////////////////////////////////////////////////////////////////////////////// + // Member data + + private int event; // Union of @TextEvent & AccessibilityEvent.getEventType() + private String mReason; + + private boolean mIsCutAction = false; + private boolean mIsPasteAction = false; + + @Nullable private CharSequence textOrDescription; + private CharSequence mRemovedText; + private CharSequence mAddedText; + private CharSequence mInitialWord; + @Nullable private CharSequence mDeselectedText; + @Nullable private CharSequence mSelectedText; + @Nullable private CharSequence mTraversedText; + + //////////////////////////////////////////////////////////////////////////////////// + // Construction + + public TextEventInterpretation(int eventArg) { + event = eventArg; + } + + //////////////////////////////////////////////////////////////////////////////////// + // Methods to get/set data members + + public void setEvent(@TextEvent int event) { + checkIsWritable(); + this.event = event; + } + + public TextEventInterpretation setInvalid(String reason) { + setEvent(CHANGE_INVALID); + setReason(reason); + return this; + } + + @TextEvent + public int getEvent() { + return event; + } + + public void setReason(String reason) { + checkIsWritable(); + mReason = reason; + } + + public String getReason() { + return mReason; + } + + public void setIsCutAction(boolean isCut) { + checkIsWritable(); + mIsCutAction = isCut; + } + + public boolean getIsCutAction() { + return mIsCutAction; + } + + public void setIsPasteAction(boolean isPaste) { + checkIsWritable(); + mIsPasteAction = isPaste; + } + + public boolean getIsPasteAction() { + return mIsPasteAction; + } + + public void setTextOrDescription(@Nullable CharSequence text) { + checkIsWritable(); + textOrDescription = text; + } + + @Nullable + public CharSequence getTextOrDescription() { + return textOrDescription; + } + + public void setRemovedText(CharSequence removedText) { + checkIsWritable(); + mRemovedText = removedText; + } + + public CharSequence getRemovedText() { + return mRemovedText; + } + + public void setAddedText(CharSequence addedText) { + checkIsWritable(); + mAddedText = addedText; + } + + public CharSequence getAddedText() { + return mAddedText; + } + + public void setInitialWord(CharSequence initialWord) { + checkIsWritable(); + mInitialWord = initialWord; + } + + public CharSequence getInitialWord() { + return mInitialWord; + } + + public void setDeselectedText(@Nullable CharSequence text) { + checkIsWritable(); + mDeselectedText = text; + } + + @Nullable + public CharSequence getDeselectedText() { + return mDeselectedText; + } + + public void setSelectedText(@Nullable CharSequence text) { + checkIsWritable(); + mSelectedText = text; + } + + @Nullable + public CharSequence getSelectedText() { + return mSelectedText; + } + + public void setTraversedText(@Nullable CharSequence text) { + checkIsWritable(); + mTraversedText = text; + } + + @Nullable + public CharSequence getTraversedText() { + return mTraversedText; + } + + //////////////////////////////////////////////////////////////////////////////////// + // Methods to display the data + + /** Display only non-default fields. */ + @Override + public String toString() { + return StringBuilderUtils.joinFields( + StringBuilderUtils.optionalField("Event", eventTypeToString(event)), + StringBuilderUtils.optionalText("Reason", mReason), + StringBuilderUtils.optionalTag("isCut", mIsCutAction), + StringBuilderUtils.optionalTag("isPaste", mIsPasteAction), + StringBuilderUtils.optionalText("textOrDescription", textOrDescription), + StringBuilderUtils.optionalText("removedText", mRemovedText), + StringBuilderUtils.optionalText("addedText", mAddedText), + StringBuilderUtils.optionalText("initialWord", mInitialWord), + StringBuilderUtils.optionalText("deselectedText", mDeselectedText), + StringBuilderUtils.optionalText("selectedText", mSelectedText), + StringBuilderUtils.optionalText("traversedText", mTraversedText)); + } + + public static String eventTypeToString(@TextEvent int eventType) { + switch (eventType) { + case TEXT_CLEAR: + return "TEXT_CLEAR"; + case TEXT_REMOVE: + return "TEXT_REMOVE"; + case TEXT_ADD: + return "TEXT_ADD"; + case TEXT_REPLACE: + return "TEXT_REPLACE"; + case TEXT_PASSWORD_ADD: + return "TEXT_PASSWORD_ADD"; + case TEXT_PASSWORD_REMOVE: + return "TEXT_PASSWORD_REMOVE"; + case TEXT_PASSWORD_REPLACE: + return "TEXT_PASSWORD_REPLACE"; + case CHANGE_INVALID: + return "CHANGE_INVALID"; + case SELECTION_FOCUS_EDIT_TEXT: + return "SELECTION_FOCUS_EDIT_TEXT"; + case SELECTION_MOVE_CURSOR_TO_BEGINNING: + return "SELECTION_MOVE_CURSOR_TO_BEGINNING"; + case SELECTION_MOVE_CURSOR_TO_END: + return "SELECTION_MOVE_CURSOR_TO_END"; + case SELECTION_MOVE_CURSOR_NO_SELECTION: + return "SELECTION_MOVE_CURSOR_NO_SELECTION"; + case SELECTION_MOVE_CURSOR_WITH_SELECTION: + return "SELECTION_MOVE_CURSOR_WITH_SELECTION"; + case SELECTION_MOVE_CURSOR_SELECTION_CLEARED: + return "SELECTION_MOVE_CURSOR_SELECTION_CLEARED"; + case SELECTION_CUT: + return "SELECTION_CUT"; + case SELECTION_PASTE: + return "SELECTION_PASTE"; + case SELECTION_TEXT_TRAVERSAL: + return "SELECTION_TEXT_TRAVERSAL"; + case SELECTION_SELECT_ALL: + return "SELECTION_SELECT_ALL"; + case SELECTION_SELECT_ALL_WITH_KEYBOARD: + return "SELECTION_SELECT_ALL_WITH_KEYBOARD"; + case SELECTION_RESET_SELECTION: + return "SELECTION_RESET_SELECTION"; + default: + return "(unknown event " + eventType + ")"; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventInterpreter.java b/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventInterpreter.java new file mode 100644 index 0000000..f9d4214 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/input/TextEventInterpreter.java @@ -0,0 +1,794 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.input; + +import static com.google.android.accessibility.utils.input.TextEventHistory.NO_INDEX; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.TtsSpan; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import androidx.annotation.VisibleForTesting; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.AccessibilityEventUtils; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.accessibility.utils.BuildVersionUtils; +import com.google.android.accessibility.utils.EditTextActionHistory; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.input.TextEventFilter.KeyboardEchoType; +import com.google.android.accessibility.utils.monitor.VoiceActionDelegate; +import com.google.android.accessibility.utils.output.ActorStateProvider; +import com.google.android.accessibility.utils.output.SpeechCleanupUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.List; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Looks at current event & event history, to more specifically determine current event type, and to + * extract important pieces of event data. + */ +@TargetApi(Build.VERSION_CODES.M) +public class TextEventInterpreter { + + private static final String TAG = "TextEventInterpreter"; + + /////////////////////////////////////////////////////////////////////////////////// + // Inner classes + + /** Data-structure containing raw-event and interpretation, sent to listeners. */ + public static class Interpretation { + public final AccessibilityEvent event; + public final @Nullable EventId eventId; + public final @Nullable TextEventInterpretation interpretation; + + public Interpretation( + AccessibilityEvent event, + @Nullable EventId eventId, + @Nullable TextEventInterpretation interpretation) { + this.event = event; + this.eventId = eventId; + this.interpretation = interpretation; + } + } + + /** An interface for listening to generated TextEventInterpretations */ + public interface InterpretationConsumer { + /** Receives an interpreted text-change event. */ + void accept(Interpretation interpretation); + } + + /** A minimal interface to read text-selection state. */ + public interface SelectionStateReader { + boolean isSelectionModeActive(); + } + + // ///////////////////////////////////////////////////////////////////////////////// + // Member variables + + private final Context mContext; + @Nullable private final TextCursorTracker textCursorTracker; + @Nullable private final SelectionStateReader selectionStateReader; + private final InputModeManager mInputModeManager; + private final ActorStateProvider actorStateProvider; + private final TextEventFilter filter; + + // Event history + private TextEventHistory mHistory; + private final EditTextActionHistory.Provider actionHistory; + + // ///////////////////////////////////////////////////////////////////////////////// + // Construction + + public TextEventInterpreter( + Context context, + @Nullable TextCursorTracker textCursorTracker, + @Nullable SelectionStateReader selectionStateReader, + InputModeManager inputModeManager, + TextEventHistory history, + EditTextActionHistory.Provider actionHistory, + ActorStateProvider actorStateProvider, + @Nullable VoiceActionDelegate voiceActionDelegate) { + mContext = context; + this.textCursorTracker = textCursorTracker; + this.selectionStateReader = selectionStateReader; + mInputModeManager = inputModeManager; + mHistory = history; + this.actionHistory = actionHistory; + this.actorStateProvider = actorStateProvider; + this.filter = new TextEventFilter(context, textCursorTracker, mHistory); + this.filter.setVoiceActionDelegate(voiceActionDelegate); + } + + /** Add a listener for text-event-intperpretations, which may be produced asynchronously. */ + public void addListener(InterpretationConsumer listener) { + filter.addListener(listener); + } + + public void setOnScreenKeyboardEcho(@KeyboardEchoType int value) { + filter.setOnScreenKeyboardEcho(value); + } + + public void setPhysicalKeyboardEcho(@KeyboardEchoType int value) { + filter.setPhysicalKeyboardEcho(value); + } + + public void setLastKeyEventTime(long time) { + filter.setLastKeyEventTime(time); + } + + /////////////////////////////////////////////////////////////////////////////////// + // Methods to interpret event based on event content and event history + + /** Extract text event interpretation data from event, and send to listeners. */ + public void interpret(@NonNull AccessibilityEvent event, @Nullable EventId eventId) { + filter.updateTextCursorTracker(event, eventId); + boolean shouldEchoAddedText = filter.shouldEchoAddedText(event.getEventTime()); + boolean shouldEchoInitialWords = filter.shouldEchoInitialWords(event.getEventTime()); + + @Nullable TextEventInterpretation interpretation = + interpret(event, shouldEchoAddedText, shouldEchoInitialWords); + + filter.filterAndSendInterpretation(event, eventId, interpretation); + } + + /** Extract a text event interpretation data from event. May return null. */ + @VisibleForTesting + @Nullable TextEventInterpretation interpret( + AccessibilityEvent event, boolean shouldEchoAddedText, boolean shouldEchoInitialWords) { + // Interpret more specific event type. + int eventType = event.getEventType(); + TextEventInterpretation interpretation; + switch (eventType) { + case AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED: + interpretation = interpretTextChange(event); + break; + + case AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED: + case AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY: + interpretation = interpretSelectionChange(event); + break; + + case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED: + // To get initial cursor position of EditText + @Nullable AccessibilityNodeInfoCompat source = AccessibilityEventUtils.sourceCompat(event); + if (source != null && source.isFocused() && source.isEditable()) { + if (!sourceEqualsLastNode(event)) { + mHistory.setLastFromIndex(source.getTextSelectionStart()); + mHistory.setLastToIndex(source.getTextSelectionEnd()); + setHistoryLastNode(event); + } + } + return null; + + default: + return null; + } + + switch (interpretation.getEvent()) { + case AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED: + case TextEventInterpretation.TEXT_CLEAR: + case TextEventInterpretation.TEXT_ADD: + case TextEventInterpretation.TEXT_REPLACE: + case TextEventInterpretation.TEXT_PASSWORD_ADD: + case TextEventInterpretation.TEXT_PASSWORD_REMOVE: + case TextEventInterpretation.TEXT_PASSWORD_REPLACE: + if (!shouldEchoAddedText) { + interpretation.setAddedText(""); + } + if (!shouldEchoInitialWords) { + interpretation.setInitialWord(""); + } + break; + case TextEventInterpretation.TEXT_REMOVE: + // Always echo the Text Remove event + default: + break; + } + + // Display interpretation, seal interpretation. + interpretation.setReadOnly(); + LogUtils.i(TAG, "interpretation: %s", interpretation); + return interpretation; + } + + private TextEventInterpretation interpretTextChange(AccessibilityEvent event) { + // Default to original event type. + int eventType = event.getEventType(); + TextEventInterpretation interpretation = new TextEventInterpretation(eventType); + + // Case for handling password. + if (event.isPassword()) { + int removed = event.getRemovedCount(); + int added = event.getAddedCount(); + if ((added <= 0) && (removed <= 0)) { + interpretation.setEvent(TextEventInterpretation.CHANGE_INVALID); + } else if ((added == 1) && (removed <= 0)) { + interpretation.setEvent(TextEventInterpretation.TEXT_PASSWORD_ADD); + } else if ((added <= 0) && (removed == 1)) { + interpretation.setEvent(TextEventInterpretation.TEXT_PASSWORD_REMOVE); + } else if (isJunkyCharacterReplacedByBulletInUnlockPinEntry(event)) { + return interpretation.setInvalid( + "Junky text change event when the number typed in pin entry is replaced by bullet."); + } else { + interpretation.setEvent(TextEventInterpretation.TEXT_PASSWORD_REPLACE); + } + interpretation.setReason("Event is password and not speaking passwords."); + return interpretation; + } + + // Validity check + if (!isValid(event)) { + return interpretation.setInvalid("isValid() is false."); + } + + // Check for ongoing cut/paste. + if (actionHistory.hasCutActionAtTime(event.getEventTime())) { + interpretation.setIsCutAction(true); + } else if (actionHistory.hasPasteActionAtTime(event.getEventTime())) { + interpretation.setIsPasteAction(true); + } + + // If no text was added but all the previous text was removed, text was cleared. + if (event.getRemovedCount() > 1 + && event.getAddedCount() == 0 + && event.getBeforeText().length() == event.getRemovedCount()) { + interpretation.setEvent(TextEventInterpretation.TEXT_CLEAR); + interpretation.setReason("Cleared number of characters equal to field content length."); + return interpretation; + } + + // Extract added/removed text from event. + CharSequence removedText = getRemovedText(event); + CharSequence addedText = getAddedText(event); + if (removedText == null) { + return interpretation.setInvalid("removedText is null."); + } + if (addedText == null) { + return interpretation.setInvalid("addedText is null."); + } + if (TextUtils.equals(addedText, removedText)) { + return interpretation.setInvalid("addedText is the same as removedText."); + } + + // Translate partial replacement into net addition / deletion. + final int removedLength = removedText.length(); + final int addedLength = addedText.length(); + if (removedLength > addedLength) { + if (TextUtils.regionMatches(removedText, 0, addedText, 0, addedLength)) { + removedText = getSubsequenceWithSpans(removedText, addedLength, removedLength); + // Prevent TapPresubmit alert for Nullable annotation conflict + removedText = (removedText == null) ? "" : removedText; + addedText = ""; + } + } else if (addedLength > removedLength) { + if (TextUtils.regionMatches(removedText, 0, addedText, 0, removedLength)) { + removedText = ""; + addedText = getSubsequenceWithSpans(addedText, removedLength, addedLength); + // Prevent TapPresubmit alert for Nullable annotation conflict + addedText = (addedText == null) ? "" : addedText; + } + } + interpretation.setRemovedText(removedText); + interpretation.setAddedText(addedText); + + // Apply speech clean up rules. Example: changing "A" to "capital A". + final CharSequence cleanRemovedText = SpeechCleanupUtils.cleanUp(mContext, removedText); + final CharSequence cleanAddedText = SpeechCleanupUtils.cleanUp(mContext, addedText); + if (isJunkyCharacterReplacedByBulletInUnlockPinEntry(event)) { + return interpretation.setInvalid( + "Junky text change event when the number typed in pin entry is replaced by bullet."); + } + + // Text added + if (!TextUtils.isEmpty(cleanAddedText)) { + boolean replacementSupported = + mContext.getResources().getBoolean(R.bool.supports_text_replacement); + if (appendLastWordIfNeeded(event, interpretation) + || TextUtils.isEmpty(cleanRemovedText) + || TextUtils.equals(cleanAddedText, cleanRemovedText) + || (!replacementSupported)) { + interpretation.setEvent(TextEventInterpretation.TEXT_ADD); + } else { + interpretation.setEvent(TextEventInterpretation.TEXT_REPLACE); + } + interpretation.setReason("cleanAddedText is not empty."); + return interpretation; + } + + // Text removed + if (!TextUtils.isEmpty(cleanRemovedText)) { + interpretation.setEvent(TextEventInterpretation.TEXT_REMOVE); + interpretation.setReason("cleanRemovedText is not empty."); + return interpretation; + } + + return interpretation.setInvalid("addedText and removedText are both empty."); + } + + /** + * Returns {@code true} if it's a junky {@link AccessibilityEvent#TYPE_VIEW_TEXT_CHANGED} when + * typing pin code in unlock screen. + * + *

Starting from P, pin entry at lock screen files text changed event when the digit typed in + * is visually replaced by bullet. We don't want to announce this change. + * + *

Fortunately, password field at lock screen doesn't have this issue. + */ + private static boolean isJunkyCharacterReplacedByBulletInUnlockPinEntry( + AccessibilityEvent event) { + if (!BuildVersionUtils.isAtLeastP() + || (event.getAddedCount() != 1) + || (event.getRemovedCount() != 1)) { + return false; + } + return AccessibilityNodeInfoUtils.isPinEntry(event.getSource()); + } + + private TextEventInterpretation interpretSelectionChange(AccessibilityEvent event) { + // Default to original event type. + int eventType = event.getEventType(); + TextEventInterpretation interpretation = new TextEventInterpretation(eventType); + + // Extract text from input field. + final boolean isGranularTraversal = + (event.getEventType() + == AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY); + @Nullable final CharSequence text; + if (isGranularTraversal) { + // Gets text from node instead of event to prevent missing locale spans. + @Nullable AccessibilityNodeInfoCompat nodeToAnnounce = + AccessibilityEventUtils.sourceCompat(event); + text = AccessibilityNodeInfoUtils.getNodeText(nodeToAnnounce); + } else { + // Only use the first item from getText(). + text = getEventText(event); + } + interpretation.setTextOrDescription(text); + + // Don't provide selection feedback when there's no text. We have to + // check the item count separately to avoid speaking hint text, + // which always has an item count of zero even though the event text + // is not empty. Note that, on <= M, password text is empty but the count is nonzero. + final int count = event.getItemCount(); + boolean isPassword = event.isPassword(); + if ((TextUtils.isEmpty(text) && !isPassword) || (count == 0)) { + // In Android O, we rely on TEXT_SELECTION_CHANGED events to announce text changes in password + // field. Thus even though we don't announce anything in this case, we need to carefully + // update the index. + @Nullable AccessibilityNodeInfoCompat source = AccessibilityEventUtils.sourceCompat(event); + if (AccessibilityNodeInfoUtils.isEmptyEditTextRegardlessOfHint(source)) { + mHistory.setLastFromIndex(0); + mHistory.setLastToIndex(0); + } + return interpretation.setInvalid("Text is empty."); + } + + // Check whether event state requires resetting selection. + if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED + && actorStateProvider.resettingNodeCursor()) { + interpretation.setEvent(TextEventInterpretation.SELECTION_RESET_SELECTION); + interpretation.setReason("Event state is EVENT_SKIP_SELECTION_CHANGED..."); + return interpretation; + } + + if (textCursorTracker == null) { + interpretation.setInvalid("textCursorTracker is null."); + return interpretation; + } + + int toIndex = event.getToIndex(); + int fromIndex = event.getFromIndex(); + int previousCursorPos = textCursorTracker.getPreviousCursorPosition(); + int currentCursorPos = textCursorTracker.getCurrentCursorPosition(); + int textLength = TextUtils.isEmpty(text) ? 0 : text.length(); + boolean isSelectionModeActive = + (selectionStateReader != null) && selectionStateReader.isSelectionModeActive(); + + int eventTypeInt = eventType; + long eventTime = event.getEventTime(); + boolean hasKeyboardAction = + (mInputModeManager.getInputMode() == InputModeManager.INPUT_MODE_KEYBOARD); + if (eventTypeInt == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) { + if (!sourceEqualsLastNode(event)) { + interpretation.setEvent(TextEventInterpretation.SELECTION_FOCUS_EDIT_TEXT); + interpretation.setReason("source != mLastNode"); + // Update history. + mHistory.setLastFromIndex(NO_INDEX); + mHistory.setLastToIndex(NO_INDEX); + setHistoryLastNode(event); + return interpretation; + } else if (actionHistory.hasCutActionAtTime(eventTime) && fromIndex == toIndex) { + interpretation.setEvent(TextEventInterpretation.SELECTION_CUT); + interpretation.setReason("Cut action ongoing and from==to"); + return interpretation; + } else if (actionHistory.hasPasteActionAtTime(eventTime)) { + interpretation.setEvent(TextEventInterpretation.SELECTION_PASTE); + interpretation.setReason("Paste action ongoing."); + return interpretation; + } else if (fromIndex == 0 + && toIndex == 0 + && previousCursorPos == 0 + && currentCursorPos == 0) { + interpretation.setEvent(TextEventInterpretation.SELECTION_MOVE_CURSOR_TO_BEGINNING); + interpretation.setReason("All cursor positions == 0"); + return interpretation; + } else if (fromIndex == textLength + && toIndex == textLength + && previousCursorPos == textLength + && currentCursorPos == textLength) { + interpretation.setEvent(TextEventInterpretation.SELECTION_MOVE_CURSOR_TO_END); + interpretation.setReason("All cursor positions == textLength"); + return interpretation; + } else if (fromIndex == 0 + && toIndex == textLength + && actionHistory.hasSelectAllActionAtTime(eventTime)) { + interpretation.setEvent(TextEventInterpretation.SELECTION_SELECT_ALL); + interpretation.setReason("Select-all ongoing and from==0 and to==textLength"); + return interpretation; + } else if (fromIndex == toIndex // Selection is empty. + && mHistory.getLastFromIndex() == mHistory.getLastToIndex() // Prev select empty + && toIndex == currentCursorPos // Cursor location is valid. + && mHistory.getLastToIndex() == previousCursorPos) { // Prev cursor is valid. + interpretation.setEvent(TextEventInterpretation.SELECTION_MOVE_CURSOR_NO_SELECTION); + interpretation.setReason("Cursor moved to end of selection."); + // Extract traversed text. + int startIndex = Math.min(mHistory.getLastToIndex(), toIndex); + int endIndex = Math.max(mHistory.getLastToIndex(), toIndex); + if (0 <= startIndex && endIndex <= textLength) { + CharSequence traversedText = getSubsequence(isPassword, text, startIndex, endIndex); + interpretation.setTraversedText(traversedText); + } + // Update history. + mHistory.setLastProcessedEvent(event); + return interpretation; + /** + * TODO refactor the following three cases when we get more information for the text + * selection action on physical keyboard. REFERTO + * + *

Sometimes TalkBack cannot distinguish between "select all" action and "move cursor + * within selection mode" action. In this case, we currently classify the ambiguous action + * with some preferences. + * + *

Suppose we use "|...|" to represent selection range. Example 1: "||hello" --> + * "|hello|" It can be achieved by Ctrl+A or Shift+Ctrl+right. Since there is no selection + * before the action, we prefer to classify it as a "select all" action, which lands on the + * first case beneath. Example 2: "|hello| world" --> "|hello world|" It can be achieved by + * Ctrl+A or Shift+Ctrl+right. Since there is already a selection before the action, we + * prefer to classify it as a "move cursor within selection mode" action, which lands on the + * second case. Example 3: "say |hello| to the world" --> "|say hello to the world|" It can + * only be achieved by Ctrl+A. Thus it lands on the third case, which is for general select + * all actions. + * + *

That's why we need to have the first case: "duplicated" SELECT_ALL_WITH_KEYBOARD. + */ + } else if (mHistory.getLastFromIndex() == mHistory.getLastToIndex() + && fromIndex == 0 + && toIndex == textLength + && hasKeyboardAction) { + interpretation.setEvent(TextEventInterpretation.SELECTION_SELECT_ALL_WITH_KEYBOARD); + interpretation.setReason("from==0 to==textLength and hasKeyboardAction"); + return interpretation; + } else if ((isSelectionModeActive || hasKeyboardAction) + && mHistory.getLastFromIndex() == fromIndex + && mHistory.getLastToIndex() == previousCursorPos + && toIndex == currentCursorPos) { + interpretation.setEvent(TextEventInterpretation.SELECTION_MOVE_CURSOR_WITH_SELECTION); + interpretation.setReason("Selecting and toIndex == cursorPosition"); + // Extract de/selected text. + CharSequence deselectedText = + getUnselectedText(isPassword, text, fromIndex, toIndex, mHistory.getLastToIndex()); + CharSequence selectedText = + getSelectedText(isPassword, text, fromIndex, toIndex, mHistory.getLastToIndex()); + interpretation.setDeselectedText(deselectedText); + interpretation.setSelectedText(selectedText); + // Update history. + mHistory.setLastProcessedEvent(event); + return interpretation; + } else if (fromIndex == 0 && toIndex == textLength && hasKeyboardAction) { + interpretation.setEvent(TextEventInterpretation.SELECTION_SELECT_ALL_WITH_KEYBOARD); + interpretation.setReason("from==0 to==textLength and hasKeyboardAction"); + return interpretation; + } else if (mHistory.getLastFromIndex() != mHistory.getLastToIndex() && fromIndex == toIndex) { + interpretation.setEvent(TextEventInterpretation.SELECTION_MOVE_CURSOR_SELECTION_CLEARED); + interpretation.setReason("mLastFromIndex != mLastToIndex && fromIndex == toIndex"); + return interpretation; + } + } else if (eventTypeInt + == AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY) { + + if (fromIndex >= 0 && fromIndex <= textLength && toIndex >= 0 && toIndex <= textLength) { + interpretation.setEvent(TextEventInterpretation.SELECTION_TEXT_TRAVERSAL); + interpretation.setReason("fromIndex and toIndex both within text range"); + + // Extract traversed text. + CharSequence traversedText = null; + if (event.getMovementGranularity() + == AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER) { + int charIndex = Math.min(fromIndex, toIndex); + if (0 <= charIndex && charIndex < textLength) { + traversedText = getSubsequenceWithSpans(text, charIndex, charIndex + 1); + } + } else { + traversedText = + getSubsequenceWithSpans( + text, Math.min(fromIndex, toIndex), Math.max(fromIndex, toIndex)); + } + interpretation.setTraversedText(traversedText); + return interpretation; + } + } + + // Update history for events which could not be interpreted in the above mentioned categories. + mHistory.setLastFromIndex(event.getFromIndex()); + mHistory.setLastToIndex(event.getToIndex()); + + return interpretation.setInvalid("Unhandled selection event."); + } + + // Visible for testing only. + protected void setHistoryLastNode(AccessibilityEvent event) { + mHistory.setLastNode(event.getSource()); // TextEventHistory will recycle source node. + } + + //////////////////////////////////////////////////////////////////////////////////////// + // Helper functions for text-change events. + + private static boolean isValid(AccessibilityEvent event) { + final List afterTexts = event.getText(); + final CharSequence afterText = + (afterTexts == null || afterTexts.isEmpty()) ? null : afterTexts.get(0); + + final CharSequence beforeText = event.getBeforeText(); + + // Special case for deleting all the text in an EditText with a + // hint, since the event text will contain the hint rather than an + // empty string. + int beforeTextLength = (beforeText == null) ? 0 : beforeText.length(); + if ((event.getAddedCount() == 0) && (event.getRemovedCount() == beforeTextLength)) { + return true; + } + + if (afterText == null || beforeText == null) { + return false; + } + + final int diff = (event.getAddedCount() - event.getRemovedCount()); + + return ((beforeText.length() + diff) == afterText.length()); + } + + @Nullable + private static CharSequence getRemovedText(AccessibilityEvent event) { + final CharSequence beforeText = event.getBeforeText(); + if (beforeText == null) { + return null; + } + + final int beforeBegIndex = event.getFromIndex(); + final int beforeEndIndex = beforeBegIndex + event.getRemovedCount(); + if (areInvalidIndices(beforeText, beforeBegIndex, beforeEndIndex)) { + return ""; + } + + return getSubsequenceWithSpans(beforeText, beforeBegIndex, beforeEndIndex); + } + + /** + * Returns {@code null}, empty string or the added text depending on the event. + * + *

For cases where event.getText() is null or bad size, text interpretation is expected to be + * set invalid with "addedText is null" in interpretTextChange(). Hence where event.getText() is + * null or bad size, we return null as returning an empty string here would bypass this condition + * and the text interpretation would be incorrect. + * + * @param event + * @return the added text. + */ + @Nullable + private static CharSequence getAddedText(AccessibilityEvent event) { + final List textList = event.getText(); + // noinspection ConstantConditions + if (textList == null || textList.size() > 1) { + LogUtils.w(TAG, "getAddedText: Text list was null or bad size"); + return null; + } + + // If the text was empty, the list will be empty. See the + // implementation for TextView.onPopulateAccessibilityEvent(). + if (textList.size() == 0) { + return ""; + } + + final CharSequence text = textList.get(0); + if (text == null) { + LogUtils.w(TAG, "getAddedText: First text entry was null"); + return null; + } + + final int addedBegIndex = event.getFromIndex(); + final int addedEndIndex = addedBegIndex + event.getAddedCount(); + if (areInvalidIndices(text, addedBegIndex, addedEndIndex)) { + LogUtils.w( + TAG, + "getAddedText: Invalid indices (%d,%d) for \"%s\"", + addedBegIndex, + addedEndIndex, + text); + return ""; + } + + return getSubsequenceWithSpans(text, addedBegIndex, addedEndIndex); + } + + private static boolean areInvalidIndices(CharSequence text, int begin, int end) { + return (begin < 0) || (end > text.length()) || (begin >= end); + } + + private boolean appendLastWordIfNeeded( + AccessibilityEvent event, TextEventInterpretation interpretation) { + final CharSequence text = getEventText(event); + final CharSequence addedText = getAddedText(event); + final int fromIndex = event.getFromIndex(); + + if (fromIndex > text.length()) { + LogUtils.w(TAG, "Received event with invalid fromIndex: %s", event); + return false; + } + + // Check if any visible text was added. + if (addedText != null) { + int trimmedLength = TextUtils.getTrimmedLength(addedText); + if (trimmedLength > 0) { + return false; + } + } + + final int breakIndex = getPrecedingWhitespace(text, fromIndex); + final CharSequence word = text.subSequence(breakIndex, fromIndex); + + // Did the user just type a word? + if (TextUtils.getTrimmedLength(word) == 0) { + return false; + } + + interpretation.setInitialWord(word); + return true; + } + + //////////////////////////////////////////////////////////////////////////////////////// + // Helper functions for selection-change events. + + private static CharSequence getEventText(AccessibilityEvent event) { + final List eventText = event.getText(); + + if (eventText.isEmpty()) { + return ""; + } + + return eventText.get(0); + } + + /** Returns index of first whitespace preceding fromIndex. */ + private static int getPrecedingWhitespace(CharSequence text, int fromIndex) { + if (fromIndex > text.length()) { + fromIndex = text.length(); + } + for (int i = (fromIndex - 1); i > 0; i--) { + if (Character.isWhitespace(text.charAt(i))) { + return i; + } + } + + return 0; + } + + // Visible for testing only. + protected boolean sourceEqualsLastNode(AccessibilityEvent event) { + @Nullable AccessibilityNodeInfo source = event.getSource(); + @Nullable AccessibilityNodeInfo lastNode = mHistory.getLastNode(); + return (source != null) && source.equals(lastNode); + } + + @Nullable + private CharSequence getUnselectedText( + boolean isPassword, + @Nullable CharSequence text, + int fromIndex, + int toIndex, + int lastToIndex) { + if (fromIndex < lastToIndex && toIndex < lastToIndex) { + return getSubsequence(isPassword, text, Math.max(fromIndex, toIndex), lastToIndex); + } else if (fromIndex > lastToIndex && toIndex > lastToIndex) { + return getSubsequence(isPassword, text, lastToIndex, Math.min(fromIndex, toIndex)); + } else { + return null; + } + } + + @Nullable + private CharSequence getSelectedText( + boolean isPassword, + @Nullable CharSequence text, + int fromIndex, + int toIndex, + int lastToIndex) { + if (fromIndex < toIndex && lastToIndex < toIndex) { + return getSubsequence(isPassword, text, Math.max(fromIndex, lastToIndex), toIndex); + } else if (fromIndex > toIndex && lastToIndex > toIndex) { + return getSubsequence(isPassword, text, toIndex, Math.min(fromIndex, lastToIndex)); + } else { + return null; + } + } + + /** + * Gets the subsequence {@code [from, to)} of the given text. If the text is a password and the + * password cannot be read aloud, then returns a suitable substitute description, such as + * "Character 3" or "Characters 3 to 4". + * + * @param isPassword whether the text input is a password input + * @param text the text from which we need to extract a subsequence (or for which the password + * substitution needs to be provided) + * @param from the beginning index (inclusive) + * @param to the ending index (exclusive) + * @return the requested subsequence or an alternate description for passwords, or null if range + * is invalid. + */ + @Nullable + private CharSequence getSubsequence( + boolean isPassword, @Nullable CharSequence text, int from, int to) { + if (isPassword) { + if (to - from == 1) { + return mContext.getString(R.string.template_password_traversed, from + 1); + } else { + return mContext.getString(R.string.template_password_selected, from + 1, to); + } + } else { + return getSubsequenceWithSpans(text, from, to); + } + } + + // REFERTO. Remove only TtsSpans marked up beyond the boundary of traversed text. + @Nullable + public static CharSequence getSubsequenceWithSpans( + @Nullable CharSequence text, int from, int to) { + if (text == null) { + return null; + } + if (from < 0 || text.length() < to || to < from) { + return null; + } + + SpannableString textWithSpans = SpannableString.valueOf(text); + CharSequence subsequence = text.subSequence(from, to); + SpannableString subsequenceWithSpans = SpannableString.valueOf(subsequence); + TtsSpan[] spans = subsequenceWithSpans.getSpans(0, subsequence.length(), TtsSpan.class); + + for (TtsSpan span : spans) { + if (textWithSpans.getSpanStart(span) < from || to < textWithSpans.getSpanEnd(span)) { + subsequenceWithSpans.removeSpan(span); + } + } + return subsequence; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/input/WindowEventInterpreter.java b/utils/src/main/java/com/google/android/accessibility/utils/input/WindowEventInterpreter.java new file mode 100644 index 0000000..3d33786 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/input/WindowEventInterpreter.java @@ -0,0 +1,1701 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils.input; + +import static androidx.core.view.accessibility.AccessibilityEventCompat.CONTENT_CHANGE_TYPE_PANE_APPEARED; +import static androidx.core.view.accessibility.AccessibilityEventCompat.CONTENT_CHANGE_TYPE_PANE_DISAPPEARED; +import static androidx.core.view.accessibility.AccessibilityEventCompat.CONTENT_CHANGE_TYPE_PANE_TITLE; +import static com.google.android.accessibility.utils.AccessibilityEventUtils.WINDOW_ID_NONE; +import static com.google.android.accessibility.utils.Role.ROLE_NONE; + +import android.accessibilityservice.AccessibilityService; +import android.annotation.TargetApi; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.graphics.Rect; +import android.os.Build; +import android.os.Handler; +import android.os.Message; +import android.text.TextUtils; +import android.util.SparseArray; +import android.view.Display; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityWindowInfo; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityWindowInfoCompat; +import com.google.android.accessibility.utils.AccessibilityEventUtils; +import com.google.android.accessibility.utils.AccessibilityNode; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.accessibility.utils.AccessibilityServiceCompatUtils; +import com.google.android.accessibility.utils.AccessibilityWindowInfoUtils; +import com.google.android.accessibility.utils.BuildVersionUtils; +import com.google.android.accessibility.utils.FeatureSupport; +import com.google.android.accessibility.utils.Filter; +import com.google.android.accessibility.utils.LogDepth; +import com.google.android.accessibility.utils.Performance; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.Performance.EventIdAnd; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.ReadOnly; +import com.google.android.accessibility.utils.Role; +import com.google.android.accessibility.utils.Role.RoleName; +import com.google.android.accessibility.utils.SettingsUtils; +import com.google.android.accessibility.utils.StringBuilderUtils; +import com.google.android.accessibility.utils.WindowUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.auto.value.AutoValue; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Translates Accessibility Window Events into more usable event description. + * + *

A major difficulty is floods of transitional window events, many of which may be redundant. + * Also, some window-events originate from WindowManager, others from the Views, and both sources of + * information must be merged. To solve for this, when a window event is received, interpretation is + * delayed, and window changes are buffered in pendingWindowRoles until the delayed interpretation + * is complete. + * + *

Android P changes: + * + *

    + *
  • No longer get TYPE_WINDOW_STATE_CHANGED announcement when volume controls are hidden. + *
  • More windows returned by getWindowsOnAllDisplays(), including multiple system windows. + *
+ */ +public class WindowEventInterpreter implements WindowsDelegate { + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Constants + + private static final boolean DISPLAY_PERFORMANCE = false; + + private static final String TAG = "WindowEventInterpreter"; + private static final int WINDOW_TYPE_NONE = -1; + + // Maximum delay times to interpret events and avoid incomplete transitional data. + private static final int PIC_IN_PIC_DELAY_MS = 300; + public static final int WINDOW_CHANGE_DELAY_MS = 550; + public static final int WINDOW_CHANGE_DELAY_NO_ANIMATION_MS = 200; + private static final int ACCESSIBILITY_OVERLAY_DELAY_MS = 150; + + // Delay between repeated event-interpretation events. 300ms delay is enough to prevent + // transitional split-screen announcement on android-Q + pixel-2. + // TODO: Add minimum-delay-time only for split-screen on older android. + private static final int DELAY_INCREMENT_MS = 10; + + private static final int WINDOWS_CHANGE_TYPES_USED = + AccessibilityEvent.WINDOWS_CHANGE_ADDED + | AccessibilityEvent.WINDOWS_CHANGE_TITLE + | AccessibilityEvent.WINDOWS_CHANGE_REMOVED + | AccessibilityEvent.WINDOWS_CHANGE_PIP; + private static final int PANE_CONTENT_CHANGE_TYPES = + CONTENT_CHANGE_TYPE_PANE_TITLE + | CONTENT_CHANGE_TYPE_PANE_APPEARED + | CONTENT_CHANGE_TYPE_PANE_DISAPPEARED; + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Inner classes + + /** Caches window-data from window-event. */ + public static class Window { + public Window() {} + + /** + * The most recent title, from either window events or pane events. Used to provide screen + * feedback. + */ + public CharSequence title; + + /** Title from events windows-changed & window-state-changed excluding pane-changed. */ + private @Nullable CharSequence titleFromWindowChange; + + /** Title from TYPE_WINDOW_STATE_CHANGED events that are not announcements. */ + private @Nullable CharSequence titleFromStateChange; + + public void setTitleFromWindowChange(@Nullable CharSequence newTitle) { + if (!TextUtils.equals(this.titleFromWindowChange, newTitle)) { + this.titleFromStateChange = null; + } + this.titleFromWindowChange = newTitle; + } + + public @Nullable CharSequence getTitleFromWindowChange() { + return titleFromWindowChange; + } + + public void setTitleFromStateChange(@Nullable CharSequence newTitle) { + this.titleFromStateChange = newTitle; + } + + public @Nullable CharSequence getTitleFromStateChange() { + return titleFromStateChange; + } + + @RoleName public int eventSourceRole = ROLE_NONE; + public @Nullable CharSequence eventPackageName; + + @Override + public String toString() { + return "{ " + + StringBuilderUtils.joinFields( + StringBuilderUtils.optionalText("title", title), + StringBuilderUtils.optionalText("titleFromWindowChange", titleFromWindowChange), + StringBuilderUtils.optionalText( + "eventSourceRole", Role.roleToString(eventSourceRole)), + StringBuilderUtils.optionalText("eventPackageName", eventPackageName)) + + "}"; + } + } + + /** Caches data from non-main-window announcements. */ + @AutoValue + public abstract static class Announcement { + public static Announcement create( + CharSequence text, + @Nullable CharSequence packageName, + boolean isFromVolumeControlPanel, + boolean isFromInputMethodEditor) { + return new AutoValue_WindowEventInterpreter_Announcement( + text, packageName, isFromVolumeControlPanel, isFromInputMethodEditor); + } + + public abstract CharSequence text(); + + public abstract @Nullable CharSequence packageName(); + + public abstract boolean isFromVolumeControlPanel(); + + public abstract boolean isFromInputMethodEditor(); + } + + /** + * Assignment of windows to roles. Encapsulated in a data-struct, to allow temporary assignment of + * roles. + */ + public static class WindowRoles { + // Window A: In split screen mode, left (right in RTL) or top window. In full screen mode, the + // current window. + public int windowIdA = WINDOW_ID_NONE; + public @Nullable CharSequence windowTitleA; + + // Window B: In split screen mode, right (left in RTL) or bottom window. This must be + // WINDOW_ID_NONE in full screen mode. + public int windowIdB = WINDOW_ID_NONE; + public @Nullable CharSequence windowTitleB; + + // Accessibility overlay window + public int accessibilityOverlayWindowId = WINDOW_ID_NONE; + public @Nullable CharSequence accessibilityOverlayWindowTitle; + + // Picture-in-picture window history. + public int picInPicWindowId = WINDOW_ID_NONE; + public @Nullable CharSequence picInPicWindowTitle; + + // Input method window + public int inputMethodWindowId = WINDOW_ID_NONE; + public @Nullable CharSequence inputMethodWindowTitle; + + public WindowRoles() {} + + public WindowRoles(WindowRoles oldRoles) { + windowIdA = oldRoles.windowIdA; + windowTitleA = oldRoles.windowTitleA; + windowIdB = oldRoles.windowIdB; + windowTitleB = oldRoles.windowTitleB; + accessibilityOverlayWindowId = oldRoles.accessibilityOverlayWindowId; + accessibilityOverlayWindowTitle = oldRoles.accessibilityOverlayWindowTitle; + picInPicWindowId = oldRoles.picInPicWindowId; + picInPicWindowTitle = oldRoles.picInPicWindowTitle; + inputMethodWindowId = oldRoles.inputMethodWindowId; + inputMethodWindowTitle = oldRoles.inputMethodWindowTitle; + } + + public void clear() { + windowIdA = WINDOW_ID_NONE; + windowTitleA = null; + windowIdB = WINDOW_ID_NONE; + windowTitleB = null; + accessibilityOverlayWindowId = WINDOW_ID_NONE; + accessibilityOverlayWindowTitle = null; + picInPicWindowId = WINDOW_ID_NONE; + picInPicWindowTitle = null; + inputMethodWindowId = WINDOW_ID_NONE; + inputMethodWindowTitle = null; + } + + @Override + public String toString() { + return String.format( + "a:%s:%s b:%s:%s accessOverlay:%s:%s picInPic:%s:%s inputMethod:%s:%s", + windowIdA, + windowTitleA, + windowIdB, + windowTitleB, + accessibilityOverlayWindowId, + accessibilityOverlayWindowTitle, + picInPicWindowId, + picInPicWindowTitle, + inputMethodWindowId, + inputMethodWindowTitle); + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Member data + + private final AccessibilityService service; + private final boolean isSplitScreenModeAvailable; + private final HashMap windowIdToData = new HashMap<>(); + // Caches the window roles from last window transition for comparison. + private WindowRoles windowRoles = new WindowRoles(); + private @Nullable WindowRoles pendingWindowRoles; + private int picInPicLastShownId = WINDOW_ID_NONE; // Last pic-in-pic window that was shown. + private long picInPicDisappearTime = 0; // Last time pic-in-pic was hidden. + // Announcement from event TYPE_WINDOW_STATE_CHANGED, to be spoken with next event-interpretation. + private @Nullable Announcement announcement; + + /** Preference to reduce delay before considering windows stable. */ + private boolean reduceDelayPref = false; + + private long screenTransitionStartTime = 0; + + /** + * Sets to {@code true} if receiving {@link AccessibilityEvent#TYPE_WINDOWS_CHANGED} and resets it + * after window transitions finished. + */ + public boolean areWindowsChanging = false; + + /** Flag whether IME transition happened recently. */ + private static boolean recentKeyboardWindowChange = false; + + private final WindowEventDelayer windowEventDelayer = new WindowEventDelayer(); + + private List listeners = new ArrayList<>(); + private final List priorityListeners = new ArrayList<>(); + + private final Performance.Statistics statisticsAboutDelay = new Performance.Statistics(); + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Construction + + public WindowEventInterpreter(AccessibilityService service) { + this.service = service; + boolean isArc = FeatureSupport.isArc(); + isSplitScreenModeAvailable = + BuildVersionUtils.isAtLeastN() && !FeatureSupport.isTv(service) && !isArc; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Methods + + public void setReduceDelayPref(boolean reduceDelayPref) { + this.reduceDelayPref = reduceDelayPref; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Methods + + public void clearScreenState() { + clearRoles(); + } + + @Override // WindowsDelegate + public CharSequence getWindowTitle(int windowId) { + return getWindowTitleForFeedback(windowId); + } + + private @Nullable CharSequence getWindowTitleInternal(int windowId) { + return getWindowTitleInternal(windowId, areWindowsChanging); + } + + /** + * Gets window title from window first if {@code windowInfoFirst} is true. Otherwise, gets from + * the cache which comes from the event or window. + */ + private @Nullable CharSequence getWindowTitleInternal(int windowId, boolean windowInfoFirst) { + CharSequence titleFromWindowInfo = getWindowTitleFromWindowInfo(windowId); + @Nullable Window window = windowIdToData.get(windowId); + + if (windowInfoFirst && !TextUtils.isEmpty(titleFromWindowInfo)) { + return titleFromWindowInfo; + } + + if (window != null && !TextUtils.isEmpty(window.title)) { + return window.title; + } + + return titleFromWindowInfo; + } + + private @Nullable CharSequence getWindowTitleFromWindowInfo(int windowId) { + if (!FeatureSupport.supportGetTitleFromWindows()) { + return null; + } + + List windows = getAllWindows(service); + for (AccessibilityWindowInfo window : windows) { + if (window.getId() == windowId) { + return AccessibilityWindowInfoUtils.getTitle(window); + } + } + return null; + } + + public boolean isSplitScreenModeAvailable() { + return isSplitScreenModeAvailable; + } + + @Override // WindowsDelegate + public boolean isSplitScreenMode(int displayId) { + if (!isSplitScreenModeAvailable) { + return false; + } + + List windows = + AccessibilityServiceCompatUtils.getWindowsOnAllDisplays(service).get(displayId); + if (windows == null) { + return false; + } + for (AccessibilityWindowInfo window : windows) { + if ((window != null) + && window.getType() == AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER) { + return true; + } + } + return false; + } + + /** + * Returns {@code true} if it is a supported {@link AccessibilityEvent#TYPE_WINDOWS_CHANGED} + * event. + */ + public static boolean isSupportedWindowsChange(AccessibilityEvent event) { + // On android P, only use window events with change-types that set window title or announce. + if (event == null || event.getEventType() != AccessibilityEvent.TYPE_WINDOWS_CHANGED) { + return false; + } + if (BuildVersionUtils.isAtLeastP() + && ((event.getWindowChanges() & WINDOWS_CHANGE_TYPES_USED) == 0)) { + return false; + } + return true; + } + + /** + * Returns {@code true} if it is a supported {@link AccessibilityEvent#TYPE_WINDOW_STATE_CHANGED} + * event. + */ + public boolean isSupportedWindowStateChange(AccessibilityEvent event) { + if (event == null || event.getEventType() != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { + return false; + } + + if (BuildVersionUtils.isAtLeastT()) { + switch (event.getContentChangeTypes()) { + case AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_APPEARED: + case AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_DISAPPEARED: + case AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_TITLE: + return true; + default: + LogUtils.d( + TAG, + "Dropping window state changed event %s if the source window is not anchored.", + event.toString()); + return AccessibilityEventUtils.hasAnchoredWindow(event); + } + } + + if (recentKeyboardWindowChange && isFromOnScreenKeyboard(event)) { + LogUtils.v( + TAG, + "IME transition happened and handled, so ignore the resting announcements from IME."); + return false; + } + + return true; + } + + /** Step 1: Define listener for delayed window events. */ + public interface WindowEventHandler { + void handle(EventInterpretation interpretation, @Nullable EventId eventId); + } + + /** Step 2: Add window event listener, that gets window-events before other listeners. */ + public void addPriorityListener(WindowEventHandler listener) { + priorityListeners.add(listener); + } + + /** Step 2: Add window event listener. */ + public void addListener(WindowEventHandler listener) { + listeners.add(listener); + } + + @VisibleForTesting + public void setListeners(WindowEventHandler listener) { + listeners = new ArrayList<>(); + addListener(listener); + } + + /** Step 3: Extract data from window event and related APIs. */ + @TargetApi(Build.VERSION_CODES.P) + public void interpret(AccessibilityEvent event, @Nullable EventId eventId) { + interpret(event, eventId, true); + } + + @TargetApi(Build.VERSION_CODES.P) + public void interpret(AccessibilityEvent event, @Nullable EventId eventId, boolean allowEvent) { + if (!isSupportedWindowsChange(event) && !isSupportedWindowStateChange(event)) { + return; + } + + LogUtils.v( + TAG, + "START interpret() event type=%s time=%s allowEvent=%s", + AccessibilityEventUtils.typeToString(event.getEventType()), + event.getEventTime(), + allowEvent); + int depth = 0; + + if (screenTransitionStartTime == 0) { + screenTransitionStartTime = event.getEventTime(); + } + InterpretationAndRoles interpAndRoles = interpretInternal(event, depth); + EventInterpretation interpretation = interpAndRoles.interpretation; + WindowRoles newWindowRoles = interpAndRoles.roles; + + // Check whether windows are stable. + long delayMs = calculateDelayMs(interpretation); + interpretation.setWindowsStable(delayMs == 0); + interpretation.setMaxDelay(delayMs); + interpretation.setAllowAnnounce(allowEvent); + LogDepth.log(TAG, depth, "interpret() delayMs=%s, interpretation=%s", delayMs, interpretation); + interpretation.setEventStartTime(screenTransitionStartTime); + + // Stop delayed interpretation efforts, since new non-empty interpretation is coming. + windowEventDelayer.removeMessages(WindowEventDelayer.MSG_DELAY_INTERPRET); + + if (delayMs == 0) { + setRoles(newWindowRoles); + LogUtils.v(TAG, "END interpret()"); + } else { + // Delay updating window roles, to find non-transitional role changes. But accumulate delayed + // role information in pendingWindowRoles, to allow delayed interpretation to have up-to-date + // window info. + // + // Saving all role updates breaks announcing "settings" and "home", because 2nd + // WINDOWS_CHANGED not different roles than 1st. Discarding role updates is preventing + // announcing home screen, because WINDOWS_CHANGED then WINDOW_STATE_CHANGED never updates + // roles. Base new role updates on pending roles, to allow WINDOW_STATE_CHANGED to know that + // window id changed in preceding WINDOWS_CHANGED. + pendingWindowRoles = newWindowRoles; + delayInterpret(interpretation, eventId); + } + + // Send an immediate window event interpretation, possibly with unstable windows. + notifyInterpretationListeners(interpretation, eventId); + } + + private InterpretationAndRoles interpretInternal(AccessibilityEvent event, int depth) { + LogDepth.log(TAG, depth, "interpret() windowRoles=%s", windowRoles); + // Create event interpretation. + EventInterpretation interpretation = new EventInterpretation(); + interpretation.setEventType(event.getEventType()); + interpretation.setOriginalEvent(true); + interpretation.setWindowIdFromEvent(AccessibilityEventUtils.getWindowId(event)); + if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { + if (BuildVersionUtils.isAtLeastP()) { + interpretation.setChangeTypes(event.getContentChangeTypes()); + } + // Update anchor node role for feedback announcement. + updateAnchorNodeRole(event, interpretation); + } else if (event.getEventType() == AccessibilityEvent.TYPE_WINDOWS_CHANGED) { + areWindowsChanging = true; + if (BuildVersionUtils.isAtLeastP()) { + interpretation.setChangeTypes(event.getWindowChanges()); + } + } + + final int displayId = AccessibilityEventUtils.getDisplayId(event); + interpretation.setDisplayId(displayId); + + // Sets the bounds of the source node when the source node of the event is available + AccessibilityNode sourceNode = AccessibilityNode.takeOwnership(event.getSource()); + try { + if (sourceNode != null) { + Rect rect = new Rect(); + sourceNode.getBoundsInScreen(rect); + interpretation.setSourceBoundsInScreen(rect); + } + } finally { + AccessibilityNode.recycle("WindowEventInterpreter.interpretInternal", sourceNode); + } + + // Collect old window information into interpretation. + setOldWindowInterpretation( + windowRoles.windowIdA, windowRoles.windowTitleA, interpretation.getWindowA()); + setOldWindowInterpretation( + windowRoles.windowIdB, windowRoles.windowTitleB, interpretation.getWindowB()); + setOldWindowInterpretation( + windowRoles.accessibilityOverlayWindowId, + windowRoles.accessibilityOverlayWindowTitle, + interpretation.getAccessibilityOverlay()); + setOldWindowInterpretation( + windowRoles.picInPicWindowId, + windowRoles.picInPicWindowTitle, + interpretation.getPicInPic()); + setOldWindowInterpretation( + windowRoles.inputMethodWindowId, + windowRoles.inputMethodWindowTitle, + interpretation.getInputMethod()); + + // Update stored windows for titles. + updateWindowTitles(event, interpretation, depth + 1); + syncAnnouncement(interpretation); + + // Map windows to roles, detect window role changes. + WindowRoles latestRoles = (pendingWindowRoles == null) ? windowRoles : pendingWindowRoles; + WindowRoles newWindowRoles = new WindowRoles(latestRoles); + updateWindowRoles(interpretation, service, newWindowRoles, depth + 1); + setWindowTitles(newWindowRoles); + detectWindowChanges(newWindowRoles, interpretation, depth + 1); + detectInputMethodChanged( + newWindowRoles, interpretation, /* checkDuplicate= */ false, depth + 1); + + // Detect picture-in-picture window change, ruling out temporary disappear & reappear. + boolean picInPicDisappearedRecently = + (event.getEventTime() < (picInPicDisappearTime + PIC_IN_PIC_DELAY_MS)); + boolean picInPicTemporarilyHidden = + (picInPicLastShownId == newWindowRoles.picInPicWindowId && picInPicDisappearedRecently); + boolean picInPicChanged = + (!picInPicTemporarilyHidden && interpretation.getPicInPic().idOrTitleChanged()); + // Update picture-in-picture history. + if (newWindowRoles.picInPicWindowId == WINDOW_ID_NONE) { + if (interpretation.getPicInPic().getOldId() != WINDOW_ID_NONE) { + picInPicDisappearTime = event.getEventTime(); + } + } else { + picInPicLastShownId = newWindowRoles.picInPicWindowId; + } + interpretation.setPicInPicChanged(picInPicChanged); + + return new InterpretationAndRoles(interpretation, newWindowRoles); + } + + private static class InterpretationAndRoles { + public final EventInterpretation interpretation; + public final WindowRoles roles; + + public InterpretationAndRoles(EventInterpretation interpretation, WindowRoles roles) { + this.interpretation = interpretation; + this.roles = roles; + } + } + + /** + * Delay the event to wait for next window event comes in below situations: + * + *
    + *
  • Delay for main window changed to update window title from the latest event. + *
  • Delay for input method changed and announcements to check duplicates between them in + * {@code detectInputMethodChanged}. + *
+ */ + private long calculateDelayMs(EventInterpretation interpretation) { + if (!interpretation.getMainWindowsChanged() + && !interpretation.getInputMethodChanged() + && (interpretation.getAnnouncement() == null)) { + return 0; + } + return (interpretation.getAccessibilityOverlay().getId() == WINDOW_ID_NONE) + ? getWindowTransitionDelayMs() + : ACCESSIBILITY_OVERLAY_DELAY_MS; + } + + /** Returns the current window-transition delay in milliseconds. */ + public long getWindowTransitionDelayMs() { + long delayMs = WINDOW_CHANGE_DELAY_MS; + if (reduceDelayPref && SettingsUtils.isAnimationDisabled(service)) { + delayMs = WINDOW_CHANGE_DELAY_NO_ANIMATION_MS; + } + return delayMs; + } + + /** Step 4: Delay event interpretation. */ + private class WindowEventDelayer extends Handler { + public static final int MSG_DELAY_INTERPRET = 1; + public static final int MSG_WAIT_ANNOUNCEMENT = 2; + + @Override + public void handleMessage(Message message) { + if (message.what == MSG_DELAY_INTERPRET) { + @SuppressWarnings("unchecked") + EventIdAnd eventIdAndInterpretation = + (EventIdAnd) message.obj; + delayedInterpret(eventIdAndInterpretation.object, eventIdAndInterpretation.eventId); + } else if (message.what == MSG_WAIT_ANNOUNCEMENT) { + recentKeyboardWindowChange = false; + LogUtils.v(TAG, "IME transition finished & start to support Announcement from IME."); + } + } + } + + public void clearQueue() { + windowEventDelayer.removeMessages(WindowEventDelayer.MSG_DELAY_INTERPRET); + clearWindowTransition(); + } + + /** + * Refresh the global flags. + * + *
    + *
  • Reset {@code windowRoles} if {@code clearRole} is true. Otherwise update to latest. + *
  • Reset other global flags which temporarily be used to cache data in delayed-event-queue. + *
+ */ + private void clearRoles() { + windowRoles.clear(); + picInPicLastShownId = WINDOW_ID_NONE; + picInPicDisappearTime = 0; + clearWindowTransition(); + } + + private void setRoles(@Nullable WindowRoles newWindowRoles) { + if (newWindowRoles != null) { + windowRoles = newWindowRoles; + } + clearWindowTransition(); + } + + private void clearWindowTransition() { + announcement = null; + pendingWindowRoles = null; + screenTransitionStartTime = 0; + areWindowsChanging = false; + } + + /** Step 5: After delay from "unstable" window events, re-run window interpretation. */ + void delayedInterpret(EventInterpretation interpretation, @Nullable EventId eventId) { + int depth = 0; + LogDepth.log(TAG, depth, "delayedInterpret()"); + + interpretation.setOriginalEvent(false); + + // Map windows to roles, detect window role changes. + WindowRoles latestRoles = (pendingWindowRoles == null) ? windowRoles : pendingWindowRoles; + WindowRoles newWindowRoles = new WindowRoles(latestRoles); + updateWindowRoles(interpretation, service, newWindowRoles, depth + 1); + setWindowTitles(newWindowRoles); + detectWindowChanges(newWindowRoles, interpretation, depth + 1); + detectInputMethodChanged(newWindowRoles, interpretation, /* checkDuplicate= */ true, depth + 1); + LogUtils.v(TAG, "END delayedInterpret() interpretation=%s", interpretation); + + // Assume windows are stable if they all have titles from state-change-events, or if maximum + // delay is reached. + if (interpretation.hasTitlesFromStateChange() + || (interpretation.getMaxDelayMs() <= interpretation.getTotalDelayMs())) { + interpretation.setWindowsStable(true); + setRoles(newWindowRoles); + notifyInterpretationListeners(interpretation, eventId); + } else { + delayInterpret(interpretation, eventId); + } + } + + private void delayInterpret(EventInterpretation interpretation, @Nullable EventId eventId) { + long delay = DELAY_INCREMENT_MS; + interpretation.incrementTotalDelayMs(delay); + + windowEventDelayer.sendMessageDelayed( + windowEventDelayer.obtainMessage( + WindowEventDelayer.MSG_DELAY_INTERPRET, new EventIdAnd<>(interpretation, eventId)), + delay); + } + + /** Send event interpretation to each listener. */ + private void notifyInterpretationListeners( + EventInterpretation interpretation, @Nullable EventId eventId) { + + if (DISPLAY_PERFORMANCE && interpretation.windowsStable) { + statisticsAboutDelay.increment(interpretation.getTotalDelayMs()); + Performance.displayStatistics(statisticsAboutDelay); + } + + for (WindowEventHandler listener : priorityListeners) { + listener.handle(interpretation, eventId); + } + for (WindowEventHandler listener : listeners) { + listener.handle(interpretation, eventId); + } + } + + /** Collect data about window into interpretation. */ + private static void setOldWindowInterpretation( + int oldWindowId, @Nullable CharSequence oldWindowTitle, WindowInterpretation interpretation) { + interpretation.setOldId(oldWindowId); + interpretation.setOldTitle(oldWindowTitle); + } + + private void setNewWindowInterpretation(int windowId, WindowInterpretation interpretation) { + interpretation.setId(windowId); + @Nullable CharSequence title = getWindowTitleInternal(windowId); + interpretation.setTitle(title); + + @Nullable Window window = windowIdToData.get(windowId); + interpretation.setTitleFromStateChange( + (window == null) ? null : window.getTitleFromStateChange()); + + interpretation.setTitleForFeedback(getWindowTitleForFeedback(windowId, title)); + } + + /** Updates anchor node role for interpretation if the source window has an anchor node. */ + private void updateAnchorNodeRole(AccessibilityEvent event, EventInterpretation interpretation) { + AccessibilityWindowInfo sourceWindow = AccessibilityNodeInfoUtils.getWindow(event.getSource()); + if (sourceWindow == null) { + return; + } + AccessibilityNodeInfoCompat anchorNode = AccessibilityWindowInfoUtils.getAnchor(sourceWindow); + if (anchorNode != null) { + interpretation.setAnchorNodeRole(Role.getRole(anchorNode)); + } + } + + private void syncAnnouncement(EventInterpretation interpretation) { + // Sync announcement to the latest interpretation. + if (interpretation.getAnnouncement() == null) { + interpretation.setAnnouncement(announcement); + } else { + announcement = interpretation.getAnnouncement(); + } + } + + private void updateWindowTitles( + AccessibilityEvent event, EventInterpretation interpretation, int depth) { + updateWindowTitlesImp(event, interpretation, depth + 1); + LogDepth.log(TAG, depth, "updateWindowTitles() windowIdToData=%s", windowIdToData); + } + + private void updateWindowTitlesImp( + AccessibilityEvent event, EventInterpretation interpretation, int depth) { + switch (event.getEventType()) { + case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED: + { + // If split screen mode is NOT available, we only need to care single window. + if (!isSplitScreenModeAvailable) { + windowIdToData.clear(); + } + + if (isPaneContentChangeTypes(event.getContentChangeTypes())) { + updateWindowTitleFromPane(event, depth); + return; + } + int windowId = AccessibilityEventUtils.getWindowId(event); + boolean shouldAnnounceEvent = shouldAnnounceWindowStateChange(event); + CharSequence text = + getTextFromWindowStateChange(event, /* useContentDescription= */ shouldAnnounceEvent); + if (!TextUtils.isEmpty(text)) { + if (shouldAnnounceEvent) { + // When software keyboard is shown or hidden, TYPE_WINDOW_STATE_CHANGED + // is dispatched with text describing the visibility of the keyboard. + // Volume control shade/dialog also files TYPE_WINDOW_STATE_CHANGED event when it's + // shown. + interpretation.setAnnouncement( + Announcement.create( + text, + event.getPackageName(), + AccessibilityEventUtils.isFromVolumeControlPanel(event), + isFromOnScreenKeyboard(event))); + LogDepth.log( + TAG, + depth, + "setAnnouncementFromEvent window id=%s announcement=%s", + windowId, + interpretation.getAnnouncement()); + } else { + int role = Role.getSourceRole(event); + Window window = new Window(); + window.title = text; + window.setTitleFromWindowChange(text); + window.setTitleFromStateChange(text); + window.eventSourceRole = role; + window.eventPackageName = event.getPackageName(); + windowIdToData.put(windowId, window); + LogDepth.log(TAG, depth, "windowId=%s %s", windowId, window); + } + } + } + break; + case AccessibilityEvent.TYPE_WINDOWS_CHANGED: + { + HashSet windowIdsToBeRemoved = new HashSet<>(windowIdToData.keySet()); + int displayId = interpretation.getDisplayId(); + List windows = + AccessibilityServiceCompatUtils.getWindowsOnAllDisplays(service).get(displayId); + if (windows == null) { + return; + } + for (AccessibilityWindowInfo window : windows) { + int windowId = window.getId(); + CharSequence title = AccessibilityWindowInfoUtils.getTitle(window); + if (!TextUtils.isEmpty(title)) { + Window oldWindow = windowIdToData.get(windowId); + Window newWindow = (oldWindow == null) ? new Window() : oldWindow; + newWindow.title = title; + newWindow.setTitleFromWindowChange(title); + windowIdToData.put(windowId, newWindow); + LogDepth.log(TAG, depth, "windowId=%s %s", windowId, newWindow); + } + windowIdsToBeRemoved.remove(windowId); + } + for (Integer windowId : windowIdsToBeRemoved) { + windowIdToData.remove(windowId); + } + } + break; + default: // fall out + } + } + + private static @Nullable CharSequence getTextFromWindowStateChange( + AccessibilityEvent event, boolean useContentDescription) { + if (useContentDescription && !TextUtils.isEmpty(event.getContentDescription())) { + return event.getContentDescription(); + } + + List texts = event.getText(); + if (!texts.isEmpty()) { + return texts.get(0); + } + + return null; + } + + /** + * Gets {@code accessibilityPaneTitle} from {@link AccessibilityEvent#TYPE_WINDOW_STATE_CHANGED} + * and it should get from {@link AccessibilityNodeInfoCompat#getPaneTitle()} prior to {@link + * AccessibilityEvent#getText()}. + */ + private static @Nullable CharSequence getAccessibilityPaneTitle(AccessibilityEvent event) { + if (event.getEventType() != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED + || !isPaneContentChangeTypes(event.getContentChangeTypes())) { + return null; + } + CharSequence accessibilityPaneTitle = null; + AccessibilityNodeInfoCompat accessibilityNodeInfoCompat = + AccessibilityNodeInfoUtils.toCompat(event.getSource()); + if (accessibilityNodeInfoCompat != null) { + accessibilityPaneTitle = accessibilityNodeInfoCompat.getPaneTitle(); + accessibilityNodeInfoCompat.recycle(); + } + if (TextUtils.isEmpty(accessibilityPaneTitle)) { + accessibilityPaneTitle = + getTextFromWindowStateChange(event, /* useContentDescription= */ false); + } + return accessibilityPaneTitle; + } + + private static boolean isPaneContentChangeTypes(int changeTypes) { + return (changeTypes & PANE_CONTENT_CHANGE_TYPES) != 0; + } + + private void updateWindowTitleFromPane(AccessibilityEvent event, int depth) { + CharSequence accessibilityPaneTitle = getAccessibilityPaneTitle(event); + if (TextUtils.isEmpty(accessibilityPaneTitle)) { + return; + } + + int windowId = AccessibilityEventUtils.getWindowId(event); + Window oldWindow = windowIdToData.get(windowId); + CharSequence title = null; + if ((event.getContentChangeTypes() & CONTENT_CHANGE_TYPE_PANE_DISAPPEARED) != 0) { + // Rollback title to titleFromWindowChange if the title was came from pane and disappeared. + if (oldWindow != null && TextUtils.equals(accessibilityPaneTitle, oldWindow.title)) { + title = oldWindow.getTitleFromWindowChange(); + } + } else { + title = accessibilityPaneTitle; + } + + if (TextUtils.isEmpty(title)) { + return; + } + // Only updates title value for pane events. + Window newWindow = (oldWindow == null) ? new Window() : oldWindow; + newWindow.title = title; + if (!sourceAncestorHasPane(event)) { + // Keep outer-most pane-title for window. + newWindow.setTitleFromStateChange(title); + } + windowIdToData.put(windowId, newWindow); + LogDepth.log( + TAG, + depth, + "windowId=%s %s accessibilityPaneTitle=%s", + windowId, + newWindow, + accessibilityPaneTitle); + } + + private static boolean sourceAncestorHasPane(AccessibilityEvent event) { + @Nullable AccessibilityNodeInfoCompat source = AccessibilityEventUtils.sourceCompat(event); + return AccessibilityNodeInfoUtils.hasMatchingAncestor( + source, new Filter.NodeCompat((n) -> (n.getPaneTitle() != null))); + } + + /** + * Uses a heuristic to guess whether an event should be announced. Any event that comes from an + * IME, or an invisible window is considered an announcement because they are inactive windows so + * they can't be updated to window roles and run standard window-title updated process. This is a + * work around to make them could be announced. + */ + // TODO : define the behavior of non-active floating windows in TalkBack + @TargetApi(Build.VERSION_CODES.O) + private static boolean shouldAnnounceWindowStateChange(AccessibilityEvent event) { + if (event.getEventType() != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { + throw new IllegalStateException(); + } + + // Assume window ID of -1 is the keyboard. + if (AccessibilityEventUtils.getWindowId(event) == WINDOW_ID_NONE) { + return true; + } + + boolean announcementOnly = AccessibilityEventUtils.isIMEorVolumeWindow(event); + return announcementOnly; + } + + private static boolean isFromOnScreenKeyboard(AccessibilityEvent event) { + if (Role.getSourceRole(event) == Role.ROLE_ALERT_DIALOG) { + // Filters out TYPE_INPUT_METHOD_DIALOG. + return false; + } + // Assume window ID of -1 is the keyboard. + return AccessibilityEventUtils.getWindowId(event) == WINDOW_ID_NONE + || getWindowType(event) == AccessibilityWindowInfo.TYPE_INPUT_METHOD + || AccessibilityEventUtils.isFromGBoardPackage(event.getPackageName()); + } + + private boolean isAlertDialog(int windowId) { + @Nullable Window window = windowIdToData.get(windowId); + @RoleName int role = (window == null) ? ROLE_NONE : window.eventSourceRole; + return role == Role.ROLE_ALERT_DIALOG; + } + + /** + * Modifies window IDs in roles. Should run after {@code updateWindowTitles} to get the + * interpreted {@code windowIdToData}. + */ + private void updateWindowRoles( + EventInterpretation interpretation, + AccessibilityService service, + WindowRoles roles, + int depth) { + + LogDepth.log(TAG, depth, "updateWindowRoles() interpretation=%s", interpretation); + + if (interpretation.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { + // For simplicity and reliability, update roles for both TYPE_WINDOW_STATE_CHANGED and + // TYPE_WINDOWS_CHANGED, using AccessibilityService.getWindows() + // If non-empty unhandled change-type... skip updating window roles. + int changeTypes = interpretation.getChangeTypes(); + if ((changeTypes != 0) && !isPaneContentChangeTypes(changeTypes)) { + return; + } + } + + ArrayList applicationWindows = new ArrayList<>(); + ArrayList otherWindows = new ArrayList<>(); + ArrayList accessibilityOverlayWindows = new ArrayList<>(); + ArrayList picInPicWindows = new ArrayList<>(); + AccessibilityWindowInfo inputMethodWindow = null; + + int displayId = interpretation.getDisplayId(); + List windows = + AccessibilityServiceCompatUtils.getWindowsOnAllDisplays(service).get(displayId); + + // If there are no windows available, clear the cached IDs. + if (windows == null || windows.isEmpty()) { + LogDepth.log(TAG, depth, "updateWindowRoles() windows.isEmpty()=true returning"); + roles.clear(); + return; + } + + for (int i = 0; i < windows.size(); i++) { + AccessibilityWindowInfo window = windows.get(i); + if (AccessibilityWindowInfoUtils.isPictureInPicture(window)) { + picInPicWindows.add(window); + continue; + } + boolean roleAssigned = false; + switch (window.getType()) { + case AccessibilityWindowInfo.TYPE_APPLICATION: + if (window.getParent() == null) { + applicationWindows.add(window); + roleAssigned = true; + } + break; + case AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY: + // From LMR1 to N, for Talkback we create a transparent a11y overlay on edit text when + // double click is performed. That is done so that we can adjust the cursor position to + // the end of the edit text instead of the center, which is the default behavior. This + // overlay should be ignored while detecting window changes. + boolean isOverlayOnEditTextSupported = !BuildVersionUtils.isAtLeastO(); + AccessibilityNodeInfo root = AccessibilityWindowInfoUtils.getRoot(window); + boolean isTalkbackOverlay = (Role.getRole(root) == Role.ROLE_TALKBACK_EDIT_TEXT_OVERLAY); + AccessibilityNodeInfoUtils.recycleNodes(root); + // Only add overlay window not shown by talkback. + if (!isOverlayOnEditTextSupported || !isTalkbackOverlay) { + accessibilityOverlayWindows.add(window); + roleAssigned = true; + } + break; + case AccessibilityWindowInfo.TYPE_INPUT_METHOD: + if (!isAlertDialog(window.getId())) { + inputMethodWindow = window; + roleAssigned = true; + } + break; + default: // fall out + } + if (!roleAssigned) { + otherWindows.add(window); + } + } + + LogDepth.log( + TAG, + depth, + "updateWindowRoles() accessibilityOverlayWindows.size()=%d", + accessibilityOverlayWindows.size()); + LogDepth.log( + TAG, depth, "updateWindowRoles() applicationWindows.size()=%d", applicationWindows.size()); + + roles.accessibilityOverlayWindowId = WINDOW_ID_NONE; + // Choose the top-most active overlay window because some a11y overlay is non-active and always + // on screen with full-transparent mask. For this case, we should skip it and update other roles + // behind the overlay. + for (AccessibilityWindowInfo windowInfo : accessibilityOverlayWindows) { + if (windowInfo.isFocused() && windowInfo.isActive()) { + roles.accessibilityOverlayWindowId = windowInfo.getId(); + LogDepth.log(TAG, depth, "updateWindowRoles() Accessibility overlay case"); + break; + } + } + + roles.picInPicWindowId = + picInPicWindows.isEmpty() ? WINDOW_ID_NONE : picInPicWindows.get(0).getId(); + + roles.inputMethodWindowId = + inputMethodWindow == null ? WINDOW_ID_NONE : inputMethodWindow.getId(); + + if (applicationWindows.isEmpty()) { + LogDepth.log(TAG, depth, "updateWindowRoles() Zero application windows case"); + roles.windowIdA = WINDOW_ID_NONE; + roles.windowIdB = WINDOW_ID_NONE; + + // If there is no application window but has other window, report the active window as the + // current window. + for (AccessibilityWindowInfo otherWindow : otherWindows) { + if (otherWindow.isActive()) { + roles.windowIdA = otherWindow.getId(); + return; + } + } + } else if (applicationWindows.size() == 1) { + LogDepth.log(TAG, depth, "updateWindowRoles() One application window case"); + roles.windowIdA = applicationWindows.get(0).getId(); + roles.windowIdB = WINDOW_ID_NONE; + } else if (applicationWindows.size() == 2 + && !hasOverlap(applicationWindows.get(0), applicationWindows.get(1), depth + 1)) { + LogDepth.log(TAG, depth, "updateWindowRoles() Two application windows case"); + Collections.sort( + applicationWindows, + new AccessibilityWindowInfoUtils.WindowPositionComparator( + WindowUtils.isScreenLayoutRTL(service))); + + roles.windowIdA = applicationWindows.get(0).getId(); + roles.windowIdB = applicationWindows.get(1).getId(); + } else { + LogDepth.log(TAG, depth, "updateWindowRoles() Default number of application windows case"); + // If there are more than 2 windows, report the active window as the current window. + for (AccessibilityWindowInfo applicationWindow : applicationWindows) { + if (applicationWindow.isActive()) { + roles.windowIdA = applicationWindow.getId(); + roles.windowIdB = WINDOW_ID_NONE; + return; + } + } + } + } + + private static boolean hasOverlap( + AccessibilityWindowInfo windowA, AccessibilityWindowInfo windowB, int depth) { + + @Nullable Rect rectA = AccessibilityWindowInfoUtils.getBounds(windowA); + LogDepth.log(TAG, depth, "hasOverlap() windowA=%s rectA=%s", windowA, rectA); + if (rectA == null) { + return false; + } + + @Nullable Rect rectB = AccessibilityWindowInfoUtils.getBounds(windowB); + LogDepth.log(TAG, depth, "hasOverlap() windowB=%s rectB=%s", windowB, rectB); + if (rectB == null) { + return false; + } + + return Rect.intersects(rectA, rectB); + } + + /** Updates window titles in windowRoles. */ + private void setWindowTitles(WindowRoles windowRoles) { + windowRoles.windowTitleA = getWindowTitleInternal(windowRoles.windowIdA); + windowRoles.windowTitleB = getWindowTitleInternal(windowRoles.windowIdB); + windowRoles.accessibilityOverlayWindowTitle = + getWindowTitleInternal(windowRoles.accessibilityOverlayWindowId); + windowRoles.picInPicWindowTitle = getWindowTitleInternal(windowRoles.picInPicWindowId); + windowRoles.inputMethodWindowTitle = getWindowTitleInternal(windowRoles.inputMethodWindowId); + } + + /** Detect window role changes, and turn on flags in interpretation. */ + private void detectWindowChanges( + WindowRoles roles, EventInterpretation interpretation, int depth) { + // Collect new window information into interpretation. + setNewWindowInterpretation(roles.windowIdA, interpretation.getWindowA()); + setNewWindowInterpretation(roles.windowIdB, interpretation.getWindowB()); + setNewWindowInterpretation( + roles.accessibilityOverlayWindowId, interpretation.getAccessibilityOverlay()); + setNewWindowInterpretation(roles.picInPicWindowId, interpretation.getPicInPic()); + + // If there is no screen update, do not provide spoken feedback. + boolean mainWindowsChanged = + (interpretation.getWindowA().idOrTitleChanged() + || interpretation.getWindowB().idOrTitleChanged() + || interpretation.getAccessibilityOverlay().idOrTitleChanged()); + LogDepth.log(TAG, depth, "detectWindowChanges()=%s roles=%s", mainWindowsChanged, roles); + interpretation.setMainWindowsChanged(mainWindowsChanged); + } + + /** + * Detects whether the input method window changed. Because there is an old design {@code + * Announcement} to generate input method feedback, we should check whether there is an + * announcement send to represent the window transition when {@code checkDuplicate} is true to + * prevent generate duplicate feedback. + */ + // TODO : remove code related to Annoucement after intergrating to Gboard done and + // stable. + private void detectInputMethodChanged( + WindowRoles roles, EventInterpretation interpretation, boolean checkDuplicate, int depth) { + setNewWindowInterpretation(roles.inputMethodWindowId, interpretation.getInputMethod()); + boolean inputMethodChanged = interpretation.getInputMethod().idOrTitleChanged(); + interpretation.setInputMethodChanged(inputMethodChanged); + + if (interpretation.getInputMethodChanged() && checkDuplicate) { + Announcement announcement = interpretation.getAnnouncement(); + if (announcement != null && announcement.isFromInputMethodEditor()) { + // It already has an announcement in IME transition, checks whether they come from the same + // source or not. If so, use the announcement first. If not, they are supposed to be + // different transitions and should keep both. + int inputMethodWindowId = interpretation.getInputMethod().id; + CharSequence inputMethodPackageName = getWindowPackageName(service, inputMethodWindowId); + CharSequence announcementPackageName = announcement.packageName(); + if (inputMethodWindowId == WINDOW_ID_NONE + || (inputMethodPackageName != null + && announcementPackageName != null + && inputMethodPackageName.toString().contentEquals(announcementPackageName))) { + interpretation.setInputMethodChanged(false); + } + } + // No announcement in IME transition yet, wait and drop the delayed announcements send from + // IME in 1 sec, i.e. delayed "Keyboard hidden". + recentKeyboardWindowChange = true; + windowEventDelayer.sendEmptyMessageDelayed(WindowEventDelayer.MSG_WAIT_ANNOUNCEMENT, 1000); + } + LogDepth.log( + TAG, depth, "detectInputMethodChanged()=%s", interpretation.getInputMethodChanged()); + } + + /** Returns window title for feedback. */ + public CharSequence getWindowTitleForFeedback(int windowId) { + return getWindowTitleForFeedback( + windowId, getWindowTitleInternal(windowId, /* windowInfoFirst= */ true)); + } + + /** Returns window title by priority: window title > application label > untitled. */ + private CharSequence getWindowTitleForFeedback(int windowId, @Nullable CharSequence title) { + if (TextUtils.isEmpty(title)) { + // Try to fall back to application label if window title is not available. + @Nullable Window window = windowIdToData.get(windowId); + @Nullable CharSequence packageName = (window == null) ? null : window.eventPackageName; + // Try to get package name from accessibility window info if it's not in the map. + if (packageName == null) { + packageName = getWindowPackageName(service, windowId); + } + if (packageName != null) { + title = getApplicationLabel(packageName); + } + } + + if (TextUtils.isEmpty(title)) { + title = service.getString(R.string.untitled_window); + } + return title; + } + + private static @Nullable CharSequence getWindowPackageName( + AccessibilityService service, int windowId) { + @Nullable CharSequence packageName = null; + + List windows = getAllWindows(service); + for (AccessibilityWindowInfo accessibilityWindowInfo : windows) { + if (accessibilityWindowInfo.getId() == windowId) { + AccessibilityNodeInfo rootNode = + AccessibilityWindowInfoUtils.getRoot(accessibilityWindowInfo); + if (rootNode != null) { + packageName = rootNode.getPackageName(); + rootNode.recycle(); + } + break; + } + } + return packageName; + } + + private @Nullable CharSequence getApplicationLabel(CharSequence packageName) { + PackageManager packageManager = service.getPackageManager(); + if (packageManager == null) { + return null; + } + + ApplicationInfo applicationInfo; + try { + applicationInfo = packageManager.getApplicationInfo(packageName.toString(), 0 /* no flag */); + } catch (PackageManager.NameNotFoundException exception) { + return null; + } + + return packageManager.getApplicationLabel(applicationInfo); + } + + private static int getWindowType(AccessibilityEvent event) { + if (event == null) { + return WINDOW_TYPE_NONE; + } + + AccessibilityNodeInfo nodeInfo = event.getSource(); + if (nodeInfo == null) { + return WINDOW_TYPE_NONE; + } + + AccessibilityNodeInfoCompat nodeInfoCompat = AccessibilityNodeInfoUtils.toCompat(nodeInfo); + AccessibilityWindowInfoCompat windowInfoCompat = + AccessibilityNodeInfoUtils.getWindow(nodeInfoCompat); + if (windowInfoCompat == null) { + nodeInfoCompat.recycle(); + return WINDOW_TYPE_NONE; + } + + int windowType = windowInfoCompat.getType(); + windowInfoCompat.recycle(); + nodeInfoCompat.recycle(); + + return windowType; + } + + private static List getAllWindows(AccessibilityService service) { + List windows = new ArrayList<>(); + SparseArray> windowsOnAllDisplays = + AccessibilityServiceCompatUtils.getWindowsOnAllDisplays(service); + final int displaySize = windowsOnAllDisplays.size(); + for (int i = 0; i < displaySize; i++) { + windows.addAll(windowsOnAllDisplays.valueAt(i)); + } + return windows; + } + + // ///////////////////////////////////////////////////////////////////////////////////// + // Inner classes: event interpretation + + /** Fully interpreted and analyzed window-change event description. */ + public static class EventInterpretation extends ReadOnly { + private int windowIdFromEvent = WINDOW_ID_NONE; + private @Nullable Announcement announcement; + private final WindowInterpretation windowA = new WindowInterpretation(); + private final WindowInterpretation windowB = new WindowInterpretation(); + private final WindowInterpretation accessibilityOverlay = new WindowInterpretation(); + private final WindowInterpretation picInPic = new WindowInterpretation(); + private final WindowInterpretation inputMethod = new WindowInterpretation(); + private boolean mainWindowsChanged = false; + private boolean picInPicChanged = false; + private boolean windowsStable = false; + private boolean originalEvent = false; + private boolean allowAnnounce = true; + private boolean inputMethodChanged = false; + private int displayId = Display.DEFAULT_DISPLAY; + private int eventType = 0; + private long eventStartTime = 0; + private long maxDelayMs = WINDOW_CHANGE_DELAY_MS; + private long totalDelayMs = 0; + + /** Bitmask from getContentChangeTypes() or getWindowChanges(), depending on eventType. */ + private int changeTypes = 0; + /** The bounds of the source node. */ + private @Nullable Rect sourceBoundsInScreen; + + @RoleName private int anchorNodeRole; + + @Override + public void setReadOnly() { + super.setReadOnly(); + windowA.setReadOnly(); + windowB.setReadOnly(); + accessibilityOverlay.setReadOnly(); + picInPic.setReadOnly(); + inputMethod.setReadOnly(); + } + + public void setWindowIdFromEvent(int id) { + checkIsWritable(); + windowIdFromEvent = id; + } + + public int getWindowIdFromEvent() { + return windowIdFromEvent; + } + + public void setAnnouncement(@Nullable Announcement announcement) { + checkIsWritable(); + this.announcement = announcement; + } + + public @Nullable Announcement getAnnouncement() { + return announcement; + } + + public WindowInterpretation getWindowA() { + return windowA; + } + + public WindowInterpretation getWindowB() { + return windowB; + } + + public WindowInterpretation getAccessibilityOverlay() { + return accessibilityOverlay; + } + + public WindowInterpretation getPicInPic() { + return picInPic; + } + + public WindowInterpretation getInputMethod() { + return inputMethod; + } + + public void setMainWindowsChanged(boolean changed) { + checkIsWritable(); + mainWindowsChanged = changed; + } + + public boolean getMainWindowsChanged() { + return mainWindowsChanged; + } + + public void setPicInPicChanged(boolean changed) { + checkIsWritable(); + picInPicChanged = changed; + } + + public boolean getPicInPicChanged() { + return picInPicChanged; + } + + public void setInputMethodChanged(boolean changed) { + checkIsWritable(); + inputMethodChanged = changed; + } + + public boolean getInputMethodChanged() { + return inputMethodChanged; + } + + public void setWindowsStable(boolean stable) { + checkIsWritable(); + windowsStable = stable; + } + + public boolean areWindowsStable() { + return windowsStable; + } + + public void setMaxDelay(long millisec) { + checkIsWritable(); + this.maxDelayMs = millisec; + } + + public long getMaxDelayMs() { + return maxDelayMs; + } + + public void incrementTotalDelayMs(long incrementMs) { + checkIsWritable(); + totalDelayMs += incrementMs; + } + + public long getTotalDelayMs() { + return totalDelayMs; + } + + public boolean hasTitlesFromStateChange() { + return windowA.hasTitleFromStateChange() + && windowB.hasTitleFromStateChange() + && accessibilityOverlay.hasTitleFromStateChange() + && picInPic.hasTitleFromStateChange() + && inputMethod.hasTitleFromStateChange(); + } + + public void setOriginalEvent(boolean original) { + checkIsWritable(); + originalEvent = original; + } + + public boolean isOriginalEvent() { + return originalEvent; + } + + public void setAllowAnnounce(boolean allowAnnounce) { + checkIsWritable(); + this.allowAnnounce = allowAnnounce; + } + + public boolean isAllowAnnounce() { + return allowAnnounce; + } + + public void setDisplayId(int displayId) { + checkIsWritable(); + this.displayId = displayId; + } + + public int getDisplayId() { + return displayId; + } + + public void setEventType(int eventType) { + checkIsWritable(); + this.eventType = eventType; + } + + public int getEventType() { + return eventType; + } + + public void setChangeTypes(int changeTypes) { + checkIsWritable(); + this.changeTypes = changeTypes; + } + + public int getChangeTypes() { + return changeTypes; + } + + public void setEventStartTime(long time) { + checkIsWritable(); + this.eventStartTime = time; + } + + public long getEventStartTime() { + return eventStartTime; + } + + public void setSourceBoundsInScreen(@NonNull Rect bounds) { + checkIsWritable(); + this.sourceBoundsInScreen = new Rect(bounds); + } + + public @Nullable Rect getSourceBoundsInScreen() { + return (sourceBoundsInScreen == null) ? null : new Rect(sourceBoundsInScreen); + } + + public void setAnchorNodeRole(@RoleName int role) { + this.anchorNodeRole = role; + } + + @RoleName + public int getAnchorNodeRole() { + return anchorNodeRole; + } + + @Override + public String toString() { + return StringBuilderUtils.joinSubObjects( + StringBuilderUtils.joinFields( + StringBuilderUtils.optionalInt( + "WindowIdFromEvent", windowIdFromEvent, WINDOW_ID_NONE), + StringBuilderUtils.optionalTag("MainWindowsChanged", mainWindowsChanged), + StringBuilderUtils.optionalTag("PicInPicChanged", picInPicChanged), + StringBuilderUtils.optionalTag("inputMethodChanged", inputMethodChanged), + StringBuilderUtils.optionalTag("WindowsStable", windowsStable), + StringBuilderUtils.optionalTag("OriginalEvent", originalEvent), + StringBuilderUtils.optionalTag("allowAnnounce", allowAnnounce), + StringBuilderUtils.optionalInt("displayId", displayId, Display.DEFAULT_DISPLAY), + StringBuilderUtils.optionalField( + "EventType", + eventType == 0 ? null : AccessibilityEventUtils.typeToString(eventType)), + StringBuilderUtils.optionalField("ChangeTypes", stateChangesToString()), + StringBuilderUtils.optionalSubObj("Announcement", announcement), + StringBuilderUtils.optionalInt("eventStartTime", eventStartTime, 0), + StringBuilderUtils.optionalInt("maxDelayMs", maxDelayMs, 0), + StringBuilderUtils.optionalInt("totalDelayMs", totalDelayMs, 0), + StringBuilderUtils.optionalSubObj("sourceBoundsInScreen", sourceBoundsInScreen)), + StringBuilderUtils.optionalSubObj("WindowA", windowA), + StringBuilderUtils.optionalSubObj("WindowB", windowB), + StringBuilderUtils.optionalSubObj("A11yOverlay", accessibilityOverlay), + StringBuilderUtils.optionalSubObj("PicInPic", picInPic), + StringBuilderUtils.optionalSubObj("inputMethod", inputMethod), + StringBuilderUtils.optionalSubObj("AnchorNodeRole", anchorNodeRole)); + } + + private @Nullable String stateChangesToString() { + switch (eventType) { + case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED: + return contentChangeTypesToString(changeTypes); + case AccessibilityEvent.TYPE_WINDOWS_CHANGED: + return windowChangeTypesToString(changeTypes); + default: + return null; + } + } + } + + /** Fully interpreted and analyzed window-change event description about one window. */ + public static class WindowInterpretation extends ReadOnly { + private int id = WINDOW_ID_NONE; + private @Nullable CharSequence title; + private @Nullable CharSequence titleForFeedback; + private @Nullable CharSequence titleFromStateChange; + private int oldId = WINDOW_ID_NONE; + private @Nullable CharSequence oldTitle; + + public void setId(int id) { + checkIsWritable(); + this.id = id; + } + + public boolean idOrTitleChanged() { + return (oldId != id) || !TextUtils.equals(oldTitle, title); + } + + public int getId() { + return id; + } + + public void setTitle(@Nullable CharSequence title) { + checkIsWritable(); + this.title = title; + } + + public @Nullable CharSequence getTitle() { + return title; + } + + public void setTitleForFeedback(@Nullable CharSequence title) { + checkIsWritable(); + titleForFeedback = title; + } + + public @Nullable CharSequence getTitleForFeedback() { + return titleForFeedback; + } + + public void setTitleFromStateChange(@Nullable CharSequence title) { + checkIsWritable(); + this.titleFromStateChange = title; + } + + public boolean hasTitleFromStateChange() { + return !idOrTitleChanged() || (titleFromStateChange != null); + } + + public void setOldId(int oldId) { + checkIsWritable(); + this.oldId = oldId; + } + + public int getOldId() { + return oldId; + } + + public void setOldTitle(@Nullable CharSequence oldTitle) { + checkIsWritable(); + this.oldTitle = oldTitle; + } + + public @Nullable CharSequence getOldTitle() { + return oldTitle; + } + + @Override + public String toString() { + return StringBuilderUtils.joinFields( + StringBuilderUtils.optionalInt("ID", id, WINDOW_ID_NONE), + StringBuilderUtils.optionalText("Title", title), + StringBuilderUtils.optionalText("titleFromStateChange", titleFromStateChange), + StringBuilderUtils.optionalText("TitleForFeedback", titleForFeedback), + StringBuilderUtils.optionalInt("OldID", oldId, WINDOW_ID_NONE), + StringBuilderUtils.optionalText("OldTitle", oldTitle)); + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Logging methods + + private static @Nullable String contentChangeTypesToString(int typesBitmask) { + StringBuilder strings = new StringBuilder(); + if ((typesBitmask & CONTENT_CHANGE_TYPE_PANE_TITLE) != 0) { + strings.append("CONTENT_CHANGE_TYPE_PANE_TITLE"); + } + if ((typesBitmask & CONTENT_CHANGE_TYPE_PANE_APPEARED) != 0) { + strings.append("CONTENT_CHANGE_TYPE_PANE_APPEARED"); + } + if ((typesBitmask & CONTENT_CHANGE_TYPE_PANE_DISAPPEARED) != 0) { + strings.append("CONTENT_CHANGE_TYPE_PANE_DISAPPEARED"); + } + return (strings.length() == 0) ? null : strings.toString(); + } + + @TargetApi(Build.VERSION_CODES.P) + private static @Nullable String windowChangeTypesToString(int typesBitmask) { + StringBuilder strings = new StringBuilder(); + if ((typesBitmask & AccessibilityEvent.WINDOWS_CHANGE_ADDED) != 0) { + strings.append("WINDOWS_CHANGE_ADDED"); + } + if ((typesBitmask & AccessibilityEvent.WINDOWS_CHANGE_REMOVED) != 0) { + strings.append("WINDOWS_CHANGE_REMOVED"); + } + if ((typesBitmask & AccessibilityEvent.WINDOWS_CHANGE_TITLE) != 0) { + strings.append("WINDOWS_CHANGE_TITLE"); + } + return (strings.length() == 0) ? null : strings.toString(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/input/WindowsDelegate.java b/utils/src/main/java/com/google/android/accessibility/utils/input/WindowsDelegate.java new file mode 100644 index 0000000..f47e4b5 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/input/WindowsDelegate.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2017 Google Inc. + * + * 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.google.android.accessibility.utils.input; + +/** Gets information about the window on the screen. */ +public interface WindowsDelegate { + + /** Gets window title given the window ID. */ + CharSequence getWindowTitle(int windowId); + + /** + * Determines whether the screen is in the split-screen mode, where the screen has two + * non-parented application windows. + */ + boolean isSplitScreenMode(int displayId); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/keyboard/DefaultKeyComboModel.java b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/DefaultKeyComboModel.java new file mode 100644 index 0000000..3b2b9b5 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/DefaultKeyComboModel.java @@ -0,0 +1,604 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.keyboard; + +import android.content.Context; +import android.content.SharedPreferences; +import android.view.KeyEvent; +import androidx.annotation.Nullable; +import com.google.android.accessibility.utils.FeatureSupport; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.SharedPreferencesUtils; +import java.util.Map; +import java.util.TreeMap; + +/** Default key combo model. */ +public class DefaultKeyComboModel implements KeyComboModel { + public static final String PREF_KEY_PREFIX = "default_key_combo_model"; + + private final Context mContext; + private final boolean mIsArc; + private final Map mKeyComboCodeMap = new TreeMap<>(); + private final KeyComboPersister mPersister; + + private int mTriggerModifier = KeyEvent.META_ALT_ON; + + public DefaultKeyComboModel(Context context) { + mContext = context; + mIsArc = FeatureSupport.isArc(); + mPersister = new KeyComboPersister(context, PREF_KEY_PREFIX); + + loadTriggerModifierFromPreferences(); + addKeyCombos(); + } + + private void loadTriggerModifierFromPreferences() { + SharedPreferences prefs = SharedPreferencesUtils.getSharedPreferences(mContext); + int defaultTriggerModifier = + mIsArc + ? R.string.trigger_modifier_meta_entry_value + : R.string.trigger_modifier_alt_entry_value; + + if (!prefs.contains(getPreferenceKeyForTriggerModifier())) { + // Store default value in preferences to show it in preferences UI. + prefs + .edit() + .putString( + getPreferenceKeyForTriggerModifier(), mContext.getString(defaultTriggerModifier)) + .apply(); + } + + String triggerModifier = + prefs.getString( + getPreferenceKeyForTriggerModifier(), mContext.getString(defaultTriggerModifier)); + if (triggerModifier.equals(mContext.getString(R.string.trigger_modifier_alt_entry_value))) { + mTriggerModifier = KeyEvent.META_ALT_ON; + } else if (triggerModifier.equals( + mContext.getString(R.string.trigger_modifier_meta_entry_value))) { + mTriggerModifier = KeyEvent.META_META_ON; + } + } + + private void addKeyCombos() { + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_perform_click)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_perform_long_click)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_other_read_from_top)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_other_read_from_next_item)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_other_talkback_context_menu)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_other_custom_actions)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_other_language_options)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_other_toggle_search)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_global_back)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_default)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_default)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_up)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_down)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_first)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_last)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_word)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_word)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_character)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_character)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_button)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_button)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_control)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_control)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_checkbox)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_checkbox)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_aria_landmark)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_aria_landmark)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_edit_field)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_edit_field)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_focusable_item)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_focusable_item)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_graphic)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_graphic)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_1)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_1)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_2)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_2)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_3)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_3)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_4)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_4)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_5)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_5)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_6)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_6)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_list_item)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_list_item)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_link)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_link)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_list)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_list)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_table)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_table)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_combobox)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_combobox)); + if (!mIsArc) { + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next_window)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous_window)); + } + if (mIsArc) { + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_open_manage_keyboard_shortcuts)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_open_talkback_settings)); + } + if (!mIsArc) { + if (!FeatureSupport.hasAccessibilityShortcut(mContext)) { + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_global_suspend)); + } + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_global_home)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_global_recents)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_global_notifications)); + addKeyCombo(mContext.getString(R.string.keycombo_shortcut_global_play_pause_media)); + addKeyCombo( + mContext.getString(R.string.keycombo_shortcut_global_scroll_forward_reading_menu)); + addKeyCombo( + mContext.getString(R.string.keycombo_shortcut_global_scroll_backward_reading_menu)); + addKeyCombo( + mContext.getString(R.string.keycombo_shortcut_global_adjust_reading_settings_previous)); + addKeyCombo( + mContext.getString(R.string.keycombo_shortcut_global_adjust_reading_setting_next)); + } + } + + private void addKeyCombo(String key) { + if (!mPersister.contains(key)) { + mPersister.saveKeyCombo(key, getDefaultKeyComboCode(key)); + } + + mKeyComboCodeMap.put(key, mPersister.getKeyComboCode(key)); + } + + @Override + public int getTriggerModifier() { + return mTriggerModifier; + } + + public String getTriggerModifierName() { + SharedPreferences prefs = SharedPreferencesUtils.getSharedPreferences(mContext); + String triggerModifier = + prefs.getString( + getPreferenceKeyForTriggerModifier(), + mContext.getString(R.string.trigger_modifier_alt_entry_value)); + String TriggerModifierName = ""; + + if (triggerModifier.equals(mContext.getString(R.string.trigger_modifier_alt_entry_value))) { + TriggerModifierName = mContext.getString(R.string.keycombo_key_modifier_alt); + } else if (triggerModifier.equals( + mContext.getString(R.string.trigger_modifier_meta_entry_value))) { + TriggerModifierName = mContext.getString(R.string.keycombo_key_modifier_meta); + } + + return TriggerModifierName; + } + + @Override + public void notifyTriggerModifierChanged() { + loadTriggerModifierFromPreferences(); + } + + @Override + public String getPreferenceKeyForTriggerModifier() { + return mContext.getString(R.string.pref_default_keymap_trigger_modifier_key); + } + + @Override + public Map getKeyComboCodeMap() { + return mKeyComboCodeMap; + } + + @Nullable + @Override + public String getKeyForKeyComboCode(long keyComboCode) { + for (Map.Entry entry : mKeyComboCodeMap.entrySet()) { + if (entry.getValue() == keyComboCode) { + return entry.getKey(); + } + } + + return null; + } + + @Override + public long getKeyComboCodeForKey(String key) { + if (key != null && mKeyComboCodeMap.containsKey(key)) { + return mKeyComboCodeMap.get(key); + } else { + return KEY_COMBO_CODE_UNASSIGNED; + } + } + + @Override + public long getDefaultKeyComboCode(String key) { + if (key == null) { + return KEY_COMBO_CODE_UNASSIGNED; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_perform_click))) { + if (mIsArc) { + return KeyComboManager.getKeyComboCode(NO_MODIFIER, KeyEvent.KEYCODE_SPACE); + } else { + return KeyComboManager.getKeyComboCode(NO_MODIFIER, KeyEvent.KEYCODE_ENTER); + } + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_perform_long_click))) { + if (mIsArc) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_SPACE); + } else { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_ENTER); + } + } + + if (!FeatureSupport.hasAccessibilityShortcut(mContext) + && key.equals(mContext.getString(R.string.keycombo_shortcut_global_suspend))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_Z); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_read_from_top))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_ENTER); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_read_from_next_item))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_ENTER); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_talkback_context_menu))) { + if (mIsArc) { + return KeyComboManager.getKeyComboCode(NO_MODIFIER, KeyEvent.KEYCODE_ENTER); + } else { + return KeyComboManager.getKeyComboCode(NO_MODIFIER, KeyEvent.KEYCODE_SPACE); + } + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_custom_actions))) { + if (mIsArc) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_ENTER); + } else { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_SPACE); + } + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_language_options))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_L); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_toggle_search))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_SLASH); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_home))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_H); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_recents))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_R); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_back))) { + return KeyComboManager.getKeyComboCode(NO_MODIFIER, KeyEvent.KEYCODE_DEL); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_notifications))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_N); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_play_pause_media))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_SPACE); + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_scroll_forward_reading_menu))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_DOWN); + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_scroll_backward_reading_menu))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_UP); + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_adjust_reading_settings_previous))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_UP); + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_adjust_reading_setting_next))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_DOWN); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_default))) { + return KeyComboManager.getKeyComboCode(NO_MODIFIER, KeyEvent.KEYCODE_DPAD_RIGHT); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_default))) { + return KeyComboManager.getKeyComboCode(NO_MODIFIER, KeyEvent.KEYCODE_DPAD_LEFT); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_up))) { + return KeyComboManager.getKeyComboCode(NO_MODIFIER, KeyEvent.KEYCODE_DPAD_UP); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_down))) { + return KeyComboManager.getKeyComboCode(NO_MODIFIER, KeyEvent.KEYCODE_DPAD_DOWN); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_first))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_DPAD_LEFT); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_last))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_DPAD_RIGHT); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_word))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_RIGHT); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_word))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_LEFT); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_character))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_RIGHT); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_character))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_LEFT); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_button))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_B); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_button))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_B); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_control))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_C); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_control))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_C); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_checkbox))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_X); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_checkbox))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_X); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_aria_landmark))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_D); + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_navigate_previous_aria_landmark))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_D); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_edit_field))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_E); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_edit_field))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_E); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_focusable_item))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_F); + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_navigate_previous_focusable_item))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_F); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_graphic))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_G); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_graphic))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_G); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_H); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_H); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_1))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_1); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_1))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_1); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_2))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_2); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_2))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_2); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_3))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_3); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_3))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_3); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_4))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_4); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_4))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_4); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_5))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_5); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_5))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_5); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_6))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_6); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_6))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_6); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_list_item))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_I); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_list_item))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_I); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_link))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_L); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_link))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_L); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_list))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_O); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_list))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_O); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_table))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_T); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_table))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_T); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_combobox))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_Z); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_combobox))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_Z); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_window))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_DPAD_DOWN); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_window))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_DPAD_UP); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_open_manage_keyboard_shortcuts))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_K); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_open_talkback_settings))) { + return KeyComboManager.getKeyComboCode(KeyComboModel.NO_MODIFIER, KeyEvent.KEYCODE_0); + } + + return KEY_COMBO_CODE_UNASSIGNED; + } + + @Override + public void saveKeyComboCode(String key, long keyComboCode) { + mPersister.saveKeyCombo(key, keyComboCode); + mKeyComboCodeMap.put(key, keyComboCode); + } + + @Override + public void clearKeyComboCode(String key) { + saveKeyComboCode(key, KEY_COMBO_CODE_UNASSIGNED); + } + + @Override + public boolean isEligibleKeyComboCode(long keyComboCode) { + if (keyComboCode == KEY_COMBO_CODE_UNASSIGNED) { + return true; + } + + // Do not allow to set key combo which is consisted only with modifiers. + int keyCode = KeyComboManager.getKeyCode(keyComboCode); + if (KeyEvent.isModifierKey(keyCode) || keyCode == KeyEvent.KEYCODE_UNKNOWN) { + return false; + } + + // It's not allowed to use trigger modifier as part of key combo code. + return (KeyComboManager.getModifier(keyComboCode) & getTriggerModifier()) == 0; + } + + @Override + public String getDescriptionOfEligibleKeyCombo() { + return mContext.getString( + R.string.keycombo_assign_dialog_default_keymap_instruction, getTriggerModifierName()); + } + + @Override + public void updateVersion(int previousVersion) { + if (previousVersion < 50200001) { + // From version 50200001, we've renamed keycombo_shortcut_navigate_next and + // keycombo_shortcut_navigate_previous to keycombo_shortcut_navigate_next_default and + // keycombo_shortcut_navigate_previous_default respectively. + moveKeyComboPreferenceValue( + mContext.getString(R.string.keycombo_shortcut_navigate_next), + mContext.getString(R.string.keycombo_shortcut_navigate_next_default)); + moveKeyComboPreferenceValue( + mContext.getString(R.string.keycombo_shortcut_navigate_previous), + mContext.getString(R.string.keycombo_shortcut_navigate_previous_default)); + } + } + + /** + * Move key combo preference value from fromKey to toKey. Original value in fromKey is deleted. + */ + private void moveKeyComboPreferenceValue(String fromKey, String toKey) { + if (!mPersister.contains(fromKey)) { + return; + } + + saveKeyComboCode(toKey, mPersister.getKeyComboCode(fromKey)); + mPersister.remove(fromKey); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboManager.java b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboManager.java new file mode 100644 index 0000000..0322a93 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboManager.java @@ -0,0 +1,957 @@ +/* + * Copyright (C) 2013 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.google.android.accessibility.utils.keyboard; + +import android.content.Context; +import android.content.SharedPreferences; +import android.view.KeyEvent; +import androidx.annotation.Nullable; +import com.google.android.accessibility.utils.FeatureSupport; +import com.google.android.accessibility.utils.Performance; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.ServiceKeyEventListener; +import com.google.android.accessibility.utils.ServiceStateListener; +import com.google.android.accessibility.utils.SharedPreferencesUtils; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Manages state related to detecting key combinations. + * + *

TODO: move KeyComboManager under package talkback.keyboard. + */ +public class KeyComboManager implements ServiceKeyEventListener, ServiceStateListener { + + public static final int NO_MATCH = -1; + public static final int PARTIAL_MATCH = 1; + public static final int EXACT_MATCH = 2; + + public static final int ACTION_UNKNOWN = -1; + public static final int ACTION_NAVIGATE_NEXT = 1; + public static final int ACTION_NAVIGATE_PREVIOUS = 2; + public static final int ACTION_NAVIGATE_FIRST = 3; + public static final int ACTION_NAVIGATE_LAST = 4; + public static final int ACTION_PERFORM_CLICK = 5; + public static final int ACTION_BACK = 6; + public static final int ACTION_HOME = 7; + public static final int ACTION_RECENTS = 8; + public static final int ACTION_NOTIFICATION = 9; + public static final int ACTION_SUSPEND_OR_RESUME = 10; + public static final int ACTION_GRANULARITY_INCREASE = 11; + public static final int ACTION_GRANULARITY_DECREASE = 12; + public static final int ACTION_READ_FROM_TOP = 13; + public static final int ACTION_READ_FROM_NEXT_ITEM = 14; + public static final int ACTION_TOGGLE_SEARCH = 15; + + public static final int ACTION_TALKBACK_CONTEXT_MENU = 17; + public static final int ACTION_NAVIGATE_UP = 18; + public static final int ACTION_NAVIGATE_DOWN = 19; + public static final int ACTION_NAVIGATE_NEXT_WORD = 20; + public static final int ACTION_NAVIGATE_PREVIOUS_WORD = 21; + public static final int ACTION_NAVIGATE_NEXT_CHARACTER = 22; + public static final int ACTION_NAVIGATE_PREVIOUS_CHARACTER = 23; + public static final int ACTION_PERFORM_LONG_CLICK = 24; + public static final int ACTION_NAVIGATE_NEXT_HEADING = 25; + public static final int ACTION_NAVIGATE_PREVIOUS_HEADING = 26; + public static final int ACTION_NAVIGATE_NEXT_BUTTON = 27; + public static final int ACTION_NAVIGATE_PREVIOUS_BUTTON = 28; + public static final int ACTION_NAVIGATE_NEXT_CHECKBOX = 29; + public static final int ACTION_NAVIGATE_PREVIOUS_CHECKBOX = 30; + public static final int ACTION_NAVIGATE_NEXT_ARIA_LANDMARK = 31; + public static final int ACTION_NAVIGATE_PREVIOUS_ARIA_LANDMARK = 32; + public static final int ACTION_NAVIGATE_NEXT_EDIT_FIELD = 33; + public static final int ACTION_NAVIGATE_PREVIOUS_EDIT_FIELD = 34; + public static final int ACTION_NAVIGATE_NEXT_FOCUSABLE_ITEM = 35; + public static final int ACTION_NAVIGATE_PREVIOUS_FOCUSABLE_ITEM = 36; + public static final int ACTION_NAVIGATE_NEXT_HEADING_1 = 37; + public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_1 = 38; + public static final int ACTION_NAVIGATE_NEXT_HEADING_2 = 39; + public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_2 = 40; + public static final int ACTION_NAVIGATE_NEXT_HEADING_3 = 41; + public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_3 = 42; + public static final int ACTION_NAVIGATE_NEXT_HEADING_4 = 43; + public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_4 = 44; + public static final int ACTION_NAVIGATE_NEXT_HEADING_5 = 45; + public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_5 = 46; + public static final int ACTION_NAVIGATE_NEXT_HEADING_6 = 47; + public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_6 = 48; + public static final int ACTION_NAVIGATE_NEXT_LINK = 49; + public static final int ACTION_NAVIGATE_PREVIOUS_LINK = 50; + public static final int ACTION_NAVIGATE_NEXT_CONTROL = 51; + public static final int ACTION_NAVIGATE_PREVIOUS_CONTROL = 52; + public static final int ACTION_NAVIGATE_NEXT_GRAPHIC = 53; + public static final int ACTION_NAVIGATE_PREVIOUS_GRAPHIC = 54; + public static final int ACTION_NAVIGATE_NEXT_LIST_ITEM = 55; + public static final int ACTION_NAVIGATE_PREVIOUS_LIST_ITEM = 56; + public static final int ACTION_NAVIGATE_NEXT_LIST = 57; + public static final int ACTION_NAVIGATE_PREVIOUS_LIST = 58; + public static final int ACTION_NAVIGATE_NEXT_TABLE = 59; + public static final int ACTION_NAVIGATE_PREVIOUS_TABLE = 60; + public static final int ACTION_NAVIGATE_NEXT_COMBOBOX = 61; + public static final int ACTION_NAVIGATE_PREVIOUS_COMBOBOX = 62; + public static final int ACTION_NAVIGATE_NEXT_WINDOW = 63; + public static final int ACTION_NAVIGATE_PREVIOUS_WINDOW = 64; + public static final int ACTION_OPEN_MANAGE_KEYBOARD_SHORTCUTS = 65; + public static final int ACTION_OPEN_TALKBACK_SETTINGS = 66; + public static final int ACTION_CUSTOM_ACTIONS = 67; + public static final int ACTION_NAVIGATE_NEXT_DEFAULT = 68; + public static final int ACTION_NAVIGATE_PREVIOUS_DEFAULT = 69; + public static final int ACTION_LANGUAGE_OPTIONS = 70; + public static final int ACTION_PLAY_PAUSE_MEDIA = 71; + public static final int ACTION_SCROLL_FORWARD_READING_MENU = 72; + public static final int ACTION_SCROLL_BACKWARD_READING_MENU = 73; + public static final int ACTION_ADJUST_READING_SETTING_PREVIOUS = 74; + public static final int ACTION_ADJUST_READING_SETTING_NEXT = 75; + + private static final int KEY_EVENT_MODIFIER_MASK = + KeyEvent.META_SHIFT_ON | KeyEvent.META_CTRL_ON | KeyEvent.META_ALT_ON | KeyEvent.META_META_ON; + + public static final String CONCATINATION_STR = " + "; + private static final String KEYCODE_PREFIX = "KEYCODE_"; + + /** When user has pressed same key twice less than this interval, we handle them as double tap. */ + private static final long TIME_TO_DETECT_DOUBLE_TAP = 1000; // ms + + private static final int DEFAULT_KEYMAP = R.string.default_keymap_entry_value; + + /** Returns keyComboCode that represent keyEvent. */ + public static long getKeyComboCode(KeyEvent keyEvent) { + if (keyEvent == null) { + return KeyComboModel.KEY_COMBO_CODE_UNASSIGNED; + } + + int modifier = keyEvent.getModifiers() & KEY_EVENT_MODIFIER_MASK; + return getKeyComboCode(modifier, getConvertedKeyCode(keyEvent)); + } + + /** + * Returns key combo code which is combination of modifier and keycode. + * + * @param modifier + * @param keycode + * @return + */ + public static long getKeyComboCode(int modifier, int keycode) { + return (((long) modifier) << 32) + keycode; + } + + /** + * Returns modifier part of key combo code. + * + * @param keyComboCode + * @return + */ + public static int getModifier(long keyComboCode) { + return (int) (keyComboCode >> 32); + } + + /** + * Returns key code part of key combo code. + * + * @param keyComboCode + * @return + */ + public static int getKeyCode(long keyComboCode) { + return (int) (keyComboCode); + } + + /** + * Returns converted key code. This method converts the following key events. - Convert + * KEYCODE_HOME with meta to KEYCODE_ENTER. - Convert KEYCODE_BACK with meta to KEYCODE_DEL. + * + * @param event Key event to be converted. + * @return Converted key code. + */ + static int getConvertedKeyCode(KeyEvent event) { + // We care only when meta key is pressed with. + if ((event.getModifiers() & KeyEvent.META_META_ON) == 0) { + return event.getKeyCode(); + } + + if (event.getKeyCode() == KeyEvent.KEYCODE_HOME) { + return KeyEvent.KEYCODE_ENTER; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + return KeyEvent.KEYCODE_DEL; + } else { + return event.getKeyCode(); + } + } + + private final boolean mIsArc; + + /** Whether the user performed a combo during the current interaction. */ + private boolean mPerformedCombo; + + /** Whether the user may be performing a combo and we should intercept keys. */ + private boolean mHasPartialMatch; + + private Set mCurrentKeysDown = new HashSet<>(); + private Set mPassedKeys = new HashSet<>(); + + private long mCurrentKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED; + private long mCurrentKeyComboTime = 0; + private long mPreviousKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED; + private long mPreviousKeyComboTime = 0; + + /** The listener that receives callbacks when a combo is recognized. */ + private final List mListeners = new ArrayList<>(); + + /** The listener that receives callbacks when key up is performed. */ + private KeyUpListener keyUpLister; + + private Context mContext; + private boolean mMatchKeyCombo = true; + private KeyComboModel mKeyComboModel; + private int mServiceState = SERVICE_STATE_INACTIVE; + private ServiceKeyEventListener mKeyEventDelegate; + + public static KeyComboManager create(Context context) { + return new KeyComboManager(context); + } + + private KeyComboManager(Context context) { + mContext = context; + mIsArc = FeatureSupport.isArc(); + mKeyComboModel = createKeyComboModelFor(getKeymap()); + + initializeDefaultPreferenceValues(); + } + + /** Store default values in preferences to show them in preferences UI. */ + private void initializeDefaultPreferenceValues() { + SharedPreferences preferences = SharedPreferencesUtils.getSharedPreferences(mContext); + if (preferences.contains(mContext.getString(R.string.pref_select_keymap_key))) { + return; + } + + preferences + .edit() + .putString( + mContext.getString(R.string.pref_select_keymap_key), mContext.getString(DEFAULT_KEYMAP)) + .apply(); + } + + /** + * Sets delegate for key events. If it's set, it can listen and consume key events before + * KeyComboManager does. Sets null to remove current one. + */ + public void setKeyEventDelegate(ServiceKeyEventListener delegate) { + mKeyEventDelegate = delegate; + } + + /** Returns keymap by reading preference. */ + public String getKeymap() { + SharedPreferences preferences = SharedPreferencesUtils.getSharedPreferences(mContext); + return preferences.getString( + mContext.getString(R.string.pref_select_keymap_key), mContext.getString(DEFAULT_KEYMAP)); + } + + /** + * Creates key combo model for specified keymap. + * + * @param keymap Keymap. + * @return Key combo model. null will be returned if keymap is invalid. + */ + @Nullable + public KeyComboModel createKeyComboModelFor(String keymap) { + if (keymap.equals(mContext.getString(R.string.classic_keymap_entry_value))) { + return new KeyComboModelApp(mContext); + } else if (keymap.equals(mContext.getString(R.string.default_keymap_entry_value))) { + return new DefaultKeyComboModel(mContext); + } + return null; + } + + /** + * Returns key combo model. + * + * @return + */ + public KeyComboModel getKeyComboModel() { + return mKeyComboModel; + } + + /** Sets key combo model. TODO: replace this method with setKeymap. */ + public void setKeyComboModel(KeyComboModel keyComboModel) { + mKeyComboModel = keyComboModel; + } + + /** + * Returns corresponding action id to key. If invalid value is passed as key, ACTION_UNKNOWN will + * be returned. + * + * @param key + * @return + */ + private int getActionIdFromKey(String key) { + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next))) { + return ACTION_NAVIGATE_NEXT; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous))) { + return ACTION_NAVIGATE_PREVIOUS; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_default))) { + return ACTION_NAVIGATE_NEXT_DEFAULT; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_default))) { + return ACTION_NAVIGATE_PREVIOUS_DEFAULT; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_first))) { + return ACTION_NAVIGATE_FIRST; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_last))) { + return ACTION_NAVIGATE_LAST; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_perform_click))) { + return ACTION_PERFORM_CLICK; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_back))) { + return ACTION_BACK; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_home))) { + return ACTION_HOME; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_recents))) { + return ACTION_RECENTS; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_notifications))) { + return ACTION_NOTIFICATION; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_suspend))) { + return ACTION_SUSPEND_OR_RESUME; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_play_pause_media))) { + return ACTION_PLAY_PAUSE_MEDIA; + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_scroll_forward_reading_menu))) { + return ACTION_SCROLL_FORWARD_READING_MENU; + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_scroll_backward_reading_menu))) { + return ACTION_SCROLL_BACKWARD_READING_MENU; + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_adjust_reading_settings_previous))) { + return ACTION_ADJUST_READING_SETTING_PREVIOUS; + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_adjust_reading_setting_next))) { + return ACTION_ADJUST_READING_SETTING_NEXT; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_granularity_increase))) { + return ACTION_GRANULARITY_INCREASE; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_granularity_decrease))) { + return ACTION_GRANULARITY_DECREASE; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_read_from_top))) { + return ACTION_READ_FROM_TOP; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_read_from_next_item))) { + return ACTION_READ_FROM_NEXT_ITEM; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_toggle_search))) { + return ACTION_TOGGLE_SEARCH; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_talkback_context_menu))) { + return ACTION_TALKBACK_CONTEXT_MENU; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_custom_actions))) { + return ACTION_CUSTOM_ACTIONS; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_language_options))) { + return ACTION_LANGUAGE_OPTIONS; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_up))) { + return ACTION_NAVIGATE_UP; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_down))) { + return ACTION_NAVIGATE_DOWN; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_word))) { + return ACTION_NAVIGATE_NEXT_WORD; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_word))) { + return ACTION_NAVIGATE_PREVIOUS_WORD; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_character))) { + return ACTION_NAVIGATE_NEXT_CHARACTER; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_character))) { + return ACTION_NAVIGATE_PREVIOUS_CHARACTER; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_perform_long_click))) { + return ACTION_PERFORM_LONG_CLICK; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading))) { + return ACTION_NAVIGATE_NEXT_HEADING; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading))) { + return ACTION_NAVIGATE_PREVIOUS_HEADING; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_button))) { + return ACTION_NAVIGATE_NEXT_BUTTON; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_button))) { + return ACTION_NAVIGATE_PREVIOUS_BUTTON; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_checkbox))) { + return ACTION_NAVIGATE_NEXT_CHECKBOX; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_checkbox))) { + return ACTION_NAVIGATE_PREVIOUS_CHECKBOX; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_aria_landmark))) { + return ACTION_NAVIGATE_NEXT_ARIA_LANDMARK; + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_navigate_previous_aria_landmark))) { + return ACTION_NAVIGATE_PREVIOUS_ARIA_LANDMARK; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_edit_field))) { + return ACTION_NAVIGATE_NEXT_EDIT_FIELD; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_edit_field))) { + return ACTION_NAVIGATE_PREVIOUS_EDIT_FIELD; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_focusable_item))) { + return ACTION_NAVIGATE_NEXT_FOCUSABLE_ITEM; + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_navigate_previous_focusable_item))) { + return ACTION_NAVIGATE_PREVIOUS_FOCUSABLE_ITEM; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_1))) { + return ACTION_NAVIGATE_NEXT_HEADING_1; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_1))) { + return ACTION_NAVIGATE_PREVIOUS_HEADING_1; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_2))) { + return ACTION_NAVIGATE_NEXT_HEADING_2; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_2))) { + return ACTION_NAVIGATE_PREVIOUS_HEADING_2; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_3))) { + return ACTION_NAVIGATE_NEXT_HEADING_3; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_3))) { + return ACTION_NAVIGATE_PREVIOUS_HEADING_3; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_4))) { + return ACTION_NAVIGATE_NEXT_HEADING_4; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_4))) { + return ACTION_NAVIGATE_PREVIOUS_HEADING_4; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_5))) { + return ACTION_NAVIGATE_NEXT_HEADING_5; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_5))) { + return ACTION_NAVIGATE_PREVIOUS_HEADING_5; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_6))) { + return ACTION_NAVIGATE_NEXT_HEADING_6; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading_6))) { + return ACTION_NAVIGATE_PREVIOUS_HEADING_6; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_link))) { + return ACTION_NAVIGATE_NEXT_LINK; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_link))) { + return ACTION_NAVIGATE_PREVIOUS_LINK; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_control))) { + return ACTION_NAVIGATE_NEXT_CONTROL; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_control))) { + return ACTION_NAVIGATE_PREVIOUS_CONTROL; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_graphic))) { + return ACTION_NAVIGATE_NEXT_GRAPHIC; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_graphic))) { + return ACTION_NAVIGATE_PREVIOUS_GRAPHIC; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_list_item))) { + return ACTION_NAVIGATE_NEXT_LIST_ITEM; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_list_item))) { + return ACTION_NAVIGATE_PREVIOUS_LIST_ITEM; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_list))) { + return ACTION_NAVIGATE_NEXT_LIST; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_list))) { + return ACTION_NAVIGATE_PREVIOUS_LIST; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_table))) { + return ACTION_NAVIGATE_NEXT_TABLE; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_table))) { + return ACTION_NAVIGATE_PREVIOUS_TABLE; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_combobox))) { + return ACTION_NAVIGATE_NEXT_COMBOBOX; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_combobox))) { + return ACTION_NAVIGATE_PREVIOUS_COMBOBOX; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_window))) { + return ACTION_NAVIGATE_NEXT_WINDOW; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_window))) { + return ACTION_NAVIGATE_PREVIOUS_WINDOW; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_open_manage_keyboard_shortcuts))) { + return ACTION_OPEN_MANAGE_KEYBOARD_SHORTCUTS; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_open_talkback_settings))) { + return ACTION_OPEN_TALKBACK_SETTINGS; + } + + return ACTION_UNKNOWN; + } + + /** + * Returns true if key combination of the key should be always processed. + * + * @param key + * @return + */ + private boolean alwaysProcessCombo(String key) { + return key.equals(mContext.getString(R.string.keycombo_shortcut_global_suspend)); + } + + /** + * Sets the listener that receives callbacks when the user performs key combinations. + * + * @param listener The listener that receives callbacks. + */ + public void addListener(KeyComboListener listener) { + mListeners.add(listener); + } + + /** + * Sets the listener that receives callbacks when the key up action is performed. + * + * @param listener The listener that receives callbacks. + */ + public void setKeyUpListener(KeyUpListener listener) { + keyUpLister = listener; + } + + /** Set whether to process keycombo */ + public void setMatchKeyCombo(boolean value) { + mMatchKeyCombo = value; + } + + /** Returns user friendly string representations of key combo code */ + public String getKeyComboStringRepresentation(long keyComboCode) { + if (keyComboCode == KeyComboModel.KEY_COMBO_CODE_UNASSIGNED) { + return mContext.getString(R.string.keycombo_unassigned); + } + + int triggerModifier = mKeyComboModel.getTriggerModifier(); + int modifier = getModifier(keyComboCode); + int modifierWithoutTriggerModifier = modifier & ~triggerModifier; + int keyCode = getKeyCode(keyComboCode); + + StringBuilder sb = new StringBuilder(); + + // Append trigger modifier if key combo code contains it. + if ((triggerModifier & modifier) != 0) { + appendModifiers(triggerModifier, sb); + } + + // Append modifier except trigger modifier. + appendModifiers(modifierWithoutTriggerModifier, sb); + + // Append key code. + if (keyCode > 0 && !KeyEvent.isModifierKey(keyCode)) { + appendPlusSignIfNotEmpty(sb); + + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_RIGHT: + sb.append(mContext.getString(R.string.keycombo_key_arrow_right)); + break; + case KeyEvent.KEYCODE_DPAD_LEFT: + sb.append(mContext.getString(R.string.keycombo_key_arrow_left)); + break; + case KeyEvent.KEYCODE_DPAD_UP: + sb.append(mContext.getString(R.string.keycombo_key_arrow_up)); + break; + case KeyEvent.KEYCODE_DPAD_DOWN: + sb.append(mContext.getString(R.string.keycombo_key_arrow_down)); + break; + default: + String keyCodeString = KeyEvent.keyCodeToString(keyCode); + if (keyCodeString != null) { + String keyCodeNoPrefix; + if (keyCodeString.startsWith(KEYCODE_PREFIX)) { + keyCodeNoPrefix = keyCodeString.substring(KEYCODE_PREFIX.length()); + } else { + keyCodeNoPrefix = keyCodeString; + } + sb.append(keyCodeNoPrefix.replace('_', ' ')); + } + break; + } + } + + return sb.toString(); + } + + /** Appends modifier. */ + private void appendModifiers(int modifier, StringBuilder sb) { + appendModifier( + modifier, KeyEvent.META_ALT_ON, mContext.getString(R.string.keycombo_key_modifier_alt), sb); + appendModifier( + modifier, + KeyEvent.META_SHIFT_ON, + mContext.getString(R.string.keycombo_key_modifier_shift), + sb); + appendModifier( + modifier, + KeyEvent.META_CTRL_ON, + mContext.getString(R.string.keycombo_key_modifier_ctrl), + sb); + appendModifier( + modifier, + KeyEvent.META_META_ON, + mContext.getString(R.string.keycombo_key_modifier_meta), + sb); + } + + /** Appends string representation of target modifier if modifier contains it. */ + private void appendModifier( + int modifier, int targetModifier, String stringRepresentation, StringBuilder sb) { + if ((modifier & targetModifier) != 0) { + appendPlusSignIfNotEmpty(sb); + sb.append(stringRepresentation); + } + } + + private void appendPlusSignIfNotEmpty(StringBuilder sb) { + if (sb.length() > 0) { + sb.append(CONCATINATION_STR); + } + } + + /** + * Handles incoming key events. May intercept keys if the user seems to be performing a key combo. + * + * @param event The key event. + * @return {@code true} if the key was intercepted. + */ + @Override + public boolean onKeyEvent(KeyEvent event, EventId eventId) { + if (mKeyEventDelegate != null) { + if (mKeyEventDelegate.onKeyEvent(event, eventId)) { + return true; + } + } + + if (!mHasPartialMatch && !mPerformedCombo && (!mMatchKeyCombo || mListeners.isEmpty())) { + return false; + } + + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + return onKeyDown(event); + case KeyEvent.ACTION_MULTIPLE: + return mHasPartialMatch; + case KeyEvent.ACTION_UP: + return onKeyUp(event); + default: + return false; + } + } + + @Override + public boolean processWhenServiceSuspended() { + return true; + } + + private KeyEvent convertKeyEventInArc(KeyEvent event) { + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_HOME: + case KeyEvent.KEYCODE_BACK: + // In Arc, Search + X is sent as KEYCODE_X with META_META_ON in Android. Android + // converts META_META_ON + KEYCODE_ENTER and META_META_ON + KEYCODE_DEL to + // KEYCODE_HOME and KEYCODE_BACK without META_META_ON. We add META_META_ON to this + // key event to satisfy trigger modifier condition. We don't need to do this in + // non-Arc since Search + X is usually sent as KEYCODE_X with META_META_ON and + // META_META_LEFT_ON or META_META_RIGHT_ON. + return new KeyEvent( + event.getDownTime(), + event.getEventTime(), + event.getAction(), + event.getKeyCode(), + event.getRepeatCount(), + event.getMetaState() | KeyEvent.META_META_ON); + default: + return event; + } + } + + private boolean onKeyDown(KeyEvent event) { + if (mIsArc) { + event = convertKeyEventInArc(event); + } + + mCurrentKeysDown.add(event.getKeyCode()); + mCurrentKeyComboCode = getKeyComboCode(event); + mCurrentKeyComboTime = event.getDownTime(); + + // Check modifier. + int triggerModifier = mKeyComboModel.getTriggerModifier(); + boolean hasModifier = triggerModifier != KeyComboModel.NO_MODIFIER; + if (hasModifier && (triggerModifier & event.getModifiers()) != triggerModifier) { + // Do nothing if condition of modifier is not met. + mPassedKeys.addAll(mCurrentKeysDown); + return false; + } + + boolean isServiceActive = (mServiceState == SERVICE_STATE_ACTIVE); + + // If the current set of keys is a partial combo, consume the event. + mHasPartialMatch = false; + + for (Map.Entry entry : mKeyComboModel.getKeyComboCodeMap().entrySet()) { + if (!isServiceActive && !alwaysProcessCombo(entry.getKey())) { + continue; + } + + final int match = matchKeyEventWith(event, triggerModifier, entry.getValue()); + if (match == EXACT_MATCH) { + int comboId = getActionIdFromKey(entry.getKey()); + String comboName = getKeyComboStringRepresentation(comboId); + EventId eventId = Performance.getInstance().onKeyComboEventReceived(comboId); + // Checks interrupt events if matches key combos. To prevent interrupting actions generated + // by key combos, we should send interrupt events + // before performing key combos. + interrupt(comboId); + + for (KeyComboListener listener : mListeners) { + if (listener.onComboPerformed(comboId, comboName, eventId)) { + mPerformedCombo = true; + return true; + } + } + } + + if (match == PARTIAL_MATCH) { + mHasPartialMatch = true; + } + } + + // Do not handle key event if user has pressed search key (meta key) twice to open search + // app. + if (hasModifier && triggerModifier == KeyEvent.META_META_ON) { + if (mPreviousKeyComboCode == mCurrentKeyComboCode + && mCurrentKeyComboTime - mPreviousKeyComboTime < TIME_TO_DETECT_DOUBLE_TAP + && (mCurrentKeyComboCode + == KeyComboManager.getKeyComboCode( + KeyEvent.META_META_ON, KeyEvent.KEYCODE_META_RIGHT) + || mCurrentKeyComboCode + == KeyComboManager.getKeyComboCode( + KeyEvent.META_META_ON, KeyEvent.KEYCODE_META_LEFT))) { + // Set KEY_COMBO_CODE_UNASSIGNED not to open search app again with following search + // key event. + mCurrentKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED; + mPassedKeys.addAll(mCurrentKeysDown); + return false; + } + } + + if (!mHasPartialMatch) { + mPassedKeys.addAll(mCurrentKeysDown); + } + return mHasPartialMatch; + } + + private int matchKeyEventWith(KeyEvent event, int triggerModifier, long keyComboCode) { + int keyCode = getConvertedKeyCode(event); + int metaState = event.getModifiers() & KEY_EVENT_MODIFIER_MASK; + + int targetKeyCode = getKeyCode(keyComboCode); + int targetMetaState = getModifier(keyComboCode) | triggerModifier; + + // Handle exact matches first. + if (metaState == targetMetaState && keyCode == targetKeyCode) { + return EXACT_MATCH; + } + + if (targetMetaState != 0 && metaState == 0) { + return NO_MATCH; + } + + // Otherwise, all modifiers must be down. + if (KeyEvent.isModifierKey(keyCode) + && targetMetaState != 0 + && (targetMetaState & metaState) != 0) { + // Partial match. + return PARTIAL_MATCH; + } + + // No match. + return NO_MATCH; + } + + /** + * Notifies the {@link KeyUpListener} whether should interrupt or not by checking the ActionId. + * + * @param performedActionId the ActionId generating from key combos. + */ + void interrupt(int performedActionId) { + if (keyUpLister != null) { + keyUpLister.onKeyUpShouldInterrupt(performedActionId); + } + } + + private boolean onKeyUp(KeyEvent event) { + if (mIsArc) { + event = convertKeyEventInArc(event); + } + + mCurrentKeysDown.remove(event.getKeyCode()); + boolean passed = mPassedKeys.remove(event.getKeyCode()); + + if (mCurrentKeysDown.isEmpty()) { + // Checks interrupt events if no key combos performed in the interaction. + if (!mPerformedCombo) { + interrupt(ACTION_UNKNOWN); + } + // The interaction is over, reset the state. + mPerformedCombo = false; + mHasPartialMatch = false; + mPreviousKeyComboCode = mCurrentKeyComboCode; + mPreviousKeyComboTime = mCurrentKeyComboTime; + mCurrentKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED; + mCurrentKeyComboTime = 0; + mPassedKeys.clear(); + } + + return !passed; + } + + @Override + public void onServiceStateChanged(int newState) { + // Unfortunately, key events are lost when the TalkBackService becomes active. If a key-down + // occurs that triggers TalkBack to resume, the corresponding key-up event will not be + // sent, causing the partially-matched key history to become inconsistent. + // The following method will cause the key history to be reset. + setMatchKeyCombo(mMatchKeyCombo); + + mServiceState = newState; + } + + public interface KeyComboListener { + public boolean onComboPerformed(int id, String name, EventId eventId); + } + + /** Component used to control key up action. */ + public interface KeyUpListener { + /** + * Check if need to interrupt when on key up + * + * @param performedActionId the ActionId generating from key combos + */ + public void onKeyUpShouldInterrupt(int performedActionId); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboModel.java b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboModel.java new file mode 100644 index 0000000..6abf447 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboModel.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.keyboard; + +import android.view.KeyEvent; +import java.util.Map; + +/** + * Manages key combo code and key. KeyComboModel is responsible for persisting preferences of key + * combo code. + */ +public interface KeyComboModel { + int KEY_COMBO_CODE_UNASSIGNED = KeyEvent.KEYCODE_UNKNOWN; + int KEY_COMBO_CODE_INVALID = -1; + int NO_MODIFIER = 0; + + /** + * Returns modifier of this model. If this model doesn't have modifier, + * KEY_COMBO_MODEL_NO_MODIFIER will be returned. + */ + int getTriggerModifier(); + + /** + * Notifies the model that preference of trigger modifier has changed. You must call this method + * when you change preference of trigger modifier since the model might cache the value in it. + */ + void notifyTriggerModifierChanged(); + + /** + * Returns preference key to be used for storing trigger modifier of this mode. Returns null if + * this model doesn't support trigger modifier. + */ + String getPreferenceKeyForTriggerModifier(); + + /** + * Returns map of key and key combo code. Key combo codes in this map don't contain trigger + * modifier if model has it. + */ + Map getKeyComboCodeMap(); + + /** + * Gets key for preference that is assigned for keyComboCode if keyComboCode is not + * KEY_COMBO_CODE_UNASSIGNED. If no preference is assigned or keyComboCode was + * KEY_COMBO_CODE_UNASSIGNED, returns null. + * + * @param keyComboCode key combo code which doesn't contain trigger modifier if model has it. + */ + String getKeyForKeyComboCode(long keyComboCode); + + /** Gets key combo code for key. KEY_COMBO_CODE_UNASSIGNED will be returned if key is invalid. */ + long getKeyComboCodeForKey(String key); + + /** + * Gets default key combo code for key. KEY_COMBO_CODE_UNASSIGNED will be returned if no key combo + * code is assigned to the key or it's invalid. + */ + long getDefaultKeyComboCode(String key); + + /** + * Assigns keyComboCode for preference. + * + * @param key key of key combo. + * @param keyComboCode key combo code which doesn't contain trigger modifier if model has it. + */ + void saveKeyComboCode(String key, long keyComboCode); + + /** Clears key combo code assigned for preference key. */ + void clearKeyComboCode(String key); + + /** + * Returns true if keyComboCode is eligible combination for this model. This method doesn't check + * consistency with other key combo codes in this model. e.g. duplicated key combos. + * + * @param keyComboCode key combo code which doesn't contain trigger modifier if model has it. + */ + boolean isEligibleKeyComboCode(long keyComboCode); + + /** Returns description of eligible key combination. This will be shown in the UI. */ + String getDescriptionOfEligibleKeyCombo(); + + /** Updates key combo model. This method will be called when TalkBack is updated. */ + void updateVersion(int previousVersion); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboModelApp.java b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboModelApp.java new file mode 100644 index 0000000..a2174ea --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboModelApp.java @@ -0,0 +1,356 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.keyboard; + +import android.content.Context; +import android.view.KeyEvent; +import androidx.annotation.Nullable; +import com.google.android.accessibility.utils.FeatureSupport; +import com.google.android.accessibility.utils.R; +import java.util.Map; +import java.util.TreeMap; + +/** + * Manages key and key combo code. + * + *

TODO: Rename this class to ClassicKeyComboModel after DefaultKeyComboModel becomes + * default one. + */ +public class KeyComboModelApp implements KeyComboModel { + private final Context mContext; + private final KeyComboPersister mPersister; + private final Map mKeyComboCodeMap = new TreeMap<>(); + + /** + * Search key (meta key) cannot be used as part of key combination since onKey method of + * KeyboardShortcutDialogPreference is not called if search key is contained. + */ + private static final int ELIGIBLE_MODIFIER_MASK = + KeyEvent.META_SHIFT_ON | KeyEvent.META_ALT_ON | KeyEvent.META_CTRL_ON; + + private static final int REQUIRED_MODIFIER_MASK = KeyEvent.META_ALT_ON | KeyEvent.META_CTRL_ON; + + public KeyComboModelApp(Context context) { + mContext = context; + mPersister = new KeyComboPersister(mContext, null /* no prefix */); + loadCombos(); + } + + @Override + public int getTriggerModifier() { + return NO_MODIFIER; + } + + @Override + public void notifyTriggerModifierChanged() {} + + @Override + public String getPreferenceKeyForTriggerModifier() { + return null; + } + + @Override + public Map getKeyComboCodeMap() { + return mKeyComboCodeMap; + } + + @Nullable + @Override + public String getKeyForKeyComboCode(long keyComboCode) { + if (keyComboCode == KEY_COMBO_CODE_UNASSIGNED) { + return null; + } + + for (Map.Entry entry : mKeyComboCodeMap.entrySet()) { + if (entry.getValue() == keyComboCode) { + return entry.getKey(); + } + } + + return null; + } + + @Override + public long getKeyComboCodeForKey(String key) { + if (key != null && mKeyComboCodeMap.containsKey(key)) { + return mKeyComboCodeMap.get(key); + } else { + return KEY_COMBO_CODE_UNASSIGNED; + } + } + + /** Loads default key combinations. */ + private void loadCombos() { + addCombo(mContext.getString(R.string.keycombo_shortcut_navigate_next)); + addCombo(mContext.getString(R.string.keycombo_shortcut_navigate_previous)); + addCombo(mContext.getString(R.string.keycombo_shortcut_navigate_first)); + addCombo(mContext.getString(R.string.keycombo_shortcut_navigate_last)); + addCombo(mContext.getString(R.string.keycombo_shortcut_perform_click)); + addCombo(mContext.getString(R.string.keycombo_shortcut_global_back)); + addCombo(mContext.getString(R.string.keycombo_shortcut_global_home)); + addCombo(mContext.getString(R.string.keycombo_shortcut_global_recents)); + addCombo(mContext.getString(R.string.keycombo_shortcut_global_notifications)); + if (!FeatureSupport.hasAccessibilityShortcut(mContext)) { + addCombo(mContext.getString(R.string.keycombo_shortcut_global_suspend)); + } + addCombo(mContext.getString(R.string.keycombo_shortcut_global_play_pause_media)); + addCombo(mContext.getString(R.string.keycombo_shortcut_global_scroll_forward_reading_menu)); + addCombo(mContext.getString(R.string.keycombo_shortcut_global_scroll_backward_reading_menu)); + addCombo( + mContext.getString(R.string.keycombo_shortcut_global_adjust_reading_settings_previous)); + addCombo(mContext.getString(R.string.keycombo_shortcut_global_adjust_reading_setting_next)); + addCombo(mContext.getString(R.string.keycombo_shortcut_granularity_increase)); + addCombo(mContext.getString(R.string.keycombo_shortcut_granularity_decrease)); + addCombo(mContext.getString(R.string.keycombo_shortcut_other_read_from_top)); + addCombo(mContext.getString(R.string.keycombo_shortcut_other_read_from_next_item)); + addCombo(mContext.getString(R.string.keycombo_shortcut_other_toggle_search)); + addCombo(mContext.getString(R.string.keycombo_shortcut_other_talkback_context_menu)); + addCombo(mContext.getString(R.string.keycombo_shortcut_other_custom_actions)); + addCombo(mContext.getString(R.string.keycombo_shortcut_other_language_options)); + } + + private void addCombo(String key) { + if (!mPersister.contains(key)) { + mPersister.saveKeyCombo(key, getDefaultKeyComboCode(key)); + } + + long keyComboCode = mPersister.getKeyComboCode(key); + mKeyComboCodeMap.put(key, keyComboCode); + } + + @Override + public long getDefaultKeyComboCode(String key) { + if (key == null) { + return KEY_COMBO_CODE_UNASSIGNED; + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_RIGHT); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_LEFT); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_first))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_UP); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_last))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DPAD_DOWN); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_perform_click))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_ENTER); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_back))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_DEL); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_home))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_H); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_recents))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_R); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_notifications))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_N); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_toggle_search))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_SLASH); + } + + if (!FeatureSupport.hasAccessibilityShortcut(mContext)) { + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_suspend))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_Z); + } + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_play_pause_media))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_SPACE); + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_scroll_forward_reading_menu))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_SHIFT_ON | KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_DPAD_DOWN); + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_scroll_backward_reading_menu))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_SHIFT_ON | KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_DPAD_UP); + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_adjust_reading_settings_previous))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_DPAD_UP); + } + + if (key.equals( + mContext.getString(R.string.keycombo_shortcut_global_adjust_reading_setting_next))) { + return KeyComboManager.getKeyComboCode(KeyEvent.META_CTRL_ON, KeyEvent.KEYCODE_DPAD_DOWN); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_granularity_increase))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_EQUALS); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_granularity_decrease))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_MINUS); + } + + if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_talkback_context_menu))) { + return KeyComboManager.getKeyComboCode( + KeyEvent.META_ALT_ON | KeyEvent.META_SHIFT_ON, KeyEvent.KEYCODE_G); + } + + return KEY_COMBO_CODE_UNASSIGNED; + } + + @Override + public void clearKeyComboCode(String key) { + saveKeyComboCode(key, KEY_COMBO_CODE_UNASSIGNED); + } + + @Override + public void saveKeyComboCode(String key, long keyComboCode) { + mPersister.saveKeyCombo(key, keyComboCode); + + if (mKeyComboCodeMap.containsKey(key)) { + mKeyComboCodeMap.put(key, keyComboCode); + } + } + + @Override + public boolean isEligibleKeyComboCode(long keyComboCode) { + if (keyComboCode == KEY_COMBO_CODE_UNASSIGNED) { + return true; + } + + int modifier = KeyComboManager.getModifier(keyComboCode); + if ((modifier & REQUIRED_MODIFIER_MASK) == 0 + || (modifier | ELIGIBLE_MODIFIER_MASK) != ELIGIBLE_MODIFIER_MASK) { + return false; + } + + int keyCode = KeyComboManager.getKeyCode(keyComboCode); + return keyCode != 0 + && keyCode != KeyEvent.KEYCODE_SHIFT_LEFT + && keyCode != KeyEvent.KEYCODE_SHIFT_RIGHT + && keyCode != KeyEvent.KEYCODE_ALT_LEFT + && keyCode != KeyEvent.KEYCODE_ALT_RIGHT + && keyCode != KeyEvent.KEYCODE_CTRL_LEFT + && keyCode != KeyEvent.KEYCODE_CTRL_RIGHT; + } + + @Override + public String getDescriptionOfEligibleKeyCombo() { + return mContext.getString(R.string.keycombo_assign_dialog_instruction); + } + + @Override + public void updateVersion(int previousVersion) { + // TalkBack 4.4 fixes an issue with the default keyboard shortcuts, but the changes need + // to be re-persisted for users upgrading from older versions who haven't customized their + // shortcut keys. + if (previousVersion < 40400000) { + changeGranularityKeyCombos(); + } + + // TalkBack 4.5 assigns default key combos for showing talkback context menu. We need + // to re-persist them if user hasn't changed them from old default ones. + if (previousVersion < 40500000) { + // Old shortcut for talkback context menu is unassigned. + updateKeyCombo( + mContext.getString(R.string.keycombo_shortcut_other_talkback_context_menu), + KEY_COMBO_CODE_UNASSIGNED); + } + } + + /** + * If the user hasn't changed their key combos for changing granularity/navigation settings, we + * should switch out the existing key combos for the new key combos. + */ + private void changeGranularityKeyCombos() { + // Old shortcut for increase granularity was Alt-Plus. + updateKeyCombo( + mContext.getString(R.string.keycombo_shortcut_granularity_increase), + KeyEvent.META_ALT_ON, + KeyEvent.KEYCODE_PLUS); + + // Old shortcut for decrease granularity was Alt-Minus. + updateKeyCombo( + mContext.getString(R.string.keycombo_shortcut_granularity_decrease), + KeyEvent.META_ALT_ON, + KeyEvent.KEYCODE_MINUS); + } + + /** + * Updates a key combo if the user has not yet changed it from the old default value. + * + * @param key the name of the key combo to change + * @param oldModifier the old default modifier assigned to the key combo + * @param oldKeyCode the old default keycode assigned to the key combo + */ + public void updateKeyCombo(String key, int oldModifier, int oldKeyCode) { + updateKeyCombo(key, KeyComboManager.getKeyComboCode(oldModifier, oldKeyCode)); + } + + /** + * Updates a key combo if the user has not yet changed it from the old default value. + * + * @param key the name of the key combo to change + * @param oldDefaultKeyComboCode the old default key combo. + */ + public void updateKeyCombo(String key, long oldDefaultKeyComboCode) { + final long newKeyComboCode = getDefaultKeyComboCode(key); + + if (getKeyForKeyComboCode(newKeyComboCode) != null) { + return; // User is already using the new key combo. + } + + if (mPersister.contains(key)) { + final long actualKeyComboCode = mPersister.getKeyComboCode(key); + if (oldDefaultKeyComboCode != actualKeyComboCode) { + return; // User has modified the key combo. + } + } + + if (newKeyComboCode != KEY_COMBO_CODE_UNASSIGNED) { + saveKeyComboCode(key, newKeyComboCode); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboPersister.java b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboPersister.java new file mode 100644 index 0000000..f8b332a --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyComboPersister.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.keyboard; + +import android.content.Context; +import android.content.SharedPreferences; +import com.google.android.accessibility.utils.SharedPreferencesUtils; + +/** Key value store to make key combo code persistent. */ +public class KeyComboPersister { + private static final String PREFIX_CONCATENATOR = "|"; + + private SharedPreferences mPrefs; + private final String mKeyPrefix; + + public KeyComboPersister(Context context, String keyPrefix) { + mPrefs = SharedPreferencesUtils.getSharedPreferences(context); + mKeyPrefix = keyPrefix; + } + + private String getPrefixedKey(String key) { + if (mKeyPrefix == null) { + return key; + } else { + return mKeyPrefix + PREFIX_CONCATENATOR + key; + } + } + + public void saveKeyCombo(String key, long keyComboCode) { + mPrefs.edit().putLong(getPrefixedKey(key), keyComboCode).apply(); + } + + public void remove(String key) { + mPrefs.edit().remove(getPrefixedKey(key)).apply(); + } + + public boolean contains(String key) { + return mPrefs.contains(getPrefixedKey(key)); + } + + public Long getKeyComboCode(String key) { + return mPrefs.getLong(getPrefixedKey(key), KeyComboModel.KEY_COMBO_CODE_UNASSIGNED); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyboardUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyboardUtils.java new file mode 100644 index 0000000..f83dd5f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/keyboard/KeyboardUtils.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2018 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.google.android.accessibility.utils.keyboard; + +import android.accessibilityservice.AccessibilityService; +import android.content.ComponentName; +import android.content.Context; +import android.content.res.Configuration; +import android.text.TextUtils; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodManager; +import com.google.android.accessibility.utils.AccessibilityServiceCompatUtils; +import java.util.ArrayList; +import java.util.List; + +/** Helper class for keyboard utility functions */ +public class KeyboardUtils { + + /** + * Returns true if either soft or hard keyboard is active. + * + * @param service Accessibility Service that is currently trying to get keyboard state. + * @return {@code true} if either soft or hard keyborad is active, else {@code false}. + */ + // TODO: Move the logic of updating keyboard state into WindowTracker. + public static boolean isKeyboardActive(AccessibilityService service) { + if (service == null) { + return false; + } + Configuration config = service.getResources().getConfiguration(); + + boolean isSoftKeyboardActive = AccessibilityServiceCompatUtils.isInputWindowOnScreen(service); + boolean isHardKeyboardActive = + (config.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO); + + return isSoftKeyboardActive || isHardKeyboardActive; + } + + /** Returns {@code true} if {@code componentName} is an enabled input method */ + public static boolean isImeEnabled(Context context, ComponentName componentName) { + for (InputMethodInfo inputMethodInfo : getEnabledInputMethodList(context)) { + if (inputMethodInfo.getComponent().equals(componentName)) { + return true; + } + } + return false; + } + + /** Returns {@code true} if the system has more than one IME enabled. */ + public static boolean areMultipleImesEnabled(Context context) { + List list = getEnabledInputMethodList(context); + return list != null && list.size() > 1; + } + + /** Gets id of first enabled {@link InputMethodInfo}whose package matches {@code packageName}. */ + public static String getEnabledImeId(Context context, String packageName) { + for (InputMethodInfo inputMethodInfo : getEnabledInputMethodList(context)) { + if (inputMethodInfo.getPackageName().equals(packageName)) { + return inputMethodInfo.getId(); + } + } + return ""; + } + + /** + * Returns next enabled ime id. If it's the tail of enabled list, return the first enabled ime. + * Empty if it's the only enabled ime. + */ + public static String getNextEnabledImeId(Context context) { + String id = ""; + String firstId = ""; + boolean next = false; + for (InputMethodInfo inputMethodInfo : getEnabledInputMethodList(context)) { + if (next) { + id = inputMethodInfo.getId(); + break; + } else if (inputMethodInfo.getPackageName().equals(context.getPackageName())) { + next = true; + } else if (TextUtils.isEmpty(firstId)) { + firstId = inputMethodInfo.getId(); + } + } + return next && TextUtils.isEmpty(id) ? firstId : id; + } + + private static List getEnabledInputMethodList(Context context) { + InputMethodManager inputMethodManager = + (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null) { + List inputMethodInfoList = inputMethodManager.getEnabledInputMethodList(); + if (inputMethodInfoList != null) { + return inputMethodInfoList; + } + } + return new ArrayList<>(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/labeling/Label.java b/utils/src/main/java/com/google/android/accessibility/utils/labeling/Label.java new file mode 100644 index 0000000..e8e31e8 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/labeling/Label.java @@ -0,0 +1,340 @@ +/* + * Copyright (C) 2013 Google Inc. + * + * 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.google.android.accessibility.utils.labeling; + +import com.google.android.accessibility.utils.LocaleUtils; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** A model class for a custom view label that TalkBack can speak. */ +public class Label { + /** The ID of a label that has not yet been stored in the database. */ + public static final long NO_ID = -1L; + + private static final String DEBUG_FORMAT_STRING = + "%s[id=%d, packageName=%s, " + + "packageSignature=%s, viewName=%s, text=%s, locale=%s, packageVersion=%d, " + + "screenshotPath=%s, timestamp=%d]"; + + private long mId; + private String mPackageName; + private String mPackageSignature; + private String mViewName; + private String mText; + private String mLocale; + private int mPackageVersion; + private String mScreenshotPath; + + /** + * The time the label was created or last modified, measured in milliseconds since midnight on + * January 1, 1970 UTC. + */ + private long mTimestampMillis; + + /** + * Creates a new label, usually one that exists in the local database. + * + * @param labelId A unique local identifier for the label. + * @param packageName The package name for the labeled application. + * @param packageSignature A string that uniquely identifies the package. + * @param viewName The fully qualified resource name for the labeled view. + * @param text The text that should be displayed as the label. + * @param locale The locale of the label text. + * @param packageVersion The labeled application's package version code. + * @param screenshotPath The local path to a screenshot of the labeled view. + * @param timestampMillis The time the label was created or last modified. + */ + public Label( + long labelId, + String packageName, + String packageSignature, + String viewName, + String text, + String locale, + int packageVersion, + String screenshotPath, + long timestampMillis) { + mId = labelId; + mPackageName = packageName; + mPackageSignature = packageSignature; + mViewName = viewName; + mText = text; + setLocale(locale); + mPackageVersion = packageVersion; + mScreenshotPath = screenshotPath; + mTimestampMillis = timestampMillis; + } + + /** + * Creates a new label, usually one that does not yet exist in the local database. + * + * @param packageName The package name for the labeled application. + * @param packageSignature A string uniquely representing the package. + * @param viewName The fully qualified resource name for the labeled view. + * @param text The text that should be displayed as the label. + * @param locale The locale of the label text. + * @param packageVersion The labeled application's package version code. + * @param screenshotPath The local path to a screenshot of the labeled view. + * @param timestamp The time the label was created or last modified. + */ + public Label( + String packageName, + String packageSignature, + String viewName, + String text, + String locale, + int packageVersion, + String screenshotPath, + long timestamp) { + this( + NO_ID, + packageName, + packageSignature, + viewName, + text, + locale, + packageVersion, + screenshotPath, + timestamp); + } + + /** + * Creates a label object that exists in the local database from a label object that does not + * exist in the local database using a deep copy. + * + *

This effectively assigns an ID to an existing label. + * + * @param labelWithoutId The existing label to copy, without an ID. + * @param labelId A unique local identifier for the label. + */ + public Label(Label labelWithoutId, long labelId) { + if (labelWithoutId.getId() != Label.NO_ID) { + throw new IllegalArgumentException("Label to copy cannot have an ID already assigned."); + } + + mId = labelId; + mPackageName = labelWithoutId.mPackageName; + mPackageSignature = labelWithoutId.mPackageSignature; + mViewName = labelWithoutId.mViewName; + mText = labelWithoutId.mText; + setLocale(labelWithoutId.mLocale); + mPackageVersion = labelWithoutId.mPackageVersion; + mScreenshotPath = labelWithoutId.mScreenshotPath; + mTimestampMillis = labelWithoutId.mTimestampMillis; + } + + /** + * @return A unique local identifier for the label, or {@link #NO_ID} if an identifier has not + * been assigned. + */ + public long getId() { + return mId; + } + + /** @return The package name for the application containing the label. */ + public String getPackageName() { + return mPackageName; + } + + /** @param packageName The package name for the labeled application. */ + public void setPackageName(String packageName) { + mPackageName = packageName; + } + + /** + * @return A hex-encoded SHA-1 hash of the signing certificates for the package containing the + * labeled application. + */ + public String getPackageSignature() { + return mPackageSignature; + } + + /** + * @param packageSignature A hex-encoded SHA-1 hash of the signing certificates for the package + * containing the labeled application. + */ + public void setPackageSignature(String packageSignature) { + mPackageSignature = packageSignature; + } + + /** @return The fully qualified resource name for the labeled view. */ + public String getViewName() { + return mViewName; + } + + /** @param viewName The fully qualified resource name for the labeled view. */ + public void setViewName(String viewName) { + mViewName = viewName; + } + + /** @return The text that should be displayed as the label. */ + public String getText() { + return mText; + } + + /** @param text The text that should be displayed as the label. */ + public void setText(String text) { + mText = text; + } + + /** @return The locale of the label text. */ + public String getLocale() { + return mLocale; + } + + /** @param locale The locale of the label text. */ + public void setLocale(String locale) { + mLocale = LocaleUtils.getLanguageLocale(locale); + } + + /** @return The package version code of the labeled application. */ + public int getPackageVersion() { + return mPackageVersion; + } + + /** @param packageVersion The labeled application's package version code. */ + public void setPackageVersion(int packageVersion) { + mPackageVersion = packageVersion; + } + + /** @return The local path to a screenshot of the labeled view. */ + public String getScreenshotPath() { + return mScreenshotPath; + } + + /** @param screenshotPath The local path to a screenshot of the labeled view. */ + public void setScreenshotPath(String screenshotPath) { + mScreenshotPath = screenshotPath; + } + + /** + * @return The time the label was created or last modified, measured in milliseconds since + * midnight on January 1, 1970 UTC. + */ + public long getTimestamp() { + return mTimestampMillis; + } + + /** + * @param timestampMillis The time the label was created or last modified, measured in + * milliseconds since midnight on Jan. 1, 1970 UTC. + */ + public void setTimestamp(long timestampMillis) { + mTimestampMillis = timestampMillis; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((mLocale == null) ? 0 : mLocale.hashCode()); + result = prime * result + ((mPackageName == null) ? 0 : mPackageName.hashCode()); + result = prime * result + ((mPackageSignature == null) ? 0 : mPackageSignature.hashCode()); + result = prime * result + ((mScreenshotPath == null) ? 0 : mScreenshotPath.hashCode()); + result = prime * result + ((mText == null) ? 0 : mText.hashCode()); + result = prime * result + (int) (mTimestampMillis ^ (mTimestampMillis >>> 32)); + result = prime * result + mPackageVersion; + result = prime * result + ((mViewName == null) ? 0 : mViewName.hashCode()); + return result; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof Label)) { + return false; + } + + final Label other = (Label) obj; + + if (mLocale == null) { + if (other.mLocale != null) { + return false; + } + } else if (!mLocale.equals(other.mLocale)) { + return false; + } + + if (mPackageName == null) { + if (other.mPackageName != null) { + return false; + } + } else if (!mPackageName.equals(other.mPackageName)) { + return false; + } + + if (mPackageSignature == null) { + if (other.mPackageSignature != null) { + return false; + } + } else if (!mPackageSignature.equals(other.mPackageSignature)) { + return false; + } + + if (mScreenshotPath == null) { + if (other.mScreenshotPath != null) { + return false; + } + } else if (!mScreenshotPath.equals(other.mScreenshotPath)) { + return false; + } + + if (mText == null) { + if (other.mText != null) { + return false; + } + } else if (!mText.equals(other.mText)) { + return false; + } + + if (mTimestampMillis != other.mTimestampMillis) { + return false; + } + + if (mPackageVersion != other.mPackageVersion) { + return false; + } + + if (mViewName == null) { + if (other.mViewName != null) { + return false; + } + } else if (!mViewName.equals(other.mViewName)) { + return false; + } + + return true; + } + + /** @return A text representation of the object and its fields for debugging. */ + @Override + public String toString() { + return String.format( + DEBUG_FORMAT_STRING, + getClass().getSimpleName(), + mId, + mPackageName, + mPackageSignature, + mViewName, + mText, + mLocale, + mPackageVersion, + mScreenshotPath, + mTimestampMillis); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/labeling/LabelManager.java b/utils/src/main/java/com/google/android/accessibility/utils/labeling/LabelManager.java new file mode 100644 index 0000000..06eed3b --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/labeling/LabelManager.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2017 Google Inc. + * + * 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.google.android.accessibility.utils.labeling; + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; + +/** An interface for retreiving custom labels. */ +public interface LabelManager { + static final int SOURCE_TYPE_USER = 0; // labels that were inserted by user + static final int SOURCE_TYPE_IMPORT = 1; // labels that were imported + static final int SOURCE_TYPE_BACKUP = 2; // labels that were overridden by import + + Label getLabelForViewIdFromCache(String resourceName); + /** Returns whether node needs a label. */ + boolean needsLabel(AccessibilityNodeInfoCompat node); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/labeling/LabelProviderClient.java b/utils/src/main/java/com/google/android/accessibility/utils/labeling/LabelProviderClient.java new file mode 100644 index 0000000..0f786a1 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/labeling/LabelProviderClient.java @@ -0,0 +1,775 @@ +/* + * Copyright (C) 2013 Google Inc. + * + * 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.google.android.accessibility.utils.labeling; + +import android.content.ContentProviderClient; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.util.Log; +import androidx.annotation.Nullable; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A client for storing and retrieving custom TalkBack view labels using the {@link Label} model + * class and a connection to a {@link android.content.ContentProvider} for labels. + */ +public class LabelProviderClient { + + private static final String TAG = "LabelProviderClient"; + + private static final String EQUALS_ARG = " = ?"; + private static final String NOT_EQUALS_ARG = " != ? "; + private static final String LEQ_ARG = " <= ?"; + private static final String STARTS_WITH_ARG = " LIKE ?"; + private static final String AND = " AND "; + private static final String GET_LABELS_FOR_APPLICATION_QUERY_WHERE = + LabelsTable.KEY_PACKAGE_NAME + + EQUALS_ARG + + AND + + LabelsTable.KEY_LOCALE + + STARTS_WITH_ARG + + AND + + LabelsTable.KEY_PACKAGE_VERSION + + LEQ_ARG + + AND + + LabelsTable.KEY_SOURCE_TYPE + + NOT_EQUALS_ARG; + private static final String PACKAGE_SUMMARY_QUERY_WHERE = + LabelsTable.KEY_LOCALE + STARTS_WITH_ARG + AND + LabelsTable.KEY_SOURCE_TYPE + NOT_EQUALS_ARG; + + private static final String DELETE_LABEL_SELECTION = + LabelsTable.KEY_PACKAGE_NAME + + EQUALS_ARG + + AND + + LabelsTable.KEY_VIEW_NAME + + EQUALS_ARG + + AND + + LabelsTable.KEY_LOCALE + + STARTS_WITH_ARG + + AND + + LabelsTable.KEY_PACKAGE_VERSION + + LEQ_ARG + + AND + + LabelsTable.KEY_SOURCE_TYPE + + EQUALS_ARG; + + private static final String LABELS_PATH = "labels"; + private static final String PACKAGE_SUMMARY_PATH = "packageSummary"; + + private ContentProviderClient mClient; + private final Uri mLabelsContentUri; + private final Uri mPackageSummaryContentUri; + + /** + * Constructs a new client instance for the provider at the given URI. + * + * @param context The current context. + * @param authority The authority of the labels content provider to access. + */ + public LabelProviderClient(Context context, String authority) { + mLabelsContentUri = + new Uri.Builder().scheme("content").authority(authority).path(LABELS_PATH).build(); + mPackageSummaryContentUri = + new Uri.Builder().scheme("content").authority(authority).path(PACKAGE_SUMMARY_PATH).build(); + + final ContentResolver contentResolver = context.getContentResolver(); + mClient = contentResolver.acquireContentProviderClient(mLabelsContentUri); + + if (mClient == null) { + LogUtils.w(TAG, "Failed to acquire content provider client."); + } + } + + /** + * Inserts the specified label into the labels database via a client for the labels {@link + * android.content.ContentProvider}. + * + *

Don't run this method on the UI thread. Use {@link android.os.AsyncTask}. + * + * @param label The model object for the label to store in the database. + * @return A new label object with the assigned label ID from the database, or {@code null} if the + * insert operation failed. + */ + @Nullable + public Label insertLabel(Label label, int sourceType) { + LogUtils.d(TAG, "Inserting label: %s.", label); + + if (label == null) { + return null; + } + + final long labelId = label.getId(); + if (label.getId() != Label.NO_ID) { + LogUtils.w(TAG, "Cannot insert label with existing ID (id=%d).", labelId); + return null; + } + + if (!checkClient()) { + return null; + } + + final ContentValues values = buildContentValuesForLabel(label); + values.put(LabelsTable.KEY_SOURCE_TYPE, sourceType); + + final Uri resultUri; + try { + resultUri = mClient.insert(mLabelsContentUri, values); + } catch (RemoteException e) { + LogUtils.e(TAG, "RemoteException caught!"); + LogUtils.d(TAG, e.toString()); + return null; + } + + if (resultUri == null) { + LogUtils.w(TAG, "Failed to insert label."); + return null; + } + + final long newLabelId = Long.parseLong(resultUri.getLastPathSegment()); + return new Label(label, newLabelId); + } + + /** + * Gets a list of all labels in the label database. + * + *

Don't run this method on the UI thread. Use {@link android.os.AsyncTask}. + * + * @return An unmodifiable list of all labels in the database, or an empty list if the query + * returns no results, or {@code null} if the query fails. + */ + @Nullable + public List

Don't run this method on the UI thread. Use {@link android.os.AsyncTask}. + * + * @return An unmodifiable list of {@link PackageLabelInfo} objects, or an empty map if the query + * returns no results, or {@code null} if the query fails. + */ + @Nullable + public List getPackageSummary(String locale) { + LogUtils.d(TAG, "Querying package summary."); + + if (!checkClient()) { + return null; + } + + final String[] whereArgs = {locale + "%", String.valueOf(LabelManager.SOURCE_TYPE_BACKUP)}; + + Cursor cursor = null; + try { + cursor = + mClient.query( + mPackageSummaryContentUri, + null /* projection */, + PACKAGE_SUMMARY_QUERY_WHERE, + whereArgs, + null /* sortOrder */); + + return getPackageSummaryFromCursor(cursor); + } catch (RemoteException e) { + LogUtils.e(TAG, "RemoteException caught!"); + LogUtils.d(TAG, e.toString()); + return null; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * Queries for labels matching a particular package and locale. + * + *

Don't run this method on the UI thread. Use {@link android.os.AsyncTask}. + * + * @param packageName The package name to match. + * @param locale The locale to match. + * @param maxPackageVersion The maximum package version for result labels. + * @return An unmodifiable map from view names to label objects that contains all labels matching + * the criteria, or {@code null} if the query failed. + */ + @Nullable + public Map getLabelsForPackage( + String packageName, String locale, int maxPackageVersion) { + LogUtils.d( + TAG, + "Querying labels for package: packageName=%s, locale=%s, maxPackageVersion=%s.", + packageName, + locale, + maxPackageVersion); + + if (!checkClient()) { + return null; + } + + final String[] whereArgs = + new String[] { + packageName, + locale + "%", + String.valueOf(maxPackageVersion), + String.valueOf(LabelManager.SOURCE_TYPE_BACKUP) + }; + + Cursor cursor = null; + try { + cursor = + mClient.query( + mLabelsContentUri, + LabelsTable.ALL_COLUMNS /* projection */, + GET_LABELS_FOR_APPLICATION_QUERY_WHERE, + whereArgs, + null /* sortOrder */); + + return getLabelMapFromCursor(cursor); + } catch (RemoteException e) { + LogUtils.e(TAG, "RemoteException caught!"); + LogUtils.d(TAG, e.toString()); + return null; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * Queries for labels matching a particular package and locale for all versions of that package. + * + *

Don't run this method on the UI thread. Use {@link android.os.AsyncTask}. + * + * @param packageName The package name to match. + * @param locale The locale to match. + * @return An unmodifiable map from view names to label objects that contains all labels matching + * the criteria, or {@code null} if the query failed. + */ + public Map getLabelsForPackage(String packageName, String locale) { + return getLabelsForPackage(packageName, locale, Integer.MAX_VALUE); + } + + /** + * Queries for a single label by its label ID. + * + *

Don't run this method on the UI thread. Use {@link android.os.AsyncTask}. + * + * @param id The ID of the label to find. + * @return The label with the given ID, or {@code null} if no such label was found. + */ + @Nullable + public Label getLabelById(long id) { + LogUtils.d(TAG, "Querying single label: id=%d.", id); + + if (!checkClient()) { + return null; + } + + final Uri uri = ContentUris.withAppendedId(mLabelsContentUri, id); + Cursor cursor = null; + try { + cursor = + mClient.query( + uri, + LabelsTable.ALL_COLUMNS, + null /* where */, + null /* whereArgs */, + null /* sortOrder */); + + return getLabelFromCursor(cursor); + } catch (RemoteException e) { + LogUtils.e(TAG, "RemoteException caught!"); + LogUtils.d(TAG, e.toString()); + return null; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * Updates a single label. + * + *

Don't run this method on the UI thread. Use {@link android.os.AsyncTask}. + * + * @param label The label with updated values to store. + * @return {@code true} if the update succeeded, or {@code false} otherwise. + */ + public boolean updateLabel(Label label, int newSourceType) { + LogUtils.d(TAG, "Updating label: %s.", label); + + if (label == null) { + return false; + } + + if (!checkClient()) { + return false; + } + + final long labelId = label.getId(); + + if (labelId == Label.NO_ID) { + LogUtils.w(TAG, "Cannot update label with no ID."); + return false; + } + + final Uri uri = ContentUris.withAppendedId(mLabelsContentUri, labelId); + final ContentValues values = buildContentValuesForLabel(label); + values.put(LabelsTable.KEY_SOURCE_TYPE, newSourceType); + + try { + final int rowsAffected = + mClient.update(uri, values, null /* selection */, null /* selectionArgs */); + return rowsAffected > 0; + } catch (RemoteException e) { + LogUtils.e(TAG, "RemoteException caught!"); + LogUtils.d(TAG, e.toString()); + return false; + } + } + + public boolean updateLabelSourceType(long labelId, int newSourceType) { + LogUtils.d(TAG, "Updating label source type"); + + if (!checkClient()) { + return false; + } + + if (labelId == Label.NO_ID) { + LogUtils.w(TAG, "Cannot update label with no ID."); + return false; + } + + final Uri uri = ContentUris.withAppendedId(mLabelsContentUri, labelId); + ContentValues values = new ContentValues(); + values.put(LabelsTable.KEY_SOURCE_TYPE, newSourceType); + + try { + final int rowsAffected = mClient.update(uri, values, null, null); + return rowsAffected > 0; + } catch (RemoteException e) { + LogUtils.e(TAG, "RemoteException caught!"); + LogUtils.d(TAG, e.toString()); + return false; + } + } + + public boolean updateSourceType(int currentSourceType, int newSourceType) { + LogUtils.d(TAG, "Updating source type"); + + if (!checkClient()) { + return false; + } + + ContentValues values = new ContentValues(); + values.put(LabelsTable.KEY_SOURCE_TYPE, newSourceType); + + try { + String selection = LabelsTable.KEY_SOURCE_TYPE + "=" + currentSourceType; + final int rowsAffected = mClient.update(mLabelsContentUri, values, selection, null); + return rowsAffected > 0; + } catch (RemoteException e) { + LogUtils.e(TAG, "RemoteException caught!"); + LogUtils.d(TAG, e.toString()); + return false; + } + } + + /** + * Deletes a single label. + * + *

Don't run this method on the UI thread. Use {@link android.os.AsyncTask}. + * + * @param labelId The label_id to delete. + * @return {@code true} if the delete succeeded, or {@code false} otherwise. + */ + public boolean deleteLabel(long labelId) { + LogUtils.d(TAG, "Deleting label: %s.", labelId); + + if (!checkClient()) { + return false; + } + + if (labelId == Label.NO_ID) { + LogUtils.w(TAG, "Cannot delete label with no ID."); + return false; + } + + final Uri uri = ContentUris.withAppendedId(mLabelsContentUri, labelId); + + try { + final int rowsAffected = mClient.delete(uri, null /* selection */, null /* selectionArgs */); + return rowsAffected > 0; + } catch (RemoteException e) { + LogUtils.e(TAG, "RemoteException caught!"); + LogUtils.d(TAG, e.toString()); + return false; + } + } + + public boolean deleteLabel( + String packageName, String viewName, String locale, int packageVersion, int sourceType) { + LogUtils.d( + TAG, + "Deleting label: package name: %s, view name: %s," + + " locale: %s, package version: %d, source type: %d", + packageName, + viewName, + locale, + packageVersion, + sourceType); + + if (!checkClient()) { + return false; + } + + try { + final String[] whereArgs = + new String[] { + packageName, + viewName, + locale + "%", + Integer.toString(packageVersion), + Integer.toString(sourceType) + }; + final int rowsAffected = mClient.delete(mLabelsContentUri, DELETE_LABEL_SELECTION, whereArgs); + return rowsAffected > 0; + } catch (RemoteException e) { + LogUtils.e(TAG, "RemoteException caught!"); + LogUtils.d(TAG, e.toString()); + return false; + } + } + + public boolean deleteLabels(int sourceType) { + LogUtils.d(TAG, "Deleting backup labels"); + + if (!checkClient()) { + return false; + } + + try { + String selection = LabelsTable.KEY_SOURCE_TYPE + " = " + sourceType; + int rowsAffected = mClient.delete(mLabelsContentUri, selection, null); + return rowsAffected > 0; + } catch (RemoteException e) { + LogUtils.e(TAG, "RemoteException caught!"); + LogUtils.d(TAG, e.toString()); + return false; + } + } + + /** Shuts down the client and releases any resources. */ + public void shutdown() { + if (checkClient()) { + mClient.release(); + mClient = null; + } + } + + /** + * Returns whether the client was properly initialized (non-null). + * + * @return {@code true} if client is non-null, or {@code false} otherwise. + */ + public boolean isInitialized() { + return mClient != null; + } + + /** + * Builds content values for the fields of a label. + * + * @param label The source of the values. + * @return A set of values representing the label. + */ + private static ContentValues buildContentValuesForLabel(Label label) { + final ContentValues values = new ContentValues(); + + values.put(LabelsTable.KEY_PACKAGE_NAME, label.getPackageName()); + values.put(LabelsTable.KEY_PACKAGE_SIGNATURE, label.getPackageSignature()); + values.put(LabelsTable.KEY_VIEW_NAME, label.getViewName()); + values.put(LabelsTable.KEY_TEXT, label.getText()); + values.put(LabelsTable.KEY_LOCALE, label.getLocale()); + values.put(LabelsTable.KEY_PACKAGE_VERSION, label.getPackageVersion()); + values.put(LabelsTable.KEY_SCREENSHOT_PATH, label.getScreenshotPath()); + values.put(LabelsTable.KEY_TIMESTAMP, label.getTimestamp()); + + return values; + } + + /** + * Gets a {@link Label} object from the data in the given cursor at the current row position. + * + * @param cursor The cursor to use to get the label. + * @return The label at the current cursor position, or {@code null} if the current cursor + * position has no row. + */ + @Nullable + private Label getLabelFromCursorAtCurrentPosition(Cursor cursor) { + if (cursor == null || cursor.isClosed() || cursor.isAfterLast()) { + LogUtils.w(TAG, "Failed to get label from cursor."); + return null; + } + + final long labelId = cursor.getLong(LabelsTable.INDEX_ID); + final String packageName = cursor.getString(LabelsTable.INDEX_PACKAGE_NAME); + final String packageSignature = cursor.getString(LabelsTable.INDEX_PACKAGE_SIGNATURE); + final String viewName = cursor.getString(LabelsTable.INDEX_VIEW_NAME); + final String text = cursor.getString(LabelsTable.INDEX_TEXT); + final String locale = cursor.getString(LabelsTable.INDEX_LOCALE); + final int packageVersion = cursor.getInt(LabelsTable.INDEX_PACKAGE_VERSION); + final String screenshotPath = cursor.getString(LabelsTable.INDEX_SCREENSHOT_PATH); + final long timestamp = cursor.getLong(LabelsTable.INDEX_TIMESTAMP); + + return new Label( + labelId, + packageName, + packageSignature, + viewName, + text, + locale, + packageVersion, + screenshotPath, + timestamp); + } + + /** + * Gets a pair of package name and label count from the data in the given cursor at the current + * row position. + * + * @param cursor The cursor to use to get the label. + * @return A pair of package name and label count, or {@code null} if the current cursor position + * has no row. + */ + @Nullable + private PackageLabelInfo getPackageLabelInfoFromCursor(Cursor cursor) { + if (cursor == null || cursor.isClosed() || cursor.isAfterLast()) { + LogUtils.w(TAG, "Failed to get PackageLabelInfo from cursor."); + return null; + } + + final String packageName = cursor.getString(0); + final int labelCount = cursor.getInt(1); + + return new PackageLabelInfo(packageName, labelCount); + } + + /** + * Gets a single label from a cursor as the result of a query. + * + * @param cursor The cursor from which to get the label. + * @return The label returned from the query, or {@code null} if no valid label was returned. + */ + @Nullable + private Label getLabelFromCursor(Cursor cursor) { + if (cursor == null) { + return null; + } + + cursor.moveToFirst(); + final Label result = getLabelFromCursorAtCurrentPosition(cursor); + + logResult(result); + + return result; + } + + /** + * Gets an unmodifiable list of labels from a cursor resulting from a query. + * + * @param cursor The cursor from which to get the labels. + * @return The unmodifiable list of labels returned from the query. + */ + private List

Note: This method can only be called once per instance of this class. + */ + public void createTable() throws SQLException { + if (mCreated) { + throw new IllegalStateException("createTable was already called on this instance."); + } + + mDatabase.execSQL(buildQueryString()); + mCreated = true; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/monitor/AudioPlaybackMonitor.java b/utils/src/main/java/com/google/android/accessibility/utils/monitor/AudioPlaybackMonitor.java new file mode 100644 index 0000000..fd80814 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/monitor/AudioPlaybackMonitor.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils.monitor; + +import android.annotation.TargetApi; +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.AudioManager.AudioPlaybackCallback; +import android.media.AudioPlaybackConfiguration; +import android.os.Build; +import com.google.android.accessibility.utils.BuildVersionUtils; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Monitors usage of AudioPlayback. */ +public class AudioPlaybackMonitor { + + /** Interface for AudioPlayback */ + public interface AudioPlaybackStateChangedListener { + void onAudioPlaybackActivated(); + } + + /** + * Brief explanation for why a given sound is playing. + * + *

This is essentially a type-safe wrapper around AudioAttribute's usage enum for the values + * most relevant to Android Accessibility. + */ + public enum PlaybackSource { + USAGE_ASSISTANCE_NAVIGATION_GUIDANCE( + AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE, "Navigation Guidance"), + USAGE_MEDIA(AudioAttributes.USAGE_MEDIA, "Usage Media"), + USAGE_ASSISTANT(AudioAttributes.USAGE_ASSISTANT, "Usage Assistant"); + + private final int id; + private final String name; + + PlaybackSource(int id, String name) { + this.id = id; + this.name = name; + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } + } + + private final @Nullable Context context; + private @Nullable AudioManager audioManager; + @Nullable private final AudioPlaybackCallback audioPlaybackCallback; + + @Nullable private AudioPlaybackStateChangedListener listener; + private boolean isPlaying = false; + + @TargetApi(Build.VERSION_CODES.O) + public AudioPlaybackMonitor(Context context) { + this.context = context; + if (BuildVersionUtils.isAtLeastO()) { + audioPlaybackCallback = + new AudioPlaybackCallback() { + @Override + public void onPlaybackConfigChanged(List configs) { + super.onPlaybackConfigChanged(configs); + final boolean isPlaying = containsAudioPlaybackSources(configs); + if (listener != null && !AudioPlaybackMonitor.this.isPlaying && isPlaying) { + listener.onAudioPlaybackActivated(); + } + AudioPlaybackMonitor.this.isPlaying = isPlaying; + } + }; + } else { + audioPlaybackCallback = null; + } + } + + private @Nullable AudioManager getAudioManager() { + if ((audioManager == null) && (context != null)) { + audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + } + return audioManager; + } + + public boolean isAudioPlaybackActive() { + if (audioPlaybackCallback != null) { + return isPlaying; + } else { + return false; + } + } + + public boolean isPlaybackSourceActive(PlaybackSource source) { + if (!BuildVersionUtils.isAtLeastO() || source == null) { + return false; + } + @Nullable AudioManager audioManagerNow = getAudioManager(); + if (audioManagerNow == null) { + return false; + } + List configs = audioManagerNow.getActivePlaybackConfigurations(); + for (AudioPlaybackConfiguration config : configs) { + if (config.getAudioAttributes().getUsage() == source.getId()) { + return true; + } + } + return false; + } + + @TargetApi(Build.VERSION_CODES.O) + public void onResumeInfrastructure() { + if (audioPlaybackCallback != null) { + isPlaying = false; + @Nullable AudioManager audioManagerNow = getAudioManager(); + if (audioManagerNow == null) { + return; + } + audioManagerNow.registerAudioPlaybackCallback(audioPlaybackCallback, null); + } + } + + @TargetApi(Build.VERSION_CODES.O) + public void onSuspendInfrastructure() { + if (audioPlaybackCallback != null) { + @Nullable AudioManager audioManagerNow = getAudioManager(); + if (audioManagerNow == null) { + return; + } + audioManagerNow.unregisterAudioPlaybackCallback(audioPlaybackCallback); + } + } + + public void setAudioPlaybackStateChangedListener(AudioPlaybackStateChangedListener listener) { + this.listener = listener; + } + + /** Returns status summary for logging only. */ + public String getStatusSummary() { + if (!BuildVersionUtils.isAtLeastO()) { + return ""; + } + String result = ""; + result += "["; + for (PlaybackSource source : PlaybackSource.values()) { + result += source.getName() + " status: "; + result += (isPlaybackSourceActive(source) ? "active" : "inactive"); + result += ";"; + } + result += "]"; + return result; + } + + @TargetApi(Build.VERSION_CODES.O) + private static boolean containsAudioPlaybackSources(List configs) { + if (configs == null) { + return false; + } + // Try to find a target audio source in the playback configurations. + for (PlaybackSource source : PlaybackSource.values()) { + int sourceId = source.getId(); + for (AudioPlaybackConfiguration config : configs) { + if (sourceId == config.getAudioAttributes().getUsage()) { + return true; + } + } + } + return false; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/monitor/HeadphoneStateMonitor.java b/utils/src/main/java/com/google/android/accessibility/utils/monitor/HeadphoneStateMonitor.java new file mode 100644 index 0000000..ac7a070 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/monitor/HeadphoneStateMonitor.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils.monitor; + +import android.content.Context; +import android.media.AudioDeviceCallback; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import java.util.HashSet; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Listens for headphone state. This should only be instantiated on Android M+ (API 23) -- earlier + * versions of Android can use static method isHeadphoneOn(). + */ +public class HeadphoneStateMonitor { + + /** An interface that can be used to listen to headphone state changes. */ + public interface Listener { + void onHeadphoneStateChanged(boolean hasHeadphones); + } + + private final Set mConnectedAudioDevices = new HashSet<>(); + private @Nullable AudioDeviceCallback audioDeviceCallback; + private Context mContext; + private @Nullable Listener listener; + + public HeadphoneStateMonitor(Context context) { + mContext = context; + } + + private @NonNull AudioDeviceCallback getAudioDeviceCallback() { + if (audioDeviceCallback == null) { + audioDeviceCallback = + new AudioDeviceCallback() { + @Override + public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { + // When devices are added, increase our count of active output devices. + for (AudioDeviceInfo device : addedDevices) { + if (isExternalDevice(device)) { + mConnectedAudioDevices.add(device.getId()); + } + } + if (listener != null) { + listener.onHeadphoneStateChanged(hasHeadphones()); + } + } + + @Override + public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { + // When devices are removed, decrease our count of active output devices. + for (AudioDeviceInfo device : removedDevices) { + if (isExternalDevice(device)) { + mConnectedAudioDevices.remove(device.getId()); + } + } + if (listener != null) { + listener.onHeadphoneStateChanged(hasHeadphones()); + } + } + }; + } + return audioDeviceCallback; + } + + /** Initializes this HeadphoneStateMonitor to start listening to headphone state changes. */ + public void startMonitoring() { + @Nullable AudioManager audioManager = + (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); + // Initialize the active device count. + mConnectedAudioDevices.clear(); + if (audioManager == null) { + return; + } + AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS); + for (AudioDeviceInfo device : devices) { + if (isExternalDevice(device)) { + mConnectedAudioDevices.add(device.getId()); + } + } + audioManager.registerAudioDeviceCallback( + getAudioDeviceCallback(), /* use the main thread */ null); + } + + /** Stop listening to headphone state changes. */ + public void stopMonitoring() { + @Nullable AudioManager audioManager = + (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) { + return; + } + audioManager.unregisterAudioDeviceCallback(getAudioDeviceCallback()); + } + + /** + * Whether headphones are present, i.e. if there is at least one output audio device. Provides the + * same result at isHeadphoneOn() for Android M and above. + */ + public boolean hasHeadphones() { + return !mConnectedAudioDevices.isEmpty(); + } + + /** + * Whether the device is currently connected to bluetooth or wired headphones for audio output. + * When called on older devices this use a deprecat methods on audioManager to get the same + * result. + */ + @SuppressWarnings("deprecation") + public static boolean isHeadphoneOn(Context context) { + @Nullable AudioManager audioManager = + (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) { + return false; + } + AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS); + for (AudioDeviceInfo device : devices) { + if (isExternalDevice(device)) { + // While there can be more than one external audio device, finding one is enough here. + return true; + } + } + return false; + } + + /** + * Sets a listener which will be informed of any headphone state changes. This listener is also + * called immediately with the current state. + */ + public void setHeadphoneListener(@Nullable Listener listener) { + this.listener = listener; + if (this.listener != null) { + this.listener.onHeadphoneStateChanged(hasHeadphones()); + } + } + + private static boolean isExternalDevice(AudioDeviceInfo device) { + return device.isSink() + && (device.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP + || device.getType() == AudioDeviceInfo.TYPE_AUX_LINE + || device.getType() == AudioDeviceInfo.TYPE_WIRED_HEADPHONES + || device.getType() == AudioDeviceInfo.TYPE_WIRED_HEADSET + || device.getType() == AudioDeviceInfo.TYPE_USB_HEADSET); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/monitor/MediaRecorderMonitor.java b/utils/src/main/java/com/google/android/accessibility/utils/monitor/MediaRecorderMonitor.java new file mode 100644 index 0000000..2c169c4 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/monitor/MediaRecorderMonitor.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.monitor; + +import static android.media.MediaRecorder.AudioSource.MIC; +import static android.media.MediaRecorder.AudioSource.VOICE_RECOGNITION; + +import android.annotation.TargetApi; +import android.content.Context; +import android.media.AudioManager; +import android.media.AudioManager.AudioRecordingCallback; +import android.media.AudioRecordingConfiguration; +import android.os.Build; +import com.google.android.accessibility.utils.BuildVersionUtils; +import com.google.android.accessibility.utils.compat.media.AudioSystemCompatUtils; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Monitors usage of microphone. */ +public class MediaRecorderMonitor { + public interface MicrophoneStateChangedListener { + void onMicrophoneActivated(); + } + + private final @Nullable AudioManager audioManager; + private final @Nullable AudioRecordingCallback audioRecordingCallback; + + private @Nullable MicrophoneStateChangedListener listener; + private boolean isRecording = false; + private boolean isVoiceRecognitionActive = false; + + public MediaRecorderMonitor(Context context) { + audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (BuildVersionUtils.isAtLeastN()) { + audioRecordingCallback = + new AudioRecordingCallback() { + @Override + public void onRecordingConfigChanged(List configs) { + super.onRecordingConfigChanged(configs); + isVoiceRecognitionActive = containsAudioSourceVoiceRecog(configs); + final boolean isRecording = containsAudioSources(configs); + if (!MediaRecorderMonitor.this.isRecording && isRecording && (listener != null)) { + listener.onMicrophoneActivated(); + } + MediaRecorderMonitor.this.isRecording = isRecording; + } + }; + } else { + audioRecordingCallback = null; + } + } + + public boolean isMicrophoneActive() { + if (audioRecordingCallback != null) { + return isRecording; + } else { + if (AudioSystemCompatUtils.isSourceActive(VOICE_RECOGNITION) + || AudioSystemCompatUtils.isSourceActive(MIC)) { + return true; + } + return false; + } + } + + public boolean isVoiceRecognitionActive() { + if (audioRecordingCallback != null) { + return isVoiceRecognitionActive; + } else { + return AudioSystemCompatUtils.isSourceActive(VOICE_RECOGNITION); + } + } + + @TargetApi(Build.VERSION_CODES.N) + public void onResumeInfrastructure() { + if ((audioRecordingCallback != null) && (audioManager != null)) { + isRecording = false; + audioManager.registerAudioRecordingCallback(audioRecordingCallback, null); + } + } + + @TargetApi(Build.VERSION_CODES.N) + public void onSuspendInfrastructure() { + if ((audioRecordingCallback != null) && (audioManager != null)) { + audioManager.unregisterAudioRecordingCallback(audioRecordingCallback); + } + } + + public void setMicrophoneStateChangedListener(MicrophoneStateChangedListener listener) { + this.listener = listener; + } + + @TargetApi(Build.VERSION_CODES.N) + private static boolean containsAudioSources(List configs) { + if (configs == null) { + return false; + } + // Try to find a target audio source in the recording configurations. + for (AudioRecordingConfiguration config : configs) { + if ((VOICE_RECOGNITION == config.getClientAudioSource()) + || (MIC == config.getClientAudioSource())) { + return true; + } + } + return false; + } + + private static boolean containsAudioSourceVoiceRecog(List configs) { + if (configs == null) { + return false; + } + for (AudioRecordingConfiguration config : configs) { + if (config.getClientAudioSource() == VOICE_RECOGNITION) { + return true; + } + } + return false; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/monitor/ScreenMonitor.java b/utils/src/main/java/com/google/android/accessibility/utils/monitor/ScreenMonitor.java new file mode 100644 index 0000000..78f76d7 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/monitor/ScreenMonitor.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2018 Google Inc. + * + * 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.google.android.accessibility.utils.monitor; + +import android.app.KeyguardManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.PowerManager; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** {@link BroadcastReceiver} for receiving updates for the screen state. */ +public final class ScreenMonitor extends BroadcastReceiver { + /** The intent filter to match phone and screen state changes. */ + private static final IntentFilter SCREEN_CHANGE_FILTER = new IntentFilter(); + + static { + SCREEN_CHANGE_FILTER.addAction(Intent.ACTION_SCREEN_ON); + SCREEN_CHANGE_FILTER.addAction(Intent.ACTION_SCREEN_OFF); + } + + private final PowerManager powerManager; + private final @Nullable ScreenStateChangeListener screenStateListener; + private boolean isScreenOn; + + /** Listens to changes when screen is turned off. */ + public interface ScreenStateChangeListener { + void screenTurnedOff(); + } + + /** + * Returns whether the device is currently locked. + * + * @return {@code true} if device is locked. + */ + public static boolean isDeviceLocked(Context context) { + KeyguardManager keyguardManager = + (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); + return ((keyguardManager != null) && keyguardManager.isKeyguardLocked()); + } + + public ScreenMonitor(PowerManager powerManager) { + this(powerManager, null); + } + + public ScreenMonitor( + PowerManager powerManager, @Nullable ScreenStateChangeListener screenStateListener) { + this.powerManager = powerManager; + this.screenStateListener = screenStateListener; + updateScreenState(); + } + + public boolean isScreenOn() { + return isScreenOn; + } + + public IntentFilter getFilter() { + return SCREEN_CHANGE_FILTER; + } + + public void updateScreenState() { + isScreenOn = (powerManager != null) && powerManager.isInteractive(); + } + + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action == null) { + return; + } + + switch (action) { + case Intent.ACTION_SCREEN_ON: + isScreenOn = true; + break; + case Intent.ACTION_SCREEN_OFF: + isScreenOn = false; + if (screenStateListener != null) { + screenStateListener.screenTurnedOff(); + } + break; + default: // fall out + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/monitor/VoiceActionDelegate.java b/utils/src/main/java/com/google/android/accessibility/utils/monitor/VoiceActionDelegate.java new file mode 100644 index 0000000..307fd40 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/monitor/VoiceActionDelegate.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2021 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.google.android.accessibility.utils.monitor; + +/** Gets status of media and microphone actions. */ +public interface VoiceActionDelegate { + boolean isVoiceRecognitionActive(); + + boolean isMicrophoneActive(); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ocr/OcrController.java b/utils/src/main/java/com/google/android/accessibility/utils/ocr/OcrController.java new file mode 100644 index 0000000..886b7c3 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ocr/OcrController.java @@ -0,0 +1,399 @@ +/* + * Copyright (C) 2021 Google Inc. + * + * 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.google.android.accessibility.utils.ocr; + +import static java.util.Comparator.comparing; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.Filter; +import com.google.android.accessibility.utils.RectUtils; +import com.google.android.libraries.accessibility.utils.bitmap.BitmapUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.common.collect.ImmutableList; +import com.google.mlkit.vision.common.InputImage; +import com.google.mlkit.vision.text.Text.Element; +import com.google.mlkit.vision.text.Text.Line; +import com.google.mlkit.vision.text.Text.TextBlock; +import com.google.mlkit.vision.text.TextRecognition; +import com.google.mlkit.vision.text.TextRecognizer; +import com.google.mlkit.vision.text.latin.TextRecognizerOptions; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * OcrController holds onto an OCR TextRecognizer, and allows consumers to perform OCR based on a + * list of {@link AccessibilityNodeInfoCompat}, and a screencapture {@link Bitmap}. + */ +public class OcrController { + + // TODO: Consider localizing wordSeparator and paragraphSeparator as a string resource + // (or, retrieve separators based on user's default language from system). + public static final String WORD_SEPARATOR = " "; + public static final String PARAGRAPH_SEPARATOR = "\n"; + private static final long DELAY_PARSER_OCR_RESULT_MS = 50; + /** Maximal waiting time of OCR results. */ + private static final long OCR_RESULT_MAX_WAITING_TIME_MS = 5000; + + public static final Comparator TEXT_BLOCK_POSITION_COMPARATOR = + comparing( + textBlock -> { + @Nullable Rect boundingBox = textBlock.getBoundingBox(); + return boundingBox == null ? new Rect() : boundingBox; + }, + RectUtils.RECT_POSITION_COMPARATOR); + private static final String TAG = "OcrController"; + + private final OcrListener ocrListener; + private final Handler handler; + // TextRecognizer (MlKitContext) may not be ready when the device just boots completely, so this + // recognizer can't be initialized in the constructor. + @Nullable private TextRecognizer recognizer; + + public OcrController(Context context, OcrListener ocrListener) { + this(new Handler(Looper.getMainLooper()), ocrListener, /* recognizer= */ null); + } + + public OcrController( + Handler handler, OcrListener ocrListener, @Nullable TextRecognizer recognizer) { + this.ocrListener = ocrListener; + this.handler = handler; + this.recognizer = recognizer; + } + + /** + * Closes the detector and releases its resources. Invokes this method when you no longer want to + * use OcrController. + */ + public void shutdown() { + if (recognizer != null) { + recognizer.close(); + recognizer = null; + } + } + + /** + * Recognize text in all the nodes by performing OCR on the cropped screenshots of all the nodes, + * and save the resulting TextBlocks in each node. + * + * @param image The {@link Bitmap} containing the screenshot. + * @param ocrInfos Provides some information of {@link AccessibilityNodeInfoCompat} nodes + * representing the on-screen {@link android.view.View}s whose screenshots we want to extract + * text from using OCR. Caller retains responsibility to recycle them. + * @param filter Only the nodes which are accepted by the filter will be recognized. + */ + public void recognizeTextForNodes( + Bitmap image, + List ocrInfos, + Rect selectionBounds, + Filter filter) { + if (recognizer == null) { + try { + recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS); + } catch (IllegalStateException e) { + LogUtils.w(TAG, "Fail to get TextRecognizer."); + // MlKitContext is not ready. + ocrListener.onOcrFinished(ocrInfos); + return; + } + } + + new Thread( + new OcrRunnable( + handler, ocrListener, recognizer, image, ocrInfos, selectionBounds, filter)) + .start(); + } + + private static void filterTextBlocks(OcrInfo ocrInfo, Rect selectionBounds) { + Rect nodeBounds = new Rect(); + ocrInfo.getBoundsInScreenForOcr(nodeBounds); + + List textBlocks = ocrInfo.getTextBlocks(); + if (textBlocks == null) { + return; + } + Set toBeRemoved = new HashSet<>(); + for (int i = textBlocks.size() - 1; i >= 0; i--) { + TextBlock block = textBlocks.get(i); + @Nullable Rect textBlockBox = block.getBoundingBox(); + if (textBlockBox == null) { + toBeRemoved.add(block); + continue; + } + + textBlockBox.offset(nodeBounds.left, nodeBounds.top); + + if (!Rect.intersects(textBlockBox, selectionBounds)) { + toBeRemoved.add(block); + } + } + textBlocks.removeAll(toBeRemoved); + } + + // TODO: Consolidate logic from getTextFromBlocks and createWordBoundsMapping. Consider using + // SpannableStringBuilder#setSpan to attach TextLocationSpans (containing Rects) to each element + // from OCR TextBlocks. + + /** + * Combine all the text from the textBlocks into one string, concatenating words and lines with + * wordSeparators (likely spaces), and TextBlocks with paragraphSeparators (likely newlines). + * + * @param textBlocks The TextBlocks that contain the resulting text from OCR. + * @return A string containing the combined text from all the TextBlocks. + */ + @Nullable + public static String getTextFromBlocks(@Nullable List textBlocks) { + if (textBlocks == null || textBlocks.isEmpty()) { + return null; + } + + StringBuilder text = new StringBuilder(); + + for (int i = 0; i < textBlocks.size(); i++) { + TextBlock textBlock = textBlocks.get(i); + + for (Line line : textBlock.getLines()) { + for (Element word : line.getElements()) { + text.append(word.getText().trim()).append(WORD_SEPARATOR); + } + } + + // TODO: Can we just assume all TextBlocks aren't empty (i.e. always contain Lines)? + // If this TextBlock isn't empty (i.e. contains Lines), replace the just-added wordSeparator + // with a paragraphSeparator (if this isn't the last textblock) or remove the just-added + // wordSeparator (if this is the last textblock). + if (!textBlock.getLines().isEmpty()) { + if (i < textBlocks.size() - 1) { + text.replace(text.length() - WORD_SEPARATOR.length(), text.length(), PARAGRAPH_SEPARATOR); + } else { + text.replace(text.length() - WORD_SEPARATOR.length(), text.length(), ""); + } + } + } + + return text.toString(); + } + + /** + * Interprets OCR result into a list of {@link TextBlock} sorted by {@link + * #TEXT_BLOCK_POSITION_COMPARATOR}. + */ + @Nullable + @VisibleForTesting + public static List ocrResultToSortedList(@Nullable List textBlocks) { + if (textBlocks == null) { + return null; + } + List result = new ArrayList<>(); + for (TextBlock item : textBlocks) { + if (item == null || item.getBoundingBox() == null) { + continue; + } + result.add(item); + } + + if (result.isEmpty()) { + return null; + } + + result.sort(TEXT_BLOCK_POSITION_COMPARATOR); + return result; + } + + /** + * Listener callback interface for performing OCR. + * + * @see OcrController#recognizeTextForNodes(Bitmap, List, Rect, Filter) + */ + public interface OcrListener { + + /** Invoked when OCR started. */ + void onOcrStarted(); + + /** + * Invoked when OCR completes. + * + * @param ocrResults The {@link List} of {@link AccessibilityNodeInfoCompat} and their + * ocrTextBlocks which sets with {@link List}s of {@link TextBlock}s that contain the lines, + * words, and bounding boxes detected by OCR. For reference, see: + * https://developers.google.com/android/reference/com/google/mlkit/vision/text/Text.TextBlock + */ + void onOcrFinished(List ocrResults); + } + + /** Performs OCR and gets text blocks from OCR results. */ + @VisibleForTesting + static class OcrRunnable implements Runnable { + + private final Handler handler; + private final OcrListener ocrListener; + private final TextRecognizer recognizer; + private final Bitmap screenshot; + private final List ocrInfos; + @Nullable private final Rect selectionBounds; + private final Filter filter; + + public OcrRunnable( + Handler handler, + OcrListener ocrListener, + TextRecognizer recognizer, + Bitmap screenshot, + List ocrInfos, + @Nullable Rect selectionBounds, + Filter filter) { + this.handler = handler; + this.ocrListener = ocrListener; + this.recognizer = recognizer; + this.screenshot = screenshot; + this.ocrInfos = ocrInfos; + this.selectionBounds = selectionBounds; + this.filter = filter; + } + + @Override + public void run() { + // Add recognized text to HashMap instead of mutating the nodes inside the thread. + ConcurrentHashMap> textBlocksMap = new ConcurrentHashMap<>(); + ParserResultRunnable runnable = + new ParserResultRunnable(handler, ocrInfos, textBlocksMap, selectionBounds, ocrListener); + + for (OcrInfo ocrInfo : ocrInfos) { + AccessibilityNodeInfoCompat node = ocrInfo.getNode(); + if (filter.accept(node)) { + Rect nodeBounds = new Rect(); + ocrInfo.getBoundsInScreenForOcr(nodeBounds); + + if (screenshot.isRecycled()) { + LogUtils.w(TAG, "Screenshot has been recycled."); + break; + } + + Bitmap croppedBitmap; + try { + croppedBitmap = BitmapUtils.cropBitmap(screenshot, nodeBounds); + } catch (IllegalArgumentException e) { + LogUtils.w(TAG, e.getMessage() == null ? "Fail to crop screenshot." : e.getMessage()); + continue; + } + + if (croppedBitmap == null) { + continue; + } + + runnable.addRecognitionCount(); + recognizer + .process(InputImage.fromBitmap(croppedBitmap, /* rotationDegrees= */ 0)) + .addOnSuccessListener(text -> textBlocksMap.put(ocrInfo, text.getTextBlocks())) + .addOnFailureListener( + exception -> { + LogUtils.w(TAG, "Fail to recognize text. errMsg=" + exception.getMessage()); + textBlocksMap.put(ocrInfo, ImmutableList.of()); + }); + } + } + handler.postDelayed(runnable, DELAY_PARSER_OCR_RESULT_MS); + } + } + + /** Parsers OCR results and notifies caller that OCR is finished. */ + @VisibleForTesting + static class ParserResultRunnable implements Runnable { + + private final Handler handler; + private final List ocrInfos; + @Nullable private final Rect selectionBounds; + private final ConcurrentHashMap> textBlocksMap; + private final OcrListener ocrListener; + private long waitingTimeMs; + private int recognitionNumber = 0; + + public ParserResultRunnable( + Handler handler, + List ocrInfos, + ConcurrentHashMap> textBlocksMap, + @Nullable Rect selectionBounds, + OcrListener ocrListener) { + this.handler = handler; + this.ocrInfos = ocrInfos; + this.textBlocksMap = textBlocksMap; + this.selectionBounds = selectionBounds; + this.ocrListener = ocrListener; + this.waitingTimeMs = 0; + } + + /** Checks if all OCR tasks for the nodes in ocrInfos are finished. */ + public boolean isOcrFinished() { + return textBlocksMap.size() == recognitionNumber; + } + + public synchronized void addRecognitionCount() { + recognitionNumber++; + } + + @Override + public void run() { + // If there are unfinished recognition tasks, waiting DELAY_PARSER_OCR_RESULT_MS then checking + // again. + if (!isOcrFinished() && waitingTimeMs < OCR_RESULT_MAX_WAITING_TIME_MS) { + waitingTimeMs = waitingTimeMs + DELAY_PARSER_OCR_RESULT_MS; + LogUtils.v(TAG, "waiting for OCR result... timeout=" + waitingTimeMs); + handler.postDelayed(this, DELAY_PARSER_OCR_RESULT_MS); + return; + } + + boolean isOcrInAction = false; + + // Copies OCR results which are represented by a list of text blocks (a.k.a. paragraphs) into + // nodes that requested OCR. + for (OcrInfo ocrInfo : ocrInfos) { + List textBlocks = ocrResultToSortedList(textBlocksMap.get(ocrInfo)); + ocrInfo.setTextBlocks(textBlocks); + + // It's unnecessary to check all OCR results. isOCRInAction will be true as long as + // there is a non-empty OCR result. + if (!isOcrInAction && !TextUtils.isEmpty(getTextFromBlocks(textBlocks))) { + isOcrInAction = true; + } + } + + if (isOcrInAction) { + ocrListener.onOcrStarted(); + } + + // If the user selected only one node, and the node has non-null ocrTextBlocks, then + // filter the textblocks down to only textBlocks that intersect with selectionBounds + if (selectionBounds != null + && ocrInfos.size() == 1 + && ocrInfos.get(0).getTextBlocks() != null) { + filterTextBlocks(ocrInfos.get(0), selectionBounds); + } + + ocrListener.onOcrFinished(ocrInfos); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ocr/OcrInfo.java b/utils/src/main/java/com/google/android/accessibility/utils/ocr/OcrInfo.java new file mode 100644 index 0000000..497fcd9 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ocr/OcrInfo.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2021 Google Inc. + * + * 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.google.android.accessibility.utils.ocr; + +import android.graphics.Rect; +import androidx.annotation.Nullable; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.mlkit.vision.text.Text.TextBlock; +import java.util.List; + +/** + * Stores some node information for performing OCR on {@link AccessibilityNodeInfoCompat}, and OCR + * result. + */ +public class OcrInfo { + + public final AccessibilityNodeInfoCompat node; + @Nullable public List textBlocks; + + /** Caller retains responsibility to recycle the node. */ + public OcrInfo(AccessibilityNodeInfoCompat node) { + this.node = node; + } + + public AccessibilityNodeInfoCompat getNode() { + return node; + } + + public void getBoundsInScreenForOcr(Rect bounds) { + node.getBoundsInScreen(bounds); + } + + public void setTextBlocks(@Nullable List textBlocks) { + this.textBlocks = textBlocks; + } + + @Nullable + public List getTextBlocks() { + return textBlocks; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/ActorStateProvider.java b/utils/src/main/java/com/google/android/accessibility/utils/output/ActorStateProvider.java new file mode 100644 index 0000000..903955b --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/ActorStateProvider.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2021 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.google.android.accessibility.utils.output; + +import com.google.errorprone.annotations.CheckReturnValue; + +/** + * Provides actor-state data to event-interpreters. This default-implementation should be partially + * overridden by accessibility-services. + */ +@CheckReturnValue +public class ActorStateProvider { + + /** + * Returns whether the accessibility-service is automatically sending text-cursor to end, instead + * of a real text-editing action. + */ + public boolean resettingNodeCursor() { + return false; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/EarconsPlayTask.java b/utils/src/main/java/com/google/android/accessibility/utils/output/EarconsPlayTask.java new file mode 100644 index 0000000..e288eab --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/EarconsPlayTask.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.output; + +import android.media.SoundPool; +import android.os.AsyncTask; + +/** Task to play earcons in background thread */ +public class EarconsPlayTask extends AsyncTask { + private SoundPool mSoundPool; + private int soundId; + private float volume; + private float rate; + + public EarconsPlayTask(SoundPool soundPool, int soundId, float volume, float rate) { + this.mSoundPool = soundPool; + this.soundId = soundId; + this.volume = volume; + this.rate = rate; + } + + /** + * Play earcon with given soundId in background thread + * + * @param voids not using any parameters as they will passed in via constructor + * @return if play successful or not + */ + @Override + protected Boolean doInBackground(Void... voids) { + return mSoundPool.play(soundId, volume, volume, 0, 0, rate) != 0; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/FailoverTextToSpeech.java b/utils/src/main/java/com/google/android/accessibility/utils/output/FailoverTextToSpeech.java new file mode 100644 index 0000000..589c694 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/FailoverTextToSpeech.java @@ -0,0 +1,1220 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.output; + +import android.annotation.TargetApi; +import android.content.BroadcastReceiver; +import android.content.ComponentCallbacks; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.database.ContentObserver; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Message; +import android.os.PowerManager; +import android.provider.Settings.Secure; +import android.speech.tts.TextToSpeech; +import android.speech.tts.TextToSpeech.Engine; +import android.speech.tts.TextToSpeech.OnInitListener; +import android.speech.tts.UtteranceProgressListener; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.Log; +import android.util.Pair; +import com.google.android.accessibility.utils.BuildVersionUtils; +import com.google.android.accessibility.utils.Performance; +import com.google.android.accessibility.utils.WeakReferenceHandler; +import com.google.android.accessibility.utils.compat.provider.SettingsCompatUtils.SecureCompatUtils; +import com.google.android.accessibility.utils.compat.speech.tts.TextToSpeechCompatUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Wrapper for {@link TextToSpeech} that handles fail-over when a specific engine does not work. + * + *

Does NOT implement queuing! Every call to {@link #speak} flushes the global + * speech queue. + * + *

This wrapper handles the following: + * + *

    + *
  • Fail-over from a failing TTS to a working one + *
  • Splitting utterances into <4k character chunks + *
  • Switching to the system TTS when media is unmounted + *
  • Utterance-specific pitch and rate changes + *
  • Pitch and rate changes relative to the user preference + *
+ */ +@SuppressWarnings("deprecation") +public class FailoverTextToSpeech { + private static final String TAG = "FailoverTextToSpeech"; + + /** The package name for the Google TTS engine. */ + private static final String PACKAGE_GOOGLE_TTS = "com.google.android.tts"; + + /** Number of times a TTS engine can fail before switching. */ + private static final int MAX_TTS_FAILURES = 3; + + /** Maximum number of TTS error messages to print to the log. */ + private static final int MAX_LOG_MESSAGES = 10; + + /** Class defining constants used for describing speech parameters. */ + public static class SpeechParam { + /** Float parameter for controlling speech volume. Range is {0 ... 2}. */ + public static final String VOLUME = TextToSpeech.Engine.KEY_PARAM_VOLUME; + + /** Float parameter for controlling speech rate. Range is {0 ... 2}. */ + public static final String RATE = "rate"; + + /** Float parameter for controlling speech pitch. Range is {0 ... 2}. */ + public static final String PITCH = "pitch"; + } + + /** + * Constant to flush speech globally. The constant corresponds to the non-public API {@link + * TextToSpeech#QUEUE_DESTROY}. To avoid a bug, we always need to use {@link + * TextToSpeech#QUEUE_FLUSH} before using {@link #SPEECH_FLUSH_ALL} -- on Android version M only. + */ + private static final int SPEECH_FLUSH_ALL = 2; + + /** + * What fraction of the volume seekbar corresponds to a doubling of audio volume. + * + *

During a phone call, TalkBack speech is redirected from STREAM_MUSIC to STREAM_VOICE_CALL, + * causing an unexpected change in TalkBack speech volume. During a phone call, we reduce the + * TalkBack speech volume based on the volume difference between STREAM_MUSIC and + * STREAM_VOICE_CALL. VOLUME_FRAC_PER_DOUBLING controls the amount of volume reduction per + * difference of STREAM_MUSIC vs STREAM_VOICE_CALL. + * + *

On nexus 6, volume doubles every 11% volume seekbar step. On samsung s5, volume doubles + * every 27% volume step. Setting adjustment too aggressively (too low) causes effective volume to + * go down when call volume is higher -- the call volume seekbar would work in reverse for + * TalkBack speech. Setting this adjustment too conservatively (too high) causes the original + * volume jump to continue, though in lesser degree. + */ + private static final float VOLUME_FRAC_PER_DOUBLING = 0.25f; + + /** + * {@link BroadcastReceiver} for determining changes in the media state used for switching the TTS + * engine. + */ + private final MediaMountStateMonitor mMediaStateMonitor = new MediaMountStateMonitor(); + + /** A list of installed TTS engines. */ + private final LinkedList mInstalledTtsEngines = new LinkedList<>(); + + private final Context mContext; + private final ContentResolver mResolver; + + /** The TTS engine. */ + private TextToSpeech mTts; + + /** The engine loaded into the current TTS. */ + private String mTtsEngine; + + /** The number of time the current TTS has failed consecutively. */ + private int mTtsFailures; + + /** The package name of the preferred TTS engine. */ + private String mDefaultTtsEngine; + + /** The package name of the system TTS engine. */ + private String mSystemTtsEngine; + + /** A temporary TTS used for switching engines. */ + private @Nullable TextToSpeech mTempTts; + + /** The engine loading into the temporary TTS. */ + private @Nullable String mTempTtsEngine; + + /** The rate adjustment specified in {@link android.provider.Settings}. */ + private float mDefaultRate; + + /** The pitch adjustment specified in {@link android.provider.Settings}. */ + private float mDefaultPitch; + + private List mListeners = new ArrayList<>(); + + /** Wake lock for keeping the device unlocked while reading */ + private PowerManager.WakeLock mWakeLock; + + private final AudioManager mAudioManager; + private final TelephonyManager mTelephonyManager; + + private boolean mShouldHandleTtsCallbackInMainThread = true; + + /** + * A buffer of N most recent utterance ids, used to ensure that a recent utterance's completion + * handler does not unlock a WakeLock used by the currently speaking utterance. + */ + private LinkedList mRecentUtteranceIds = new LinkedList<>(); // may contain nulls + + public FailoverTextToSpeech(Context context) { + mContext = context; + mContext.registerReceiver(mMediaStateMonitor, mMediaStateMonitor.getFilter()); + + final Uri defaultSynth = Secure.getUriFor(Secure.TTS_DEFAULT_SYNTH); + final Uri defaultPitch = Secure.getUriFor(Secure.TTS_DEFAULT_PITCH); + final Uri defaultRate = Secure.getUriFor(Secure.TTS_DEFAULT_RATE); + + mResolver = context.getContentResolver(); + mResolver.registerContentObserver(defaultSynth, false, mSynthObserver); + mResolver.registerContentObserver(defaultPitch, false, mPitchObserver); + mResolver.registerContentObserver(defaultRate, false, mRateObserver); + + registerGoogleTtsFixCallbacks(); + + updateDefaultPitch(); + updateDefaultRate(); + + // Updating the default engine reloads the list of installed engines and + // the system engine. This also loads the default engine. + updateDefaultEngine(); + + // connect to system services + initWakeLock(context); + mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); + mTelephonyManager = (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE); + } + + /** Separate function for overriding in unit tests, because WakeLock cannot be mocked. */ + protected void initWakeLock(Context context) { + mWakeLock = + ((PowerManager) context.getSystemService(Context.POWER_SERVICE)) + .newWakeLock(PowerManager.SCREEN_DIM_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, TAG); + } + + /** + * Add a new listener for changes in speaking state. + * + * @param listener The listener to add. + */ + public void addListener(FailoverTtsListener listener) { + mListeners.add(listener); + } + + /** + * Whether the text-to-speech engine is ready to speak. + * + * @return {@code true} if calling {@link #speak} is expected to succeed. + */ + public boolean isReady() { + return (mTts != null); + } + + /** + * Returns the label for the current text-to-speech engine. + * + * @return The localized name of the current engine. + */ + public @Nullable CharSequence getEngineLabel() { + return TextToSpeechUtils.getLabelForEngine(mContext, mTtsEngine); + } + + /** + * Returns the {@link TextToSpeech} instance that is currently being used as the engine. + * + * @return The engine instance. + */ + @SuppressWarnings("UnusedDeclaration") // Used by analytics + public TextToSpeech getEngineInstance() { + return mTts; + } + + /** + * Sets whether to handle TTS callback in main thread. If {@code false}, the callback will be + * handled in TTS thread. + */ + public void setHandleTtsCallbackInMainThread(boolean shouldHandleInMainThread) { + mShouldHandleTtsCallbackInMainThread = shouldHandleInMainThread; + } + + /** + * Speak the specified text. + * + * @param text The text to speak. + * @param locale Language of the text. + * @param pitch The pitch adjustment, in the range [0 ... 1]. + * @param rate The rate adjustment, in the range [0 ... 1]. + * @param params The parameters to pass to the text-to-speech engine. + */ + public void speak( + CharSequence text, + @Nullable Locale locale, + float pitch, + float rate, + HashMap params, + int stream, + float volume, + boolean preventDeviceSleep) { + + String utteranceId = params.get(Engine.KEY_PARAM_UTTERANCE_ID); + addRecentUtteranceId(utteranceId); + + // Handle empty text immediately. + if (TextUtils.isEmpty(text)) { + mHandler.onUtteranceCompleted(params.get(Engine.KEY_PARAM_UTTERANCE_ID), /* success= */ true); + return; + } + + int result; + + volume *= calculateVolumeAdjustment(); + + if (preventDeviceSleep && mWakeLock != null && !mWakeLock.isHeld()) { + mWakeLock.acquire(); + } + + Exception failureException = null; + try { + result = trySpeak(text, locale, pitch, rate, params, stream, volume); + } catch (Exception e) { + failureException = e; + result = TextToSpeech.ERROR; + allowDeviceSleep(); + } + + if (result == TextToSpeech.ERROR) { + attemptTtsFailover(mTtsEngine); + } + + if ((result != TextToSpeech.SUCCESS) && params.containsKey(Engine.KEY_PARAM_UTTERANCE_ID)) { + if (failureException != null) { + LogUtils.w(TAG, "Failed to speak %s due to an exception", text); + failureException.printStackTrace(); + } else { + LogUtils.w(TAG, "Failed to speak %s", text); + } + + mHandler.onUtteranceCompleted(params.get(Engine.KEY_PARAM_UTTERANCE_ID), /* success= */ true); + } + } + + /** Adjust volume if we are in a phone call and speaking with phone audio stream * */ + private float calculateVolumeAdjustment() { + float multiple = 1.0f; + + // Accessibility services will eventually have their own audio stream, making this + // adjustment unnecessary. + if (!BuildVersionUtils.isAtLeastN()) { + + // If we are in a phone call... + // (Phone call state is often reported late, missing the first utterance.) + if (mTelephonyManager != null) { + int callState = mTelephonyManager.getCallState(); + if (callState != TelephonyManager.CALL_STATE_IDLE) { + // find audio stream volumes + if (mAudioManager != null) { + int volumeMusic = mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC); + if (volumeMusic <= 0) { + return 0.0f; + } + int volumeVoice = mAudioManager.getStreamVolume(AudioManager.STREAM_VOICE_CALL); + int maxVolMusic = mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC); + int maxVolVoice = mAudioManager.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL); + float volumeMusicFrac = + (maxVolMusic <= 0) ? -1.0f : (float) volumeMusic / (float) maxVolMusic; + float volumeVoiceCallFrac = + (maxVolVoice <= 0) ? -1.0f : (float) volumeVoice / (float) maxVolVoice; + // If phone volume is higher than talkback/media volume... + if (0.0f <= volumeMusicFrac && volumeMusicFrac < volumeVoiceCallFrac) { + // Reduce effective volume closer to media volume. + // The UI volume seekbars have an exponential effect on volume, + // but text-to-speech volume multiple has a linear effect. + // So take the Nth root of the volume difference to reduce speech + // volume multiplier exponentially, to match the volume seekbar effect. + float diff = volumeVoiceCallFrac - volumeMusicFrac; + float num_doubling_steps = diff / VOLUME_FRAC_PER_DOUBLING; + multiple = (float) Math.pow(2.0f, -num_doubling_steps); + } + } + } + } + } + return multiple; + } + + /** Releases the {@link PowerManager.WakeLock} */ + private void allowDeviceSleep() { + allowDeviceSleep(null); + } + + private void allowDeviceSleep(@Nullable String completedUtteranceId) { + if (mWakeLock != null && mWakeLock.isHeld()) { + boolean isRecent = mRecentUtteranceIds.contains(completedUtteranceId); + boolean isLast = + mRecentUtteranceIds.size() > 0 + && mRecentUtteranceIds.getLast().equals(completedUtteranceId); + if (completedUtteranceId == null || isLast || !isRecent) { + mWakeLock.release(); + } + } + } + + private void addRecentUtteranceId(String utteranceId) { + mRecentUtteranceIds.add(utteranceId); + while (mRecentUtteranceIds.size() > 10) { + mRecentUtteranceIds.remove(); + } + } + + /** For use in unit tests */ + public List getRecentUtteranceIds() { + return Collections.unmodifiableList(mRecentUtteranceIds); + } + + /** Stops speech from all applications. No utterance callbacks will be sent. */ + public void stopAll() { + try { + allowDeviceSleep(); + ensureQueueFlush(); + mTts.speak("", SPEECH_FLUSH_ALL, null); + } catch (Exception e) { + // Don't care, we're not speaking. + } + } + + /** Stops all speech that originated from TalkBack. No utterance callbacks will be sent. */ + public void stopFromTalkBack() { + try { + allowDeviceSleep(); + mTts.speak("", TextToSpeech.QUEUE_FLUSH, null); + } catch (Exception e) { + // Don't care, we're not speaking. + } + } + + /** + * Unregisters receivers, observers, and shuts down the text-to-speech engine. No calls should be + * made to this object after calling this method. + */ + public void shutdown() { + allowDeviceSleep(); + mContext.unregisterReceiver(mMediaStateMonitor); + unregisterGoogleTtsFixCallbacks(); + + mResolver.unregisterContentObserver(mSynthObserver); + mResolver.unregisterContentObserver(mPitchObserver); + mResolver.unregisterContentObserver(mRateObserver); + + TextToSpeechUtils.attemptTtsShutdown(mTts); + mTts = null; + + TextToSpeechUtils.attemptTtsShutdown(mTempTts); + mTempTts = null; + } + + /** + * Attempts to speak the specified text. + * + * @param text to speak, must be under 3999 chars. + * @param locale language to speak with. Use default language if it's null. + * @param pitch to speak text in. + * @param rate to speak text in. + * @param params to the TTS. + * @return The result of speaking the specified text. + */ + private int trySpeak( + CharSequence text, + @Nullable Locale locale, + float pitch, + float rate, + HashMap params, + int stream, + float volume) { + if (mTts == null) { + return TextToSpeech.ERROR; + } + + float effectivePitch = (pitch * mDefaultPitch); + float effectiveRate = (rate * mDefaultRate); + + String utteranceId = params.get(Engine.KEY_PARAM_UTTERANCE_ID); + if ((locale != null) && !locale.equals(mLastUtteranceLocale)) { + if (attemptSetLanguage(locale)) { + mLastUtteranceLocale = locale; + } + } else if ((locale == null) && (mLastUtteranceLocale != null)) { + ensureSupportedLocale(); + mLastUtteranceLocale = null; + } + int result = speak(text, params, utteranceId, effectivePitch, effectiveRate, stream, volume); + + if (result != TextToSpeech.SUCCESS) { + ensureSupportedLocale(); + } + + LogUtils.d(TAG, "Speak call for %s returned %d", utteranceId, result); + return result; + } + + private int speak( + CharSequence text, + HashMap params, + String utteranceId, + float pitch, + float rate, + int stream, + float volume) { + Bundle bundle = new Bundle(); + + if (params != null) { + for (String key : params.keySet()) { + bundle.putString(key, params.get(key)); + } + } + + bundle.putInt(SpeechParam.PITCH, (int) (pitch * 100)); + bundle.putInt(SpeechParam.RATE, (int) (rate * 100)); + bundle.putInt(Engine.KEY_PARAM_STREAM, stream); + bundle.putFloat(SpeechParam.VOLUME, volume); + + ensureQueueFlush(); + return mTts.speak(text, SPEECH_FLUSH_ALL, bundle, utteranceId); + } + + /** + * Flushes the TextToSpeech queue for fast speech queueing, needed only on Android M. + * REFERTO + */ + private void ensureQueueFlush() { + if (BuildVersionUtils.isM()) { + mTts.speak("", TextToSpeech.QUEUE_FLUSH, null, null); + } + } + + /** + * Try to switch the TTS engine. + * + * @param engine The package name of the desired TTS engine + */ + private void setTtsEngine(String engine, boolean resetFailures) { + if (resetFailures) { + mTtsFailures = 0; + } + + // Always try to stop the current engine before switching. + TextToSpeechUtils.attemptTtsShutdown(mTts); + TextToSpeechUtils.attemptTtsShutdown(mTempTts); + + if (mTempTts == null || mTempTts.getLanguage() == null) { + // The TTS instance is not existing or not responding, the service has likely stopped, so + // dispose of our handle and create another one below. + LogUtils.i(TAG, "Bad TextToSpeech instance detected. Re-creating."); + } else { + // We use the fact that a getLanguage() call should never return null unless there is a + // failure talking to the service. This is tested on tv, but not on other platforms yet. + LogUtils.e(TAG, "Can't start TTS engine %s while still loading previous engine", engine); + return; + } + + LogUtils.logWithLimit( + TAG, Log.INFO, mTtsFailures, MAX_LOG_MESSAGES, "Switching to TTS engine: %s", engine); + + mTempTtsEngine = engine; + mTempTts = new TextToSpeech(mContext, mTtsChangeListener, engine); + } + + /** + * Assumes the current engine has failed and attempts to start the next available engine. + * + * @param failedEngine The package name of the engine to switch from. + */ + private void attemptTtsFailover(String failedEngine) { + LogUtils.logWithLimit( + TAG, + Log.ERROR, + mTtsFailures, + MAX_LOG_MESSAGES, + "Attempting TTS failover from %s", + failedEngine); + + mTtsFailures++; + + // If there is only one installed engine, or if the current engine + // hasn't failed enough times, just restart the current engine. + if ((mInstalledTtsEngines.size() <= 1) || (mTtsFailures < MAX_TTS_FAILURES)) { + setTtsEngine(failedEngine, false); + return; + } + + // Move the engine to the back of the list. + if (failedEngine != null) { + mInstalledTtsEngines.remove(failedEngine); + mInstalledTtsEngines.addLast(failedEngine); + } + + // Try to use the first available TTS engine. + final String nextEngine = mInstalledTtsEngines.getFirst(); + + setTtsEngine(nextEngine, true); + } + + /** + * Handles TTS engine initialization. + * + * @param status The status returned by the TTS engine. + */ + @SuppressWarnings("deprecation") + private void handleTtsInitialized(int status) { + if (mTempTts == null) { + LogUtils.e(TAG, "Attempted to initialize TTS more than once!"); + return; + } + + final TextToSpeech tempTts = mTempTts; + final String tempTtsEngine = mTempTtsEngine; + + mTempTts = null; + mTempTtsEngine = null; + + if (status != TextToSpeech.SUCCESS) { + attemptTtsFailover(tempTtsEngine); + return; + } + + final boolean isSwitchingEngines = (mTts != null); + + if (isSwitchingEngines) { + TextToSpeechUtils.attemptTtsShutdown(mTts); + } + + mTts = tempTts; + mTts.setOnUtteranceProgressListener(mUtteranceProgressListener); + + if (tempTtsEngine == null) { + mTtsEngine = TextToSpeechCompatUtils.getCurrentEngine(mTts); + } else { + mTtsEngine = tempTtsEngine; + } + + updateDefaultLocale(); + + mTts.setAudioAttributes( + new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) + .build()); + + LogUtils.i(TAG, "Switched to TTS engine: %s", tempTtsEngine); + + for (FailoverTtsListener mListener : mListeners) { + mListener.onTtsInitialized(isSwitchingEngines); + } + } + + /** + * Method that's called by TTS whenever an utterance starts. + * + * @param utteranceId The utteranceId from the onUtteranceStarted callback - we expect this to + * consist of UTTERANCE_ID_PREFIX followed by the utterance index. + */ + private void handleUtteranceStarted(String utteranceId) { + for (FailoverTtsListener mListener : mListeners) { + mListener.onUtteranceStarted(utteranceId); + } + } + + /** + * Method that's called by TTS to update the range of utterance being spoken. + * + * @param utteranceId The utteranceId from the onUtteranceStarted callback - we expect this to + * consist of UTTERANCE_ID_PREFIX followed by the utterance index. + * @param start The start index of the range in the utterance text. + * @param end The end index of the range in the utterance text. + */ + private void handleUtteranceRangeStarted(String utteranceId, int start, int end) { + for (FailoverTtsListener mListener : mListeners) { + mListener.onUtteranceRangeStarted(utteranceId, start, end); + } + } + + /** + * Method that's called by TTS whenever an utterance is completed. Do common tasks and execute any + * UtteranceCompleteActions associate with this utterance index (or an earlier index, in case one + * was accidentally dropped). + * + * @param utteranceId The utteranceId from the onUtteranceCompleted callback - we expect this to + * consist of UTTERANCE_ID_PREFIX followed by the utterance index. + * @param success {@code true} if the utterance was spoken successfully. + */ + private void handleUtteranceCompleted(String utteranceId, boolean success) { + if (success) { + mTtsFailures = 0; + } + allowDeviceSleep(utteranceId); + for (FailoverTtsListener mListener : mListeners) { + mListener.onUtteranceCompleted(utteranceId, success); + } + } + + /** + * Handles media state changes. + * + * @param action The current media state. + */ + private void handleMediaStateChanged(String action) { + if (Intent.ACTION_MEDIA_UNMOUNTED.equals(action)) { + if (!TextUtils.equals(mSystemTtsEngine, mTtsEngine)) { + // Temporarily switch to the system TTS engine. + LogUtils.v(TAG, "Saw media unmount"); + setTtsEngine(mSystemTtsEngine, true); + } + } + + if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) { + if (!TextUtils.equals(mDefaultTtsEngine, mTtsEngine)) { + // Try to switch back to the default engine. + LogUtils.v(TAG, "Saw media mount"); + setTtsEngine(mDefaultTtsEngine, true); + } + } + } + + public void updateDefaultEngine() { + final ContentResolver resolver = mContext.getContentResolver(); + + // Always refresh the list of available engines, since the user may have + // installed a new TTS and then switched to it. + mInstalledTtsEngines.clear(); + mSystemTtsEngine = + TextToSpeechUtils.reloadInstalledTtsEngines( + mContext.getPackageManager(), mInstalledTtsEngines); + + // This may be null if the user hasn't specified an engine. + mDefaultTtsEngine = Secure.getString(resolver, Secure.TTS_DEFAULT_SYNTH); + + // Switch engines when the system default changes and it's not the current engine. + if (mTtsEngine == null || !mTtsEngine.equals(mDefaultTtsEngine)) { + if (mInstalledTtsEngines.contains(mDefaultTtsEngine)) { + // Can load the default engine. + setTtsEngine(mDefaultTtsEngine, true); + } else if (!mInstalledTtsEngines.isEmpty()) { + // We'll take whatever TTS we can get. + setTtsEngine(mInstalledTtsEngines.get(0), true); + } + } + } + + /** + * Loads the default pitch adjustment from {@link Secure#TTS_DEFAULT_PITCH}. This will take effect + * during the next call to {@link #trySpeak}. + */ + private void updateDefaultPitch() { + mDefaultPitch = (Secure.getInt(mResolver, Secure.TTS_DEFAULT_PITCH, 100) / 100.0f); + } + + /** + * Loads the default rate adjustment from {@link Secure#TTS_DEFAULT_RATE}. This will take effect + * during the next call to {@link #trySpeak}. + */ + private void updateDefaultRate() { + mDefaultRate = (Secure.getInt(mResolver, Secure.TTS_DEFAULT_RATE, 100) / 100.0f); + } + + /** Preferred locale for fallback language. */ + private static final Locale PREFERRED_FALLBACK_LOCALE = Locale.US; + + /** The system's default locale. */ + private Locale mSystemLocale = Locale.getDefault(); + + /** + * The current engine's default locale. This will be {@code null} if the user never specified a + * preference. + */ + private @Nullable Locale mDefaultLocale = null; + + /** + * The locale specified by the last utterance with {@link #speak(CharSequence, Locale, float, + * float, HashMap, int, float, boolean)}. + */ + private @Nullable Locale mLastUtteranceLocale = null; + + /** + * Helper method that ensures the text-to-speech engine works even when the user is using the + * Google TTS and has the system set to a non-embedded language. + * + *

This method should be called whenever the TTS engine is loaded, the system locale changes, + * or the default TTS locale changes. + */ + private void ensureSupportedLocale() { + if (needsFallbackLocale()) { + attemptSetFallbackLanguage(); + } else { + // We might need to restore the system locale. Or, if we've ever + // explicitly set the locale, we'll need to work around a bug where + // there's no way to tell the TTS engine to use whatever it thinks + // the default language should be. + attemptRestorePreferredLocale(); + } + } + + /** Returns whether we need to attempt to use a fallback language. */ + private boolean needsFallbackLocale() { + // If the user isn't using Google TTS, or if they set a preferred + // locale, we do not need to check locale support. + if (!PACKAGE_GOOGLE_TTS.equals(mTtsEngine) || (mDefaultLocale != null)) { + return false; + } + + if (mTts == null) { + return false; + } + + // Otherwise, the TTS engine will attempt to use the system locale which + // may not be supported. If the locale is embedded or advertised as + // available, we're fine. + final Set features = mTts.getFeatures(mSystemLocale); + return !(((features != null) && features.contains(Engine.KEY_FEATURE_EMBEDDED_SYNTHESIS)) + || !isNotAvailableStatus(mTts.isLanguageAvailable(mSystemLocale))); + } + + /** Attempts to obtain and set a fallback TTS locale. */ + private void attemptSetFallbackLanguage() { + final Locale fallbackLocale = getBestAvailableLocale(); + if (fallbackLocale == null) { + LogUtils.e(TAG, "Failed to find fallback locale"); + return; + } + LogUtils.v(TAG, "Attempt setting fallback TTS locale."); + attemptSetLanguage(fallbackLocale); + } + + /** + * Attempts to set a TTS locale. + * + * @param locale TTS locale to set. + * @return {@code true} if successfully set the TTS locale. + */ + private boolean attemptSetLanguage(Locale locale) { + if (locale == null) { + LogUtils.w(TAG, "Cannot set null locale."); + return false; + } + if (mTts == null) { + LogUtils.e(TAG, "mTts null when setting locale."); + return false; + } + + final int status = mTts.setLanguage(locale); + if (isNotAvailableStatus(status)) { + LogUtils.e(TAG, "Failed to set locale to %s", locale); + return false; + } + + LogUtils.v(TAG, "Set locale to %s", locale); + return true; + } + + /** + * Attempts to obtain a supported TTS locale with preference given to {@link + * #PREFERRED_FALLBACK_LOCALE}. The resulting locale may not be optimal for the user, but it will + * likely be enough to understand what's on the screen. + */ + private @Nullable Locale getBestAvailableLocale() { + if (mTts == null) { + return null; + } + + // Always attempt to use the preferred locale first. + if (mTts.isLanguageAvailable(PREFERRED_FALLBACK_LOCALE) >= 0) { + return PREFERRED_FALLBACK_LOCALE; + } + + // Since there's no way to query available languages from an engine, + // we'll need to check every locale supported by the device. + Locale bestLocale = null; + int bestScore = -1; + + final Locale[] locales = Locale.getAvailableLocales(); + for (Locale locale : locales) { + final int status = mTts.isLanguageAvailable(locale); + if (isNotAvailableStatus(status)) { + continue; + } + + final int score = compareLocales(mSystemLocale, locale); + if (score > bestScore) { + bestLocale = locale; + bestScore = score; + } + } + + return bestLocale; + } + + /** + * Attempts to restore the user's preferred TTS locale, if set. Otherwise attempts to restore the + * system locale. + */ + private void attemptRestorePreferredLocale() { + if (mTts == null) { + return; + } + mLastUtteranceLocale = null; + final Locale preferredLocale = (mDefaultLocale != null ? mDefaultLocale : mSystemLocale); + try { + final int status = mTts.setLanguage(preferredLocale); + if (!isNotAvailableStatus(status)) { + LogUtils.i(TAG, "Restored TTS locale to %s", preferredLocale); + return; + } + } catch (Exception e) { + LogUtils.e(TAG, "Failed to setLanguage(): %s", e.toString()); + } + + LogUtils.e(TAG, "Failed to restore TTS locale to %s", preferredLocale); + } + + /** Handles updating the default locale. */ + private void updateDefaultLocale() { + final String defaultLocale = TextToSpeechUtils.getDefaultLocaleForEngine(mResolver, mTtsEngine); + mDefaultLocale = (!TextUtils.isEmpty(defaultLocale)) ? new Locale(defaultLocale) : null; + + // The default locale changed, which may mean we can restore the user's + // preferred locale. + ensureSupportedLocale(); + } + + /** Handles updating the system locale. */ + private void onConfigurationChanged(Configuration newConfig) { + final Locale newLocale = newConfig.locale; + if (newLocale.equals(mSystemLocale)) { + return; + } + + mSystemLocale = newLocale; + + // The system locale changed, which may mean we need to override the + // current TTS locale. + ensureSupportedLocale(); + } + + /** Registers the configuration change callback. */ + private void registerGoogleTtsFixCallbacks() { + final Uri defaultLocaleUri = Secure.getUriFor(SecureCompatUtils.TTS_DEFAULT_LOCALE); + mResolver.registerContentObserver(defaultLocaleUri, false, mLocaleObserver); + mContext.registerComponentCallbacks(mComponentCallbacks); + } + + /** Unregisters the configuration change callback. */ + private void unregisterGoogleTtsFixCallbacks() { + mResolver.unregisterContentObserver(mLocaleObserver); + mContext.unregisterComponentCallbacks(mComponentCallbacks); + } + + /** + * Compares a locale against a primary locale. Returns higher values for closer matches. A return + * value of 3 indicates that the locale is an exact match for the primary locale's language, + * country, and variant. + * + * @param primary The primary locale for comparison. + * @param other The other locale to compare against the primary locale. + * @return A value indicating how well the other locale matches the primary locale. Higher is + * better. + */ + private static int compareLocales(Locale primary, Locale other) { + final String lang = primary.getLanguage(); + if ((lang == null) || !lang.equals(other.getLanguage())) { + return 0; + } + + final String country = primary.getCountry(); + if ((country == null) || !country.equals(other.getCountry())) { + return 1; + } + + final String variant = primary.getVariant(); + if ((variant == null) || !variant.equals(other.getVariant())) { + return 2; + } + + return 3; + } + + /** + * Returns {@code true} if the specified status indicates that the language is available. + * + * @param status A language availability code, as returned from {@link + * TextToSpeech#isLanguageAvailable}. + * @return {@code true} if the status indicates that the language is available. + */ + private static boolean isNotAvailableStatus(int status) { + return (status != TextToSpeech.LANG_AVAILABLE) + && (status != TextToSpeech.LANG_COUNTRY_AVAILABLE) + && (status != TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE); + } + + private final FailoverTextToSpeech.SpeechHandler mHandler = new SpeechHandler(this); + + /** Handles changes to the default TTS engine. */ + private final ContentObserver mSynthObserver = + new ContentObserver(mHandler) { + @Override + public void onChange(boolean selfChange) { + updateDefaultEngine(); + } + }; + + private final ContentObserver mPitchObserver = + new ContentObserver(mHandler) { + @Override + public void onChange(boolean selfChange) { + updateDefaultPitch(); + } + }; + + private final ContentObserver mRateObserver = + new ContentObserver(mHandler) { + @Override + public void onChange(boolean selfChange) { + updateDefaultRate(); + } + }; + + /** Callbacks used to observe changes to the TTS locale. */ + private final ContentObserver mLocaleObserver = + new ContentObserver(mHandler) { + @Override + public void onChange(boolean selfChange) { + updateDefaultLocale(); + } + }; + + /** + * A listener for {@code TextToSpeech} progress. + * + *

Note: By default, the callback is invoked in TTS thread and we hand over + * the message to main thread for processing. In some special cases when we want to handle the + * callback in TTS thread, call {@link #setHandleTtsCallbackInMainThread(boolean)}. + */ + private final UtteranceProgressListener mUtteranceProgressListener = + new UtteranceProgressListener() { + private @Nullable String mLastUpdatedUtteranceId = null; + + private void updatePerformanceMetrics(String utteranceId) { + // Update performance for this utterance, only if we did not recently update + // for the same utterance. + if (utteranceId != null && !utteranceId.equals(mLastUpdatedUtteranceId)) { + Performance.getInstance().onFeedbackOutput(utteranceId); + } + mLastUpdatedUtteranceId = utteranceId; + } + + private void handleUtteranceCompleted(String utteranceId, boolean success) { + LogUtils.d(TAG, "Received callback for \"%s\"", utteranceId); + if (mShouldHandleTtsCallbackInMainThread) { + // Hand utterance completed processing to the main thread. + mHandler.onUtteranceCompleted(utteranceId, success); + } else { + FailoverTextToSpeech.this.handleUtteranceCompleted(utteranceId, success); + } + } + + @Override + public void onStart(String utteranceId) { + if (mShouldHandleTtsCallbackInMainThread) { + mHandler.onUtteranceStarted(utteranceId); + } else { + FailoverTextToSpeech.this.handleUtteranceStarted(utteranceId); + } + } + + @TargetApi(Build.VERSION_CODES.N) // This callback will only be called on N+. + @Override + public void onAudioAvailable(String utteranceId, byte[] audio) { + // onAudioAvailable() is usually called many times per utterance, + // once for each audio chunk. + updatePerformanceMetrics(utteranceId); + } + + @TargetApi(Build.VERSION_CODES.O) + @Override + public void onRangeStart(String utteranceId, int start, int end, int frame) { + if (mShouldHandleTtsCallbackInMainThread) { + mHandler.onUtteranceRangeStarted(utteranceId, start, end); + } else { + FailoverTextToSpeech.this.handleUtteranceRangeStarted(utteranceId, start, end); + } + } + + @Override + public void onStop(String utteranceId, boolean interrupted) { + handleUtteranceCompleted(utteranceId, /* success= */ !interrupted); + } + + @Override + public void onError(String utteranceId) { + handleUtteranceCompleted(utteranceId, /* success= */ false); + } + + @Override + public void onDone(String utteranceId) { + handleUtteranceCompleted(utteranceId, /* success= */ true); + } + }; + + /** + * When changing TTS engines, switches the active TTS engine when the new engine is initialized. + */ + private final OnInitListener mTtsChangeListener = + new OnInitListener() { + @Override + public void onInit(int status) { + mHandler.onTtsInitialized(status); + } + }; + + /** Callbacks used to observe configuration changes. */ + private final ComponentCallbacks mComponentCallbacks = + new ComponentCallbacks() { + @Override + public void onLowMemory() { + // Do nothing. + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + FailoverTextToSpeech.this.onConfigurationChanged(newConfig); + } + }; + + /** {@link BroadcastReceiver} for detecting media mount and unmount. */ + private class MediaMountStateMonitor extends BroadcastReceiver { + private final IntentFilter mMediaIntentFilter; + + public MediaMountStateMonitor() { + mMediaIntentFilter = new IntentFilter(); + mMediaIntentFilter.addAction(Intent.ACTION_MEDIA_MOUNTED); + mMediaIntentFilter.addAction(Intent.ACTION_MEDIA_UNMOUNTED); + mMediaIntentFilter.addDataScheme("file"); + } + + public IntentFilter getFilter() { + return mMediaIntentFilter; + } + + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + + mHandler.onMediaStateChanged(action); + } + } + + /** Handler used to return to the main thread from the TTS thread. */ + private static class SpeechHandler extends WeakReferenceHandler { + /** Hand-off engine initialized. */ + private static final int MSG_INITIALIZED = 1; + + /** Hand-off utterance started. */ + private static final int MSG_UTTERANCE_STARTED = 2; + + /** Hand-off utterance completed. */ + private static final int MSG_UTTERANCE_COMPLETED = 3; + + /** Hand-off media state changes. */ + private static final int MSG_MEDIA_STATE_CHANGED = 4; + + /** Hand-off a range of utterance started. */ + private static final int MSG_UTTERANCE_RANGE_STARTED = 5; + + public SpeechHandler(FailoverTextToSpeech parent) { + super(parent); + } + + @SuppressWarnings("unchecked") + @Override + public void handleMessage(Message msg, FailoverTextToSpeech parent) { + switch (msg.what) { + case MSG_INITIALIZED: + parent.handleTtsInitialized(msg.arg1); + break; + case MSG_UTTERANCE_STARTED: + parent.handleUtteranceStarted((String) msg.obj); + break; + case MSG_UTTERANCE_COMPLETED: + Pair data = (Pair) msg.obj; + parent.handleUtteranceCompleted( + /* utteranceId= */ data.first, /* success= */ data.second); + break; + case MSG_MEDIA_STATE_CHANGED: + parent.handleMediaStateChanged((String) msg.obj); + break; + case MSG_UTTERANCE_RANGE_STARTED: + parent.handleUtteranceRangeStarted((String) msg.obj, msg.arg1, msg.arg2); + break; + default: // fall out + } + } + + public void onTtsInitialized(int status) { + obtainMessage(MSG_INITIALIZED, status, 0).sendToTarget(); + } + + public void onUtteranceStarted(String utteranceId) { + obtainMessage(MSG_UTTERANCE_STARTED, utteranceId).sendToTarget(); + } + + public void onUtteranceRangeStarted(String utteranceId, int start, int end) { + obtainMessage(MSG_UTTERANCE_RANGE_STARTED, start, end, utteranceId).sendToTarget(); + } + + public void onUtteranceCompleted(String utteranceId, boolean success) { + obtainMessage(MSG_UTTERANCE_COMPLETED, Pair.create(utteranceId, success)).sendToTarget(); + } + + public void onMediaStateChanged(String action) { + obtainMessage(MSG_MEDIA_STATE_CHANGED, action).sendToTarget(); + } + } + + /** Listener for TTS events. */ + public interface FailoverTtsListener { + /* + * Called after the class has initialized with a tts engine. + */ + void onTtsInitialized(boolean wasSwitchingEngines); + + /* + * Called before an utterance starts speaking. + */ + void onUtteranceStarted(String utteranceId); + + /* + * .Called before speaking the range of an utterance. + */ + void onUtteranceRangeStarted(String utteranceId, int start, int end); + + /* + * Called after an utterance has completed speaking. + */ + void onUtteranceCompleted(String utteranceId, boolean success); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackController.java b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackController.java new file mode 100644 index 0000000..6641315 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackController.java @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2014 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.google.android.accessibility.utils.output; + +import static com.google.android.accessibility.utils.Performance.EVENT_ID_UNTRACKED; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.Resources.NotFoundException; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.SoundPool; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.util.SparseIntArray; +import com.google.android.accessibility.utils.BuildVersionUtils; +import com.google.android.accessibility.utils.FeatureSupport; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.R; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.HashSet; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** A feedback controller that caches sounds for quicker playback. */ +public class FeedbackController { + + private static final String TAG = "FeedbackController"; + + /** Default stream for audio feedback. */ + public static final int DEFAULT_STREAM = + BuildVersionUtils.isAtLeastO() + ? AudioManager.STREAM_ACCESSIBILITY + : AudioManager.STREAM_MUSIC; + + /** Maximum number of concurrent audio streams. */ + private static final int MAX_STREAMS = 10; + + /** The parent context. */ + private final Context mContext; + + /** The resources for this context. */ + private final Resources mResources; + + /** The SoundPool instance for loading sounds and playing previously loaded sounds. */ + private final SoundPool mSoundPool; + + /** The vibration service used to play vibration patterns. */ + private final Vibrator mVibrator; + + /** Map from the resource IDs of loaded sounds to SoundPool sound IDs. */ + private final SparseIntArray mSoundIds = new SparseIntArray(); + + /** The volume adjustment for sound feedback. */ + private float mVolumeAdjustment = 1.0f; + + private boolean mAuditoryEnabled; + private boolean mHapticEnabled; + + private final Set mHapticFeedbackListeners = new HashSet<>(); + + public FeedbackController(Context context) { + this(context, createSoundPool(), (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE)); + } + + public FeedbackController(Context context, SoundPool soundPool, Vibrator vibrator) { + mContext = context; + mResources = context.getResources(); + mSoundPool = soundPool; + mVibrator = vibrator; + } + + /** + * Plays the vibration pattern associated with the given resource ID. + * + * @param resId The vibration pattern's resource identifier. + * @return {@code true} if successful. + */ + public boolean playHaptic(int resId, @Nullable EventId eventId) { + if (!mHapticEnabled || resId == 0) { + return false; + } + LogUtils.v(TAG, "playHaptic() resId=%d eventId=%s", resId, eventId); + + final int[] patternArray; + try { + patternArray = mResources.getIntArray(resId); + } catch (NotFoundException e) { + LogUtils.e(TAG, "Failed to load pattern %d", resId); + return false; + } + + final long[] pattern = new long[patternArray.length]; + for (int i = 0; i < patternArray.length; i++) { + pattern[i] = patternArray[i]; + } + + long nanoTime = System.nanoTime(); + for (HapticFeedbackListener listener : mHapticFeedbackListeners) { + listener.onHapticFeedbackStarting(nanoTime); + } + if (FeatureSupport.supportVibrationEffect()) { + mVibrator.vibrate(VibrationEffect.createWaveform(pattern, -1)); + } else { + mVibrator.vibrate(pattern, -1); + } + return true; + } + + /** + * Adds a listener to be called when haptic feedback begins. + * + * @param listener The listener to add. + */ + public void addHapticFeedbackListener(FeedbackController.HapticFeedbackListener listener) { + mHapticFeedbackListeners.add(listener); + } + + /** + * Removes a HapticFeedbackListener. + * + * @param listener The listener to remove. + */ + public void removeHapticFeedbackListener(FeedbackController.HapticFeedbackListener listener) { + mHapticFeedbackListeners.remove(listener); + } + + /** + * Plays the auditory feedback associated with the given resource ID using the default rate, + * volume, and panning. + * + * @param resId The auditory feedback's resource identifier. + */ + public void playAuditory(int resId, @Nullable EventId eventId) { + playAuditory(resId, 1.0f /* rate */, 1.0f /* volume */, eventId); + } + + /** + * Plays the auditory feedback associated with the given resource ID using the specified rate, + * volume, and panning. + * + * @param resId The auditory feedback's resource identifier. + * @param rate The playback rate adjustment, from 0.5 (half speed) to 2.0 (double speed). + * @param volume The volume adjustment, from 0.0 (mute) to 1.0 (original volume). + */ + public void playAuditory(int resId, final float rate, float volume, @Nullable EventId eventId) { + if (!mAuditoryEnabled || resId == 0) { + return; + } + LogUtils.v(TAG, "playAuditory() resId=%d eventId=%s", resId, eventId); + + final float adjustedVolume = volume * mVolumeAdjustment; + int soundId = mSoundIds.get(resId); + + if (soundId != 0) { + new EarconsPlayTask(mSoundPool, soundId, adjustedVolume, rate).execute(); + } else { + // The sound could not be played from the cache. Start loading the sound into the + // SoundPool for future use, and use a listener to play the sound ASAP. + mSoundPool.setOnLoadCompleteListener( + (soundPool, sampleId, status) -> { + if (mAuditoryEnabled && sampleId != 0) { + new EarconsPlayTask(mSoundPool, sampleId, adjustedVolume, rate).execute(); + } + }); + mSoundIds.put(resId, mSoundPool.load(mContext, resId, 1)); + } + } + + /** Interrupts all ongoing feedback. */ + public void interrupt() { + // TODO: Stop all sounds. + mVibrator.cancel(); + } + + /** + * Releases all resources held by the feedback controller and clears the shared instance. No calls + * should be made to this instance after calling this method. + */ + public void shutdown() { + mHapticFeedbackListeners.clear(); + mSoundPool.release(); + mVibrator.cancel(); + mAuditoryEnabled = false; + mHapticEnabled = false; + } + + /** + * Sets whether to enable or disable the haptic feedback. + * + * @param enabled Whether haptic feedback should be enabled. + */ + public void setHapticEnabled(boolean enabled) { + mHapticEnabled = enabled; + } + + /** + * Sets whether to enable or disable the auditory feedback. + * + * @param enabled Whether auditory feedback should be enabled. + */ + public void setAuditoryEnabled(boolean enabled) { + mAuditoryEnabled = enabled; + } + + /** + * Sets the current volume adjustment for auditory feedback. + * + * @param adjustment The amount by which to adjust the volume of auditory feedback. 0.0 mutes the + * feedback while 1.0 plays it at its original volume. + */ + public void setVolumeAdjustment(float adjustment) { + mVolumeAdjustment = adjustment; + } + + /** + * Provides vibration and sound feedback to acknowledge the completion of an action (e.g. item + * selection in Switch Access, gesture completion in TalkBack, etc.). + */ + public void playActionCompletionFeedback() { + playHaptic(R.array.window_state_pattern, EVENT_ID_UNTRACKED); + playAuditory(R.raw.window_state, EVENT_ID_UNTRACKED); + } + + private static SoundPool createSoundPool() { + AudioAttributes aa = + new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(); + return new SoundPool.Builder().setMaxStreams(MAX_STREAMS).setAudioAttributes(aa).build(); + } + + /** + * Some features, such as the tap detector, may be affected by haptic feedback and want to know + * when we initiate it. + */ + public interface HapticFeedbackListener { + + /** + * Alerts the listener that haptic feedback is about to start. + * + * @param currentNanoTime The current system time. + */ + void onHapticFeedbackStarting(long currentNanoTime); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragment.java b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragment.java new file mode 100644 index 0000000..8a9336f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragment.java @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2013 Google Inc. + * + * 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.google.android.accessibility.utils.output; + +import android.os.Bundle; +import android.text.SpannableString; +import android.text.TextUtils; +import com.google.android.accessibility.utils.output.FailoverTextToSpeech.SpeechParam; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Represents a fragment of feedback included within a {@link FeedbackItem}. It must contain speech + * with optional earcons and haptic feedback. + */ +public class FeedbackFragment { + + private static final String TAG = "FeedbackFragment"; + + /** Text to be spoken when processing this fragment */ + private CharSequence mText; + + /** Locale information of this fragment for TTS. */ + private Locale mLocale; + + /** + * Set of resource IDs indicating the auditory icons to be played when this fragment is processed + */ + private Set mEarcons; + + /** + * Set of resource IDs indicating the haptic patterns to be generated when this fragment is + * processed + */ + private Set mHaptics; + + /** + * {@link SpeechParam} fields used for altering various properties on the speech feedback for this + * fragment. + * + * @see SpeechParam#PITCH + * @see SpeechParam#RATE + * @see SpeechParam#VOLUME + */ + private Bundle mSpeechParams; + + /** + * {@link Utterance} metadata parameters used for altering various properties on the non-speech + * feedback for this fragment. + * + * @see Utterance#KEY_METADATA_EARCON_RATE + * @see Utterance#KEY_METADATA_EARCON_VOLUME + */ + private Bundle mNonSpeechParams; + /** + * The start index of {@link #mText}, which is a substring in {@link FeedbackItem}. + * + *

Assume the speech of {@link FeedbackItem} is "first Chinese.second English.". "second + * Chinese" is saved in {@link #mText}, and we also save the index of 's' in this field. + */ + private int startIndexInFeedbackItem = 0; + + /** + * The start index that {@link #mText} is going to be spoken from. + * + *

When {@link #mText} is spoken, we know where is going to be started by {@link + * FailoverTextToSpeech.FailoverTtsListener#onUtteranceRangeStarted(String, int, int)} we save the + * start index here. + */ + public int fragmentStartIndex = 0; + + public FeedbackFragment(CharSequence text, @Nullable Bundle speechParams) { + this(text, null, null, speechParams, null); + } + + public FeedbackFragment( + CharSequence text, + @Nullable Set earcons, + @Nullable Set haptics, + @Nullable Bundle speechParams, + @Nullable Bundle nonSpeechParams) { + mText = new SpannableString(text); + + mEarcons = new HashSet<>(); + if (earcons != null) { + mEarcons.addAll(earcons); + } + + mHaptics = new HashSet<>(); + if (haptics != null) { + mHaptics.addAll(haptics); + } + + mSpeechParams = new Bundle(Bundle.EMPTY); + if (speechParams != null) { + mSpeechParams.putAll(speechParams); + } + + mNonSpeechParams = new Bundle(Bundle.EMPTY); + if (nonSpeechParams != null) { + mNonSpeechParams.putAll(nonSpeechParams); + } + } + + /** Creates a new fragment by deep copying the data from the specified fragment. */ + public FeedbackFragment(FeedbackFragment fragment) { + this( + fragment.getText(), + fragment.getEarcons(), + fragment.getHaptics(), + fragment.getSpeechParams(), + fragment.getNonSpeechParams()); + } + + /** @return The text of this fragment */ + public CharSequence getText() { + return mText; + } + + /** @param text The text to set for this fragment */ + public void setText(CharSequence text) { + mText = text; + } + + /** @return The locale information of this fragment. */ + public Locale getLocale() { + return mLocale; + } + + /** Sets locale information of this fragment. */ + public void setLocale(Locale locale) { + mLocale = locale; + } + + /** @return An unmodifiable set of IDs of the earcons to play along with this fragment */ + public Set getEarcons() { + return Collections.unmodifiableSet(mEarcons); + } + + /** + * @param earconId The ID of the earcon to add to the set of earcons to play when this fragment is + * processed + */ + public void addEarcon(int earconId) { + mEarcons.add(earconId); + } + + /** Clears all earcons associated with this fragment */ + public void clearAllEarcons() { + mEarcons.clear(); + } + + /** + * @return an unmodifiable set of IDs of the haptic patterns to produce along with this fragment + */ + public Set getHaptics() { + return Collections.unmodifiableSet(mHaptics); + } + + /** + * @param hapticId The ID of the haptic pattern to add to the set of haptic patterns to play when + * this fragment is processed + */ + public void addHaptic(int hapticId) { + mHaptics.add(hapticId); + } + + /** Clears all haptic patterns associated with this fragment. */ + public void clearAllHaptics() { + mHaptics.clear(); + } + + /** @return the {@link SpeechParam} parameters to use when processing this fragment */ + public Bundle getSpeechParams() { + return mSpeechParams; + } + + /** @param speechParams the {@link SpeechParam} parameters to use when processing this fragment */ + public void setSpeechParams(Bundle speechParams) { + mSpeechParams = speechParams; + } + + /** @return the {@link Utterance} non-speech parameters to use when processing this fragment */ + public Bundle getNonSpeechParams() { + return mNonSpeechParams; + } + + /** + * @param nonSpeechParams the {@link SpeechParam} parameters to use when processing this fragment + */ + public void setNonSpeechParams(Bundle nonSpeechParams) { + mNonSpeechParams = nonSpeechParams; + } + + /** + * Records the beginning of {@link #mText} in the original text of {@link FeedbackItem}. + * + * @param startIndexInFeedbackItem the start index of {@link #mText} in {@link FeedbackItem}. + */ + void setStartIndexInFeedbackItem(int startIndexInFeedbackItem) { + if (startIndexInFeedbackItem >= 0) { + this.startIndexInFeedbackItem = startIndexInFeedbackItem; + } + } + + /** @return the start index (see {@link #startIndexInFeedbackItem} for more details) * */ + int getStartIndexInFeedbackItem() { + return startIndexInFeedbackItem; + } + + /** @param utteranceStartIndex the index of utterance that TextToSpeech is going to read from. */ + void recordFragmentStartIndex(int utteranceStartIndex) { + this.fragmentStartIndex = utteranceStartIndex; + } + + /** Update {@link #mText} and {@link #startIndexInFeedbackItem} by {@link #fragmentStartIndex}. */ + void updateContentByFragmentStartIndex() { + if (fragmentStartIndex < mText.length()) { + CharSequence remainingSequence = mText.subSequence(fragmentStartIndex, mText.length()); + mText = remainingSequence; + startIndexInFeedbackItem += fragmentStartIndex; + fragmentStartIndex = 0; + } else { + LogUtils.w( + TAG, "updateContentByFragmentStartIndex, fragmentStartIndex is out of mText bound", ""); + } + LogUtils.v(TAG, "updateContentByFragmentStartIndex , remaining utterance = %s", mText); + } + + @Override + public String toString() { + return "{text:" + + mText + + ", earcons:" + + mEarcons + + ", haptics:" + + mHaptics + + ", speechParams:" + + mSpeechParams + + "nonSpeechParams:" + + mNonSpeechParams + + "fragmentStartIndex:" + + fragmentStartIndex + + "}"; + } + + @Override + public int hashCode() { + int hashCode = 17; + hashCode = 31 * hashCode + (mText == null ? 0 : mText.hashCode()); + hashCode = 31 * hashCode + (mEarcons == null ? 0 : mEarcons.hashCode()); + hashCode = 31 * hashCode + (mHaptics == null ? 0 : mHaptics.hashCode()); + hashCode = 31 * hashCode + getBundleHashCode(mSpeechParams); + hashCode = 31 * hashCode + getBundleHashCode(mNonSpeechParams); + return hashCode; + } + + private int getBundleHashCode(Bundle bundle) { + if (bundle == null) { + return 0; + } + + int hashCode = 0; + for (String key : bundle.keySet()) { + Object value = bundle.get(key); + hashCode += value == null ? 0 : value.hashCode(); + } + + return hashCode; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof FeedbackFragment)) { + return false; + } + + FeedbackFragment fragment = (FeedbackFragment) obj; + + if (!TextUtils.equals(mText, fragment.mText)) { + return false; + } + + //noinspection SimplifiableIfStatement + if (objectsNotEqual(mEarcons, fragment.mEarcons) + || objectsNotEqual(mHaptics, fragment.mHaptics)) { + return false; + } + + return !(bundleNotEqual(mSpeechParams, fragment.mSpeechParams) + || bundleNotEqual(mNonSpeechParams, fragment.mNonSpeechParams)); + } + + private boolean objectsNotEqual(Object obj1, Object obj2) { + return (obj1 != null || obj2 != null) && (obj1 == null || obj2 == null || !obj1.equals(obj2)); + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private boolean bundleNotEqual(Bundle bundle1, Bundle bundle2) { + if (bundle1 == null && bundle2 == null) { + return false; + } + + if (bundle1 != null && bundle2 != null) { + int size = bundle1.size(); + if (bundle2.size() != size) { + return true; + } + + for (String key : bundle1.keySet()) { + if (objectsNotEqual(bundle1.get(key), bundle2.get(key))) { + return true; + } + } + + return false; + } + + return true; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragmentsIterator.java b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragmentsIterator.java new file mode 100644 index 0000000..f217a87 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragmentsIterator.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2018 Google Inc. + * + * 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.google.android.accessibility.utils.output; + +import android.text.TextUtils; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.common.collect.Iterators; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** Provides FeedbackFragment iterator usage and record current {@link FeedbackFragment}. */ +class FeedbackFragmentsIterator { + private static final String TAG = "FeedbackFragmentsIterator"; + + private Iterator currentFragmentIterator; + + private String feedBackItemUtteranceId; + /** It's available when speaking its content and null between speaking each fragment. */ + @Nullable private FeedbackFragment currentFeedbackFragment; + + public FeedbackFragmentsIterator(@NonNull Iterator currentFragmentIterator) { + this.currentFragmentIterator = currentFragmentIterator; + } + + /** @return {@code true} if has next feedbackFragment. */ + boolean hasNext() { + return currentFeedbackFragment != null || currentFragmentIterator.hasNext(); + } + + /** + * @return next feedbackFragment from iterator. + * @throws NoSuchElementException if the iteration has no more elements + */ + FeedbackFragment next() { + if (currentFeedbackFragment == null) { + currentFeedbackFragment = currentFragmentIterator.next(); + } else { + // Has pending Fragment. + currentFeedbackFragment.updateContentByFragmentStartIndex(); + } + LogUtils.v(TAG, "next --currentFeedbackFragment text = %s.", currentFeedbackFragment.getText()); + return currentFeedbackFragment; + } + + private void recordUtteranceStartIndex(int utteranceStartIndex) { + if (currentFeedbackFragment != null) { + currentFeedbackFragment.recordFragmentStartIndex(utteranceStartIndex); + } + } + + /** @return the offset of current feedbackFragment text in {@link FeedbackItem}. */ + int getFeedbackItemOffset() { + if (currentFeedbackFragment != null) { + return currentFeedbackFragment.getStartIndexInFeedbackItem(); + } + return 0; + } + + /** + * Records the length of spoken sequence. Call it in {@link + * SpeechControllerImpl#onFragmentCompleted(String, boolean, boolean, boolean)}. + */ + void onFragmentCompleted(String utteranceId, boolean success) { + if (!TextUtils.equals(utteranceId, feedBackItemUtteranceId)) { + // Assume it's failed. + LogUtils.w( + TAG, + "onFragmentCompleted -- utteranceId = %s,feedBackItemUtteranceId = %s", + utteranceId, + feedBackItemUtteranceId); + return; + } + if (success) { + currentFeedbackFragment = null; + } + } + + void setFeedBackItemUtteranceId(String feedBackItemUtteranceId) { + this.feedBackItemUtteranceId = feedBackItemUtteranceId; + } + + /** + * Records the index of the sequence to start. Call it in {@link + * SpeechControllerImpl#onFragmentRangeStarted(String, int, int)}. + */ + void onFragmentRangeStarted(String utteranceId, int start, int end) { + if (TextUtils.equals(utteranceId, feedBackItemUtteranceId)) { + recordUtteranceStartIndex(start); + LogUtils.v( + TAG, + "onFragmentRangeStarted , speak word = %s", + AccessibilityNodeInfoUtils.subsequenceSafe( + currentFeedbackFragment.getText(), start, end)); + } else { + LogUtils.d( + TAG, + "onFragmentRangeStarted ,difference utteranceId, expected:%s ,actual:%s", + feedBackItemUtteranceId, + utteranceId); + } + } + + /** + * DeepCopy(Clone) FeedbackFragmentIterator + * + * @return object copied from FeedbackFragmentIterator. + */ + @SuppressWarnings({"unchecked"}) + public FeedbackFragmentsIterator deepCopy() { + ArrayList list = new ArrayList<>(); + + Iterators.addAll(list, currentFragmentIterator); + + FeedbackFragmentsIterator clone = new FeedbackFragmentsIterator(list.iterator()); + clone.currentFeedbackFragment = currentFeedbackFragment; + clone.setFeedBackItemUtteranceId(feedBackItemUtteranceId); + + currentFragmentIterator = ((ArrayList) list.clone()).iterator(); + + return clone; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackItem.java b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackItem.java new file mode 100644 index 0000000..928efa7 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackItem.java @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2013 Google Inc. + * + * 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.google.android.accessibility.utils.output; + +import android.text.SpannableStringBuilder; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.StringBuilderUtils; +import com.google.android.accessibility.utils.output.SpeechController.UtteranceCompleteRunnable; +import com.google.android.accessibility.utils.output.SpeechController.UtteranceRangeStartCallback; +import com.google.android.accessibility.utils.output.SpeechController.UtteranceStartRunnable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Represents the feedback produced by a single {@link Utterance} */ +public class FeedbackItem { + + /** Flag used to prevent this FeedbackItem from being included in utterance history. */ + public static final int FLAG_NO_HISTORY = 0x2; + + /** + * Flag to force feedback from this item even if audio playback is active. + * REFERTO + */ + public static final int FLAG_FORCE_FEEDBACK_EVEN_IF_AUDIO_PLAYBACK_ACTIVE = 0x4; + + /** Flag to force feedback from this item even if the microphone is active. */ + public static final int FLAG_FORCE_FEEDBACK_EVEN_IF_MICROPHONE_ACTIVE = 0x8; + + /** Flag to force feedback from this item even if speech recognition/dictation is active. */ + public static final int FLAG_FORCE_FEEDBACK_EVEN_IF_SSB_ACTIVE = 0x10; + + /** Flag to force feedback from this item even if a phone call is active. */ + public static final int FLAG_FORCE_FEEDBACK_EVEN_IF_PHONE_CALL_ACTIVE = 0x20; + + // TODO: make a flag that combines all the forced feedback flags + + /** + * Flag to inform the processor that completion of this item should advance continuous reading, if + * active. + */ + public static final int FLAG_ADVANCE_CONTINUOUS_READING = 0x40; + + /** + * Flag to inform the processor that this feedback item should have its speech ignored and have no + * impact on speech queues. + */ + public static final int FLAG_NO_SPEECH = 0x80; + + /** + * Flag to inform the processor that this feedback item should be skipped if duplicate utterance + * is on queue or currently pronouncing + */ + public static final int FLAG_SKIP_DUPLICATE = 0x100; + + /** + * Flag to inform that all utterances with the same utterance group should be cleared from + * utterance queue + */ + public static final int FLAG_CLEAR_QUEUED_UTTERANCES_WITH_SAME_UTTERANCE_GROUP = 0x200; + + /** Flag to inform that utterance with the same utterance group should be interrupted */ + public static final int FLAG_INTERRUPT_CURRENT_UTTERANCE_WITH_SAME_UTTERANCE_GROUP = 0x400; + + /** Flag to inform that device should not sleep during spoken feedback */ + public static final int FLAG_NO_DEVICE_SLEEP = 0x800; + + /** + * Flag to force feedback from this item to be generated, even while speech recognition is active + * or other voice assist app is playing voice feedback. + */ + public static final int FLAG_FORCE_FEEDBACK = 0x1000; + + /** + * Flag to indicate that the source of the feedback is the system volume slider panel; the two + * subcases are: 1) window change event caused by the volume slider panel appearing, 2) the user + * having adjusted one of the volume sliders in that panel. + */ + public static final int FLAG_SOURCE_IS_VOLUME_CONTROL = 0x2000; + + /** Unique ID defining this generated feedback */ + private String mUtteranceId = ""; + + /** Ordered fragments of the feedback to be produced from a single {@link Utterance}. */ + private List mFragments = new ArrayList<>(); + + /** Flag indicating that this FeedbackItem should be uninterruptible. */ + private boolean mIsUninterruptible; + + /** + * Flag indicating that this FeedbackItem will ignore interrupts when {@link + * SpeechController#interrupt} is called with the parameter interruptItemsThatCanIgnoreInterrupts + * set to false. Note, the interrupt will never be ignored if the parameter + * interruptItemsThatCanIgnoreInterrupts is true or if the parameter is not provided. + */ + private boolean mCanIgnoreInterrupts; + + /** Flags defining the treatment of this FeedbackItem. */ + private int mFlags; + + /** The time (in system uptime ms) that the FeedbackItem was created. */ + private final long mCreationTime; + + private int mUtteranceGroup = SpeechController.UTTERANCE_GROUP_DEFAULT; + + private final @Nullable EventId mEventId; + + /** + * Returns the {@link UtteranceStartRunnable} to be fired before feedback from this item starts. + */ + private @Nullable UtteranceStartRunnable mStartAction; + + /** + * Returns the {@link UtteranceRangeStartCallback} to be invoked to update the range of utterance + * being spoken. + */ + private @Nullable UtteranceRangeStartCallback mRangeStartCallback; + + /** + * Returns the {@link UtteranceCompleteRunnable} to be fired when feedback from this item is + * complete. + */ + private @Nullable UtteranceCompleteRunnable mCompletedAction; + + public FeedbackItem(@Nullable EventId eventId) { + mCreationTime = System.currentTimeMillis(); + mEventId = eventId; + } + + /** Creates a new FeedbackItem by deep copying the data from the specified FeedbackItem. */ + public FeedbackItem(FeedbackItem item) { + mCreationTime = System.currentTimeMillis(); + mEventId = item.getEventId(); + mFlags = item.getFlags(); + mUtteranceGroup = item.getUtteranceGroup(); + mStartAction = item.getStartAction(); + mCompletedAction = item.getCompletedAction(); + mRangeStartCallback = item.getRangeStartCallback(); + for (FeedbackFragment fragment : item.getFragments()) { + mFragments.add(new FeedbackFragment(fragment)); + } + } + + public @Nullable EventId getEventId() { + return mEventId; + } + + + + /** @return The utterance ID for this item */ + public String getUtteranceId() { + return mUtteranceId; + } + + /** + * Sets the utterance ID for this item. + * + * @param id The ID to set + */ + public void setUtteranceId(String id) { + mUtteranceId = id; + } + + /** + * Retrieves the fragments for this item. + * + * @return an unmodifiable ordered {@link List} of fragments for this item + */ + public List getFragments() { + return Collections.unmodifiableList(mFragments); + } + + /** + * Retrieves the aggregate text from all {@link FeedbackFragment}s. + * + * @return all text contained by this item, or {@code null} if no fragments exist. + */ + public @Nullable CharSequence getAggregateText() { + if (mFragments.size() == 0) { + return null; + } else if (mFragments.size() == 1) { + return mFragments.get(0).getText(); + } + + final SpannableStringBuilder sb = new SpannableStringBuilder(); + for (FeedbackFragment fragment : mFragments) { + StringBuilderUtils.appendWithSeparator(sb, fragment.getText()); + } + + return sb.toString(); + } + + /** + * Adds a fragment to the end of the list of fragments for this item. + * + * @param fragment The fragment to add + */ + public void addFragment(FeedbackFragment fragment) { + mFragments.add(fragment); + } + + public void addFragmentAtPosition(FeedbackFragment fragment, int position) { + mFragments.add(position, fragment); + } + + /** + * Removes the indicated fragment. + * + * @param fragment The fragment to remove + * @return {@code true} if removed. + */ + public boolean removeFragment(FeedbackFragment fragment) { + return mFragments.remove(fragment); + } + + /** Removes all {@link FeedbackFragment}s associated with this item. */ + public void clearFragments() { + mFragments.clear(); + } + + /** @return {@code true} if this item should be uninterruptible, {@code false} otherwise */ + public boolean isInterruptible() { + return !mIsUninterruptible; + } + + /** + * Returns whether this item will ignore interrupts when {@link SpeechController#interrupt} is + * called with the parameter interruptItemsThatCanIgnoreInterrupts set to false. + * + *

Note: Items that can ignore interrupts will never ignore them if {@link + * SpeechController#interrupt} is called with the parameter interruptItemsThatCanIgnoreInterrupts + * set to true or if the parameter is not provided. + * + * @return {@code true} if this item will ignore interrupts when {@link + * SpeechController#interrupt} is called with interruptItemsThatCanIgnoreInterrupts set to + * false, {@code false} if the interruptItemsThatCanIgnoreInterrupts parameter does not affect + * whether this item will ignore interrupts + */ + public boolean canIgnoreInterrupts() { + return mCanIgnoreInterrupts; + } + + /** + * Sets whether this item should be uninterruptible. + * + * @param isUninterruptible {@code true} if this item should be uninterruptible, {@code false} + * otherwise + */ + public void setUninterruptible(boolean isUninterruptible) { + mIsUninterruptible = isUninterruptible; + } + + /** + * Sets whether this item should ignore interrupts when when {@link SpeechController#interrupt} is + * called with the parameter interruptItemsThatCanIgnoreInterrupts set to false. Even if this item + * can ignore interrupts, it will never ignore interrupts based on this flag if {@link + * SpeechController#interrupt} is called with the parameter interruptItemsThatCanIgnoreInterrupts + * set to true or if the parameter is not provided. + * + * @param canIgnoreInterrupts {@code true} if this item should ignore interrupts when {@link + * SpeechController#interrupt} is called with interruptItemsThatCanIgnoreInterrupts set to + * false, {@code false} if the parameter interruptItemsThatCanIgnoreInterrupts should not + * affect whether this item will ignore interrupts + */ + public void setCanIgnoreInterrupts(boolean canIgnoreInterrupts) { + mCanIgnoreInterrupts = canIgnoreInterrupts; + } + + /** + * Determines if the FeedbackItem has the given flag. + * + * @param flag The flag to check + * @return {@code true} if the FeedbackItem has the given flag, {@code false} otherwise + */ + public boolean hasFlag(int flag) { + return ((mFlags & flag) == flag); + } + + /** + * Adds the given flag. + * + * @param flag The flag to add + */ + public void addFlag(int flag) { + mFlags |= flag; + } + + /** @return the {@link UtteranceStartRunnable} associated with this item */ + public @Nullable UtteranceStartRunnable getStartAction() { + return mStartAction; + } + + /** @return the {@link UtteranceRangeStartCallback} associated with this item */ + public @Nullable UtteranceRangeStartCallback getRangeStartCallback() { + return mRangeStartCallback; + } + + /** @return the {@link UtteranceCompleteRunnable} associated with this item */ + public @Nullable UtteranceCompleteRunnable getCompletedAction() { + return mCompletedAction; + } + + /** + * Replaces the existing start action of this item. + * + * @param action The action to set + */ + public void setStartAction(@Nullable UtteranceStartRunnable action) { + mStartAction = action; + } + + /** + * Replaces the existing {@link UtteranceRangeStartCallback} of this item. + * + * @param callback The callback to set + */ + public void setRangeStartCallback(@Nullable UtteranceRangeStartCallback callback) { + mRangeStartCallback = callback; + } + + /** + * Replaces the existing completion action of this item. + * + * @param action The action to set + */ + public void setCompletedAction(@Nullable UtteranceCompleteRunnable action) { + mCompletedAction = action; + } + + public void setUtteranceGroup(int utteranceGroup) { + mUtteranceGroup = utteranceGroup; + } + + public int getUtteranceGroup() { + return mUtteranceGroup; + } + + public long getCreationTime() { + return mCreationTime; + } + + @Override + public String toString() { + return "{utteranceId:\"" + + mUtteranceId + + "\", fragments:" + + mFragments + + ", uninterruptible:" + + mIsUninterruptible + + ", flags:" + + mFlags + + ", creationTime:" + + mCreationTime + + "}"; + } + + public int getFlags() { + return this.mFlags; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackProcessingUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackProcessingUtils.java new file mode 100644 index 0000000..a89fe85 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackProcessingUtils.java @@ -0,0 +1,388 @@ +/* + * Copyright (C) 2013 Google Inc. + * + * 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.google.android.accessibility.utils.output; + +import static com.google.android.accessibility.utils.AccessibilityNodeInfoUtils.TARGET_SPAN_CLASS; + +import android.content.Context; +import android.os.Bundle; +import android.speech.tts.TextToSpeech; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.CharacterStyle; +import android.text.style.ClickableSpan; +import android.text.style.LocaleSpan; +import android.text.style.URLSpan; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.SpannableUtils; +import com.google.android.accessibility.utils.output.FailoverTextToSpeech.SpeechParam; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Utilities for generating {@link FeedbackItem}s populated with {@link FeedbackFragment}s created + * according to processing rules. + */ +public class FeedbackProcessingUtils { + private static final String TAG = "FeedbackProcessingUtils"; + + /** + * Utterances must be no longer than MAX_UTTERANCE_LENGTH for the TTS to be able to handle them + * properly. Similar limitation imposed by {@link TextToSpeech#getMaxSpeechInputLength()} + */ + public static final int MAX_UTTERANCE_LENGTH = TextToSpeech.getMaxSpeechInputLength(); + + /** The pitch scale factor value to use when announcing hyperlinks. */ + private static final float PITCH_CHANGE_HYPERLINK = 0.95f; + + private static final boolean DO_FEEDBACK_ITEM_CHUNKING = true; + + // Which symbols are sentence delimiter? Only new-line symbol is considered as a delimiter now. + private static final Pattern CHUNK_DELIMITER = Pattern.compile("\n"); + // The feedback item chunking is taking place only when the fragment size is greater + // than this value. + private static final int MIN_CHUNK_LENGTH = 10; + + /** + * Produces a populated {@link FeedbackItem} based on rules defined within this class. Currently + * splits utterances into reasonable chunks and adds auditory and speech characteristics for + * formatting changes in processed text. + * + * @param text The text to include + * @param earcons The earcons to be played when this item is processed + * @param haptics The haptic patterns to be produced when this item is processed + * @param flags The Flags defining the treatment of this item + * @param speechParams The {@link SpeechParam} parameters to attribute to the spoken feedback + * within each fragment in this item. + * @param nonSpeechParams The {@link Utterance} parameters to attribute to non-speech feedback for + * this item. + * @return a populated {@link FeedbackItem} + */ + public static FeedbackItem generateFeedbackItemFromInput( + Context context, + CharSequence text, + @Nullable Set earcons, + @Nullable Set haptics, + int flags, + int utteranceGroup, + @Nullable Bundle speechParams, + @Nullable Bundle nonSpeechParams, + @Nullable EventId eventId) { + final FeedbackItem feedbackItem = new FeedbackItem(eventId); + final FeedbackFragment initialFragment = + new FeedbackFragment(text, earcons, haptics, speechParams, nonSpeechParams); + feedbackItem.addFragment(initialFragment); + feedbackItem.addFlag(flags); + feedbackItem.setUtteranceGroup(utteranceGroup); + + // Process the FeedbackItem + if (DO_FEEDBACK_ITEM_CHUNKING) { + breakSentence(feedbackItem); + } + addFormattingCharacteristics(feedbackItem); + splitLongText(feedbackItem); + + return feedbackItem; + } + + /** + * Splits text contained within the {@link FeedbackItem}'s {@link FeedbackFragment}s into + * fragments containing less than {@link #MAX_UTTERANCE_LENGTH} characters. + * + * @param item The item containing fragments to split. + */ + // Visible for testing + public static void splitLongText(FeedbackItem item) { + for (int i = 0; i < item.getFragments().size(); ++i) { + final FeedbackFragment fragment = item.getFragments().get(i); + final CharSequence fragmentText = fragment.getText(); + if (TextUtils.isEmpty(fragmentText)) { + continue; + } + + if (fragmentText.length() >= MAX_UTTERANCE_LENGTH) { + // If the text from an original fragment exceeds the allowable + // fragment text length, start by removing the original fragment + // from the item. + item.removeFragment(fragment); + + // Split the fragment's text into multiple fragments that don't + // exceed the limit and add new fragments at the appropriate + // position in the item. + final int end = fragmentText.length(); + int start = 0; + int splitFragments = 0; + while (start < end) { + final int fragmentEnd = start + MAX_UTTERANCE_LENGTH - 1; + + // TODO: We currently split only on spaces. + // Find a better way to do this for languages that don't + // use spaces. + int splitLocation = TextUtils.lastIndexOf(fragmentText, ' ', start + 1, fragmentEnd); + if (splitLocation < 0) { + splitLocation = Math.min(fragmentEnd, end); + } + final CharSequence textSection = TextUtils.substring(fragmentText, start, splitLocation); + final FeedbackFragment additionalFragment = + new FeedbackFragment(textSection, fragment.getSpeechParams()); + item.addFragmentAtPosition(additionalFragment, i + splitFragments); + splitFragments++; + start = splitLocation; + } + + // Always replace the metadata from the original fragment on the + // first fragment resulting from the split + copyFragmentMetadata(fragment, item.getFragments().get(i)); + } + } + } + + /** Collect the spans inside the SpannableString with span index and flag. */ + private static class SpanAndRange { + final int spanStart; + final int spanEnd; + final int spanFlag; + final Object span; + + SpanAndRange(Object span, int spanStart, int spanEnd, int spanFlag) { + this.span = span; + this.spanStart = spanStart; + this.spanEnd = spanEnd; + this.spanFlag = spanFlag; + } + } + + private static void splitSpans( + SpannableString spannableString, + List spanAndRanges, + int textStart, + int textEnd) { + for (SpanAndRange spanAndRange : spanAndRanges) { + int spanStart = spanAndRange.spanStart; + int spanEnd = spanAndRange.spanEnd; + if (spanEnd <= textStart || spanStart >= textEnd) { + continue; + } + int newStart = Math.max(spanStart, textStart) - textStart; + int newEnd = Math.min(spanEnd, textEnd) - textStart; + spannableString.setSpan(spanAndRange.span, newStart, newEnd, spanAndRange.spanFlag); + } + } + + /** + * Splits text delimited by the pattern of punctuation into sentence. For now, if any spans are + * found in the original text, do not split it. + * + * @param item The item containing fragments to split. + */ + public static void breakSentence(FeedbackItem item) { + List fragments = item.getFragments(); + if (fragments.size() != 1) { + LogUtils.e(TAG, "It only supports to handle the feedback item with single fragment."); + return; + } + + FeedbackFragment fragment = item.getFragments().get(0); + final CharSequence fragmentText = fragment.getText(); + if (TextUtils.isEmpty(fragmentText) || fragmentText.length() < MIN_CHUNK_LENGTH) { + return; + } + Object[] spans = ((Spanned) fragmentText).getSpans(0, fragmentText.length(), Object.class); + + List spanAndRanges = new ArrayList<>(); + for (Object span : spans) { + SpanAndRange spanAndRange = + new SpanAndRange( + span, + ((Spanned) fragmentText).getSpanStart(span), + ((Spanned) fragmentText).getSpanEnd(span), + ((Spanned) fragmentText).getSpanFlags(span)); + spanAndRanges.add(spanAndRange); + } + + Matcher matcher = CHUNK_DELIMITER.matcher(fragmentText); + int startOfUnsplitText = 0; + int chunkIndex = 1; + while (matcher.find()) { + int end = matcher.end(); + if (!splitFeasible(spanAndRanges, end)) { + continue; + } + splitChunk(item, fragment, spanAndRanges, startOfUnsplitText, end, chunkIndex); + startOfUnsplitText = end; + chunkIndex++; + } + if (chunkIndex > 1) { + if (startOfUnsplitText < fragmentText.length()) { + // The remaining text after the last sentence break. + splitChunk( + item, fragment, spanAndRanges, startOfUnsplitText, fragmentText.length(), chunkIndex); + } + item.removeFragment(fragment); + } + } + + private static void splitChunk( + FeedbackItem item, + FeedbackFragment fragment, + List spanAndRanges, + int startOfUnsplitText, + int chunkEnd, + int chunkIndex) { + final CharSequence fragmentText = fragment.getText(); + SpannableString spannableString = + new SpannableString(fragmentText.subSequence(startOfUnsplitText, chunkEnd)); + splitSpans(spannableString, spanAndRanges, startOfUnsplitText, chunkEnd); + final FeedbackFragment additionalFragment = + new FeedbackFragment(spannableString, fragment.getSpeechParams()); + item.addFragmentAtPosition(additionalFragment, chunkIndex); + } + + /** + * @param spanAndRanges: the collected spans in the original text. + * @param textEnd: end index of the sentence. + * @return true when it's recommended to break the sentence. + */ + private static boolean splitFeasible(List spanAndRanges, int textEnd) { + for (SpanAndRange spanAndRange : spanAndRanges) { + if (spanAndRange.spanStart < textEnd && spanAndRange.spanEnd > textEnd) { + return false; + } + } + return true; + } + + /** + * Splits and adds feedback to {@link FeedbackItem}s for spannable text contained within this + * {@link FeedbackItem} + * + * @param item The item to process for formatted text. + */ + public static void addFormattingCharacteristics(FeedbackItem item) { + for (int i = 0; i < item.getFragments().size(); ++i) { + final FeedbackFragment fragment = item.getFragments().get(i); + final CharSequence fragmentText = fragment.getText(); + if (TextUtils.isEmpty(fragmentText) || !(fragmentText instanceof Spannable)) { + continue; + } + + Spannable spannable = (Spannable) fragmentText; + + int len = spannable.length(); + int next; + boolean isFirstFragment = true; + for (int begin = 0; begin < len; begin = next) { + next = nextSpanTransition(spannable, begin, len, LocaleSpan.class, TARGET_SPAN_CLASS); + + // CharacterStyle is a superclass of both ClickableSpan(including URLSpan) and LocaleSpan; + // we want to split by only ClickableSpan and LocaleSpan, but it is OK if we request any + // CharacterStyle in the list of spans since we ignore the ones that are not + // ClickableSpan/LocaleSpan. + CharacterStyle[] spans = spannable.getSpans(begin, next, CharacterStyle.class); + CharacterStyle chosenSpan = null; + for (CharacterStyle span : spans) { + if (span instanceof LocaleSpan) { + // Prioritize LocaleSpan, quit the loop when a LocaleSpan is detected. Note: If multiple + // LocaleSpans are attached to the text, first LocaleSpan is given preference. + chosenSpan = span; + break; + } else if ((span instanceof ClickableSpan) || (span instanceof URLSpan)) { + chosenSpan = span; + } + // Ignore other CharacterStyle. + } + final FeedbackFragment newFragment; + CharSequence subString = spannable.subSequence(begin, next); + boolean isIdentifier = + SpannableUtils.isWrappedWithTargetSpan( + subString, SpannableUtils.IdentifierSpan.class, /* shouldTrim= */ true); + if (isIdentifier) { + continue; + } + if (isFirstFragment) { + // This is the first new fragment, so we should reuse the old fragment. + // That way, we'll keep the existing haptic/earcon feedback at the beginning! + isFirstFragment = false; + newFragment = fragment; + newFragment.setText(subString); + } else { + // Otherwise, add after the last fragment processed/added. + newFragment = new FeedbackFragment(subString, /* speechParams= */ null); + ++i; + newFragment.setStartIndexInFeedbackItem(begin); + item.addFragmentAtPosition(newFragment, i); + } + if (chosenSpan instanceof LocaleSpan) { // LocaleSpan + newFragment.setLocale(((LocaleSpan) chosenSpan).getLocale()); + } else if (chosenSpan != null) { // ClickableSpan (including UrlSpan) + handleClickableSpan(newFragment); + } + } + } + } + + /** + * Return the first offset greater than start where a markup object of any class of + * types begins or ends, or limit if there are no starts or ends greater + * than start but less than limit. + */ + private static int nextSpanTransition( + Spannable spannable, int start, int limit, Class... types) { + int next = limit; + for (Class type : types) { + int currentNext = spannable.nextSpanTransition(start, limit, type); + if (currentNext < next) { + next = currentNext; + } + } + + return next; + } + + /** + * Handles {@link FeedbackFragment} with {@link ClickableSpan} (including {@link URLSpan]}). Adds + * earcon and pitch information to the fragment. + * + * @param fragment The fragment containing {@link ClickableSpan}. + */ + private static void handleClickableSpan(FeedbackFragment fragment) { + final Bundle speechParams = new Bundle(Bundle.EMPTY); + speechParams.putFloat(SpeechParam.PITCH, PITCH_CHANGE_HYPERLINK); + fragment.setSpeechParams(speechParams); + fragment.addEarcon(R.raw.hyperlink); + } + + private static void copyFragmentMetadata(FeedbackFragment from, FeedbackFragment to) { + to.setSpeechParams(from.getSpeechParams()); + to.setNonSpeechParams(from.getNonSpeechParams()); + for (int id : from.getEarcons()) { + to.addEarcon(id); + } + + for (int id : from.getHaptics()) { + to.addHaptic(id); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechCleanupUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechCleanupUtils.java new file mode 100644 index 0000000..3468128 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechCleanupUtils.java @@ -0,0 +1,308 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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.google.android.accessibility.utils.output; + +import android.content.Context; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.util.SparseIntArray; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.SpannableUtils; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.checker.nullness.qual.PolyNull; + +/** Utilities for cleaning up speech text. */ +public class SpeechCleanupUtils { + /** The regular expression used to match consecutive identical characters */ + // Double escaping of regex characters is required. "\\1" refers to the + // first capturing group between the outer nesting of "[]"s and "{2,}" + // refers to two or more additional repetitions thereof. + private static final String CONSECUTIVE_CHARACTER_REGEX = + "([\\-\\\\/|!@#$%^&*\\(\\)=_+\\[\\]\\{\\}.?;'\":<>\\u2022])\\1{2,}"; + + /** The Pattern used to match consecutive identical characters */ + private static final Pattern CONSECUTIVE_CHARACTER_PATTERN = + Pattern.compile(CONSECUTIVE_CHARACTER_REGEX); + + /** Map containing string to speech conversions. */ + private static final SparseIntArray UNICODE_MAP = new SparseIntArray(); + + static { + UNICODE_MAP.put('&', R.string.symbol_ampersand); + UNICODE_MAP.put('<', R.string.symbol_angle_bracket_left); + UNICODE_MAP.put('>', R.string.symbol_angle_bracket_right); + UNICODE_MAP.put('\'', R.string.symbol_apostrophe); + UNICODE_MAP.put('*', R.string.symbol_asterisk); + UNICODE_MAP.put('@', R.string.symbol_at_sign); + UNICODE_MAP.put('\\', R.string.symbol_backslash); + UNICODE_MAP.put('\u2022', R.string.symbol_bullet); + UNICODE_MAP.put('^', R.string.symbol_caret); + UNICODE_MAP.put('¢', R.string.symbol_cent); + UNICODE_MAP.put(':', R.string.symbol_colon); + UNICODE_MAP.put(',', R.string.symbol_comma); + UNICODE_MAP.put('©', R.string.symbol_copyright); + UNICODE_MAP.put('{', R.string.symbol_curly_bracket_left); + UNICODE_MAP.put('}', R.string.symbol_curly_bracket_right); + UNICODE_MAP.put('°', R.string.symbol_degree); + UNICODE_MAP.put('\u00F7', R.string.symbol_division); + UNICODE_MAP.put('$', R.string.symbol_dollar_sign); + UNICODE_MAP.put('…', R.string.symbol_ellipsis); + UNICODE_MAP.put('\u2014', R.string.symbol_em_dash); + UNICODE_MAP.put('\u2013', R.string.symbol_en_dash); + UNICODE_MAP.put('€', R.string.symbol_euro); + UNICODE_MAP.put('!', R.string.symbol_exclamation_mark); + UNICODE_MAP.put('`', R.string.symbol_grave_accent); + UNICODE_MAP.put('-', R.string.symbol_hyphen_minus); + UNICODE_MAP.put('„', R.string.symbol_low_double_quote); + UNICODE_MAP.put('\u00D7', R.string.symbol_multiplication); + UNICODE_MAP.put('\n', R.string.symbol_new_line); + UNICODE_MAP.put('¶', R.string.symbol_paragraph_mark); + UNICODE_MAP.put('(', R.string.symbol_parenthesis_left); + UNICODE_MAP.put(')', R.string.symbol_parenthesis_right); + UNICODE_MAP.put('%', R.string.symbol_percent); + UNICODE_MAP.put('.', R.string.symbol_period); + UNICODE_MAP.put('π', R.string.symbol_pi); + UNICODE_MAP.put('#', R.string.symbol_pound); + UNICODE_MAP.put('£', R.string.symbol_pound_sterling); + UNICODE_MAP.put('?', R.string.symbol_question_mark); + UNICODE_MAP.put('"', R.string.symbol_quotation_mark); + UNICODE_MAP.put('®', R.string.symbol_registered_trademark); + UNICODE_MAP.put(';', R.string.symbol_semicolon); + UNICODE_MAP.put('/', R.string.symbol_slash); + UNICODE_MAP.put(' ', R.string.symbol_space); + UNICODE_MAP.put('[', R.string.symbol_square_bracket_left); + UNICODE_MAP.put(']', R.string.symbol_square_bracket_right); + UNICODE_MAP.put('√', R.string.symbol_square_root); + UNICODE_MAP.put('™', R.string.symbol_trademark); + UNICODE_MAP.put('_', R.string.symbol_underscore); + UNICODE_MAP.put('|', R.string.symbol_vertical_bar); + UNICODE_MAP.put('\u00a5', R.string.symbol_yen); + UNICODE_MAP.put('\u00ac', R.string.symbol_not_sign); + UNICODE_MAP.put('\u00a6', R.string.symbol_broken_bar); + UNICODE_MAP.put('\u00b5', R.string.symbol_micro_sign); + UNICODE_MAP.put('\u2248', R.string.symbol_almost_equals); + UNICODE_MAP.put('\u2260', R.string.symbol_not_equals); + UNICODE_MAP.put('\u00a4', R.string.symbol_currency_sign); + UNICODE_MAP.put('\u00a7', R.string.symbol_section_sign); + UNICODE_MAP.put('\u2191', R.string.symbol_upwards_arrow); + UNICODE_MAP.put('\u2190', R.string.symbol_leftwards_arrow); + UNICODE_MAP.put('\u20B9', R.string.symbol_rupee); + UNICODE_MAP.put('\u2665', R.string.symbol_black_heart); + UNICODE_MAP.put('\u007e', R.string.symbol_tilde); + UNICODE_MAP.put('\u003d', R.string.symbol_equal); + UNICODE_MAP.put('\uffe6', R.string.symbol_won); + UNICODE_MAP.put('\u203b', R.string.symbol_reference); + UNICODE_MAP.put('\u2606', R.string.symbol_white_star); + UNICODE_MAP.put('\u2605', R.string.symbol_black_star); + UNICODE_MAP.put('\u2661', R.string.symbol_white_heart); + UNICODE_MAP.put('\u25cb', R.string.symbol_white_circle); + UNICODE_MAP.put('\u25cf', R.string.symbol_black_circle); + UNICODE_MAP.put('\u2299', R.string.symbol_solar); + UNICODE_MAP.put('\u25ce', R.string.symbol_bullseye); + UNICODE_MAP.put('\u2667', R.string.symbol_white_club_suit); + UNICODE_MAP.put('\u2664', R.string.symbol_white_spade_suit); + UNICODE_MAP.put('\u261c', R.string.symbol_white_left_pointing_index); + UNICODE_MAP.put('\u261e', R.string.symbol_white_right_pointing_index); + UNICODE_MAP.put('\u25d0', R.string.symbol_circle_left_half_black); + UNICODE_MAP.put('\u25d1', R.string.symbol_circle_right_half_black); + UNICODE_MAP.put('\u25a1', R.string.symbol_white_square); + UNICODE_MAP.put('\u25a0', R.string.symbol_black_square); + UNICODE_MAP.put('\u25b3', R.string.symbol_white_up_pointing_triangle); + UNICODE_MAP.put('\u25bd', R.string.symbol_white_down_pointing_triangle); + UNICODE_MAP.put('\u25c1', R.string.symbol_white_left_pointing_triangle); + UNICODE_MAP.put('\u25b7', R.string.symbol_white_right_pointing_triangle); + UNICODE_MAP.put('\u25c7', R.string.symbol_white_diamond); + UNICODE_MAP.put('\u2669', R.string.symbol_quarter_note); + UNICODE_MAP.put('\u266a', R.string.symbol_eighth_note); + UNICODE_MAP.put('\u266c', R.string.symbol_beamed_sixteenth_note); + UNICODE_MAP.put('\u2640', R.string.symbol_female); + UNICODE_MAP.put('\u2642', R.string.symbol_male); + UNICODE_MAP.put('\u3010', R.string.symbol_left_black_lenticular_bracket); + UNICODE_MAP.put('\u3011', R.string.symbol_right_black_lenticular_bracket); + UNICODE_MAP.put('\u300c', R.string.symbol_left_corner_bracket); + UNICODE_MAP.put('\u300d', R.string.symbol_right_corner_bracket); + UNICODE_MAP.put('\u2192', R.string.symbol_rightwards_arrow); + UNICODE_MAP.put('\u2193', R.string.symbol_downwards_arrow); + UNICODE_MAP.put('\u00b1', R.string.symbol_plus_minus_sign); + UNICODE_MAP.put('\u2113', R.string.symbol_liter); + UNICODE_MAP.put('\u2103', R.string.symbol_celsius_degree); + UNICODE_MAP.put('\u2109', R.string.symbol_fahrenheit_degree); + UNICODE_MAP.put('\u00a2', R.string.symbol_cent); + UNICODE_MAP.put('\u2252', R.string.symbol_approximately_equals); + UNICODE_MAP.put('\u222b', R.string.symbol_integral); + UNICODE_MAP.put('\u27e8', R.string.symbol_mathematical_left_angle_bracket); + UNICODE_MAP.put('\u27e9', R.string.symbol_mathematical_right_angle_bracket); + UNICODE_MAP.put('\u3012', R.string.symbol_postal_mark); + UNICODE_MAP.put('\u25b2', R.string.symbol_black_triangle_pointing_up); + UNICODE_MAP.put('\u25bC', R.string.symbol_black_triangle_pointing_down); + UNICODE_MAP.put('\u25c6', R.string.symbol_black_suit_of_diamonds); + UNICODE_MAP.put('\uff65', R.string.symbol_halfwidth_katakana_middle_dot); + UNICODE_MAP.put('\u25aa', R.string.symbol_black_smallsquare); + UNICODE_MAP.put('\u300a', R.string.symbol_left_angle_bracket); + UNICODE_MAP.put('\u300b', R.string.symbol_right_angle_bracket); + UNICODE_MAP.put('\u00a1', R.string.symbol_inverted_exclamation_mark); + UNICODE_MAP.put('\u00bf', R.string.symbol_inverted_question_mark); + UNICODE_MAP.put('\u20a9', R.string.symbol_won_sign); + UNICODE_MAP.put('\uff0c', R.string.symbol_full_width_comma); + UNICODE_MAP.put('\uff01', R.string.symbol_full_width_exclamation_mark); + UNICODE_MAP.put('\u3002', R.string.symbol_full_width_ideographic_full_stop); + UNICODE_MAP.put('\uff1f', R.string.symbol_full_width_question_mark); + UNICODE_MAP.put('\u00b7', R.string.symbol_middle_dot); + UNICODE_MAP.put('\u201d', R.string.symbol_right_double_quotation_mark); + UNICODE_MAP.put('\u3001', R.string.symbol_ideographic_comma); + UNICODE_MAP.put('\uff1a', R.string.symbol_full_width_colon); + UNICODE_MAP.put('\uff1b', R.string.symbol_full_width_semicolon); + UNICODE_MAP.put('\uff06', R.string.symbol_full_width_ampersand); + UNICODE_MAP.put('\uff3e', R.string.symbol_full_width_circumflex); + UNICODE_MAP.put('\uff5e', R.string.symbol_full_width_tilde); + UNICODE_MAP.put('\u201c', R.string.symbol_left_double_quotation_mark); + UNICODE_MAP.put('\uff08', R.string.symbol_full_width_left_parenthesis); + UNICODE_MAP.put('\uff09', R.string.symbol_full_width_right_parenthesis); + UNICODE_MAP.put('\uff0a', R.string.symbol_full_width_asterisk); + UNICODE_MAP.put('\uff3f', R.string.symbol_full_width_underscore); + UNICODE_MAP.put('\u2019', R.string.symbol_right_single_quotation_mark); + UNICODE_MAP.put('\uff5b', R.string.symbol_full_width_left_curly_bracket); + UNICODE_MAP.put('\uff5d', R.string.symbol_full_width_right_curly_bracket); + UNICODE_MAP.put('\uff1c', R.string.symbol_full_width_less_than_sign); + UNICODE_MAP.put('\uff1e', R.string.symbol_full_width_greater_than_sign); + UNICODE_MAP.put('\u2018', R.string.symbol_left_single_quotation_mark); + // Arabic diacritical marks. + UNICODE_MAP.put('\u064e', R.string.symbol_fatha); + UNICODE_MAP.put('\u0650', R.string.symbol_kasra); + UNICODE_MAP.put('\u064f', R.string.symbol_damma); + UNICODE_MAP.put('\u064b', R.string.symbol_fathatan); + UNICODE_MAP.put('\u064d', R.string.symbol_kasratan); + UNICODE_MAP.put('\u064c', R.string.symbol_dammatan); + UNICODE_MAP.put('\u0651', R.string.symbol_shadda); + UNICODE_MAP.put('\u0652', R.string.symbol_sukun); + } + + /** + * Cleans up text for speech. Converts symbols to their spoken equivalents. + * + * @param context The context used to resolve string resources. + * @param text The text to clean up. + * @return Cleaned up text, or null if text is null. + */ + public static @PolyNull CharSequence cleanUp(Context context, @PolyNull CharSequence text) { + if (text != null) { + CharSequence textAfterTrim = SpannableUtils.trimText(text); + int trimmedLength = textAfterTrim.length(); + if (trimmedLength == 1) { + CharSequence textAfterCleanUp = getCleanValueFor(context, textAfterTrim.charAt(0)); + + // Return the text as it is if it remains the same after clean up so + // that any Span information is not lost + if (TextUtils.equals(textAfterCleanUp, textAfterTrim)) { + return textAfterTrim; + } + + // Retaining Spans that might have got stripped during cleanUp + CharSequence formattedText = retainSpans(text, textAfterCleanUp); + return formattedText; + + } else if (trimmedLength == 0 && text.length() > 0) { + // For example, just spaces. + return getCleanValueFor(context, text.toString().charAt(0)); + } + } + return text; + } + + // Tries to detect spans in the original text + // and wraps the cleaned up text with those spans. + private static CharSequence retainSpans(CharSequence text, CharSequence textAfterCleanUp) { + if (text instanceof Spannable) { + Spannable spannable = (Spannable) text; + Object[] spans = spannable.getSpans(0, text.length(), Object.class); + if (spans.length != 0) { + SpannableString ss = new SpannableString(textAfterCleanUp); + for (Object span : spans) { + ss.setSpan(span, 0, ss.length(), 0); + } + return ss; + } + } + return textAfterCleanUp; + } + + /** + * Collapses repeated consecutive characters in a CharSequence by matching against {@link + * #CONSECUTIVE_CHARACTER_REGEX}. + * + * @param context Context for retrieving resources + * @param text The text to process + * @return The text with consecutive identical characters collapsed + */ + public static @Nullable CharSequence collapseRepeatedCharacters( + Context context, @Nullable CharSequence text) { + if (TextUtils.isEmpty(text)) { + return null; + } + + // TODO: Add tests + Matcher matcher = CONSECUTIVE_CHARACTER_PATTERN.matcher(text); + while (matcher.find()) { + final String replacement = + context.getString( + R.string.character_collapse_template, + matcher.group().length(), + getCleanValueFor(context, matcher.group().charAt(0))); + final int matchFromIndex = matcher.end() - matcher.group().length() + replacement.length(); + text = matcher.replaceFirst(replacement); + matcher = CONSECUTIVE_CHARACTER_PATTERN.matcher(text); + matcher.region(matchFromIndex, text.length()); + } + + return text; + } + + /** + * Convenience method that feeds the given text through {@link #collapseRepeatedCharacters} and + * then {@link #cleanUp}. + */ + public static @Nullable CharSequence collapseRepeatedCharactersAndCleanUp( + Context context, @Nullable CharSequence text) { + CharSequence collapsed = collapseRepeatedCharacters(context, text); + CharSequence cleanedUp = cleanUp(context, collapsed); + return cleanedUp; + } + + /** Returns the "clean" value for the specified character. */ + public static String getCleanValueFor(Context context, char key) { + final int resId = UNICODE_MAP.get(key); + + if (resId != 0) { + return context.getString(resId); + } + + return Character.toString(key); + } + + /** Returns the "clean" value for the specified character as punctuation. */ + public static @Nullable String characterToName(Context context, char key) { + final int resId = UNICODE_MAP.get(key); + + if (resId != 0 && key != ' ') { + return context.getString(resId); + } + return null; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechController.java b/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechController.java new file mode 100644 index 0000000..f977814 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechController.java @@ -0,0 +1,450 @@ +/* + * Copyright (C) 2016 Google Inc. + * + * 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.google.android.accessibility.utils.output; + +import android.media.AudioManager; +import android.os.Bundle; +import com.google.android.accessibility.utils.BuildVersionUtils; +import com.google.android.accessibility.utils.Performance.EventId; +import java.util.Objects; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.Nullable; + +public interface SpeechController { + /** Default stream for speech output. */ + int DEFAULT_STREAM = + BuildVersionUtils.isAtLeastO() + ? AudioManager.STREAM_ACCESSIBILITY + : AudioManager.STREAM_MUSIC; + + // Queue modes. + int QUEUE_MODE_INTERRUPT = 0; + int QUEUE_MODE_QUEUE = 1; + /** + * Similar to QUEUE_MODE_QUEUE. The only difference is FeedbackItem in this mode cannot be + * interrupted by another while it is speaking. This includes not being removed from the queue + * unless shutdown is called. FeedbackItem in this mode will still be interrupted and removed from + * the queue when {@link SpeechController#interrupt} is called. + */ + int QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH = 2; + + int QUEUE_MODE_FLUSH_ALL = 3; + /** + * FeedbackItem in this mode cannot be interrupted or removed from the queue when {@link + * SpeechController#interrupt(boolean, boolean, boolean)} is called and the + * interruptItemsThatCanIgnoreInterrupts parameter is true. + */ + int QUEUE_MODE_CAN_IGNORE_INTERRUPTS = 4; + /** + * FeedbackItems in this mode have the properties of both QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH + * and QUEUE_MODE_CAN_IGNORE_INTERRUPTS. + */ + int QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH_CAN_IGNORE_INTERRUPTS = 5; + + // Speech item status codes. + int STATUS_ERROR = 1; + int STATUS_INTERRUPTED = 3; + int STATUS_SPOKEN = 4; + int STATUS_NOT_SPOKEN = 5; + // A status indicates that the speech was interrupted by the client, and the Observer should not + // be notified when speech stops as a result of the interruption. + int STATUS_ERROR_DONT_NOTIFY_OBSERVER = 6; + int STATUS_PAUSE = 7; + int UTTERANCE_GROUP_DEFAULT = 0; + int UTTERANCE_GROUP_TEXT_SELECTION = 1; + int UTTERANCE_GROUP_SEEK_PROGRESS = 2; + int UTTERANCE_GROUP_PROGRESS_BAR_PROGRESS = 3; + int UTTERANCE_GROUP_SCREEN_MAGNIFICATION = 4; + + /** + * Delegate that is registered in {@link SpeechController} to provide callbacks when processing + * {@link FeedbackItem}/{@link Utterance}. + */ + interface Delegate { + boolean isAudioPlaybackActive(); + + boolean isMicrophoneActiveAndHeadphoneOff(); + + boolean isSsbActiveAndHeadphoneOff(); + + boolean isPhoneCallActive(); + + void onSpeakingForcedFeedback(); + } + + /** + * Listener for speech started and completed. TODO: This is only used for tests. Evaluate + * if it's still appropriate. + */ + interface SpeechControllerListener { + void onUtteranceQueued(FeedbackItem utterance); + + void onUtteranceStarted(FeedbackItem utterance); + + void onUtteranceCompleted(int utteranceIndex, int status); + } + + /** Receives events when speech starts and stops. */ + interface Observer { + void onSpeechStarting(); + + void onSpeechCompleted(); + + void onSpeechPaused(); + } + + /** Interface for a run method, used to perform action when an utterance starts. */ + interface UtteranceStartRunnable { + void run(); + } + + /** Interface for a callback method, used to update the range of utterance being spoken */ + interface UtteranceRangeStartCallback { + /** + * Callback to be invoked when it is about to speak the specific range of the utterance. + * + * @param start The start index of the range in the utterance text. + * @param end The end index of the range (exclusive) in the utterance text. + */ + void onUtteranceRangeStarted(int start, int end); + } + + /** Interface for a run method with a status, used to perform post-utterance action. */ + interface UtteranceCompleteRunnable { + /** @param status The status supplied. */ + void run(int status); + } + + /** Utility class run an UtteranceCompleteRunnable. */ + class CompletionRunner implements Runnable { + private final UtteranceCompleteRunnable mRunnable; + private final int mStatus; + + public CompletionRunner(UtteranceCompleteRunnable runnable, int status) { + mRunnable = runnable; + mStatus = status; + } + + @Override + public void run() { + mRunnable.run(mStatus); + } + } + + /** Builder class for input parameters to {@code speak()}. */ + class SpeakOptions { + public @Nullable Set mEarcons = null; + public @Nullable Set mHaptics = null; + public int mQueueMode = QUEUE_MODE_QUEUE; + public int mFlags = 0; + public int mUtteranceGroup = UTTERANCE_GROUP_DEFAULT; + public @Nullable Bundle mSpeechParams = null; + public @Nullable Bundle mNonSpeechParams = null; + public @Nullable UtteranceStartRunnable mStartingAction = null; + public @Nullable UtteranceRangeStartCallback mRangeStartCallback = null; + public @Nullable UtteranceCompleteRunnable mCompletedAction = null; + + private SpeakOptions() {} // To instantiate, use create(). + + public static SpeakOptions create() { + return new SpeakOptions(); + } + + public SpeakOptions setEarcons(Set earcons) { + mEarcons = earcons; + return this; + } + + public SpeakOptions setHaptics(Set haptics) { + mHaptics = haptics; + return this; + } + + public SpeakOptions setQueueMode(int queueMode) { + mQueueMode = queueMode; + return this; + } + + public SpeakOptions setFlags(int flags) { + mFlags = flags; + return this; + } + + public SpeakOptions setUtteranceGroup(int utteranceGroup) { + mUtteranceGroup = utteranceGroup; + return this; + } + + public SpeakOptions setSpeechParams(Bundle speechParams) { + mSpeechParams = speechParams; + return this; + } + + public SpeakOptions setNonSpeechParams(Bundle nonSpeechParams) { + mNonSpeechParams = nonSpeechParams; + return this; + } + + public SpeakOptions setStartingAction(UtteranceStartRunnable runnable) { + mStartingAction = runnable; + return this; + } + + public SpeakOptions setRangeStartCallback(UtteranceRangeStartCallback callback) { + mRangeStartCallback = callback; + return this; + } + + public SpeakOptions setCompletedAction(@Nullable UtteranceCompleteRunnable runnable) { + mCompletedAction = runnable; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof SpeakOptions)) { + return false; + } + SpeakOptions that = (SpeakOptions) o; + return mQueueMode == that.mQueueMode + && mFlags == that.mFlags + && mUtteranceGroup == that.mUtteranceGroup + && Objects.equals(mEarcons, that.mEarcons) + && Objects.equals(mHaptics, that.mHaptics) + && Objects.equals(mSpeechParams, that.mSpeechParams) + && Objects.equals(mNonSpeechParams, that.mNonSpeechParams) + && Objects.equals(mStartingAction, that.mStartingAction) + && Objects.equals(mRangeStartCallback, that.mRangeStartCallback) + && Objects.equals(mCompletedAction, that.mCompletedAction); + } + + @Override + public int hashCode() { + return Objects.hash( + mEarcons, + mHaptics, + mQueueMode, + mFlags, + mUtteranceGroup, + mSpeechParams, + mNonSpeechParams, + mStartingAction, + mRangeStartCallback, + mCompletedAction); + } + } + + void toggleVoiceFeedback(); + + void setMute(boolean mute); + + /** + * Cleans up and speaks an utterance. The queueMode determines whether + * the speech will interrupt or wait on queued speech events. + * + *

This method does nothing if the text to speak is empty. See {@link + * TextUtils#isEmpty(CharSequence)} for implementation. + * + *

See {@link SpeechCleanupUtils#cleanUp} for text clean-up implementation. + * + * @param text The text to speak. + * @param earcons The set of earcon IDs to play. + * @param haptics The set of vibration patterns to play. + * @param queueMode The queue mode to use for speaking. One of: + *

    + *
  • {@link #QUEUE_MODE_INTERRUPT} + *
  • {@link #QUEUE_MODE_QUEUE} + *
  • {@link #QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH} + *
  • {@link #QUEUE_MODE_CAN_IGNORE_INTERRUPTS} + *
  • {@link #QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH_CAN_IGNORE_INTERRUPTS} + *
+ * + * @param flags Bit mask of speaking flags. Use {@code 0} for no flags, or a combination of the + * flags defined in {@link FeedbackItem} + * @param speechParams Speaking parameters. Not all parameters are supported by all engines. One + * of: + *
    + *
  • {@link SpeechParam#PITCH} + *
  • {@link SpeechParam#RATE} + *
  • {@link SpeechParam#VOLUME} + *
+ * + * @param nonSpeechParams Non-Speech parameters. Optional, but can include {@link + * Utterance#KEY_METADATA_EARCON_RATE} and {@link Utterance#KEY_METADATA_EARCON_VOLUME} + * @param startAction The action to run before this utterance starts. + * @param rangeStartCallback The callback to update the range of utterance being spoken. + * @param completedAction The action to run after this utterance has been spoken. + * @param eventId The identity of an event which originated this speech action. + */ + void speak( + CharSequence text, + Set earcons, + Set haptics, + int queueMode, + int flags, + int utteranceGroup, + @Nullable Bundle speechParams, + Bundle nonSpeechParams, + UtteranceStartRunnable startAction, + UtteranceRangeStartCallback rangeStartCallback, + UtteranceCompleteRunnable completedAction, + @Nullable EventId eventId); + + /** + * @see #speak(CharSequence, Set, Set, int, int, int, Bundle, Bundle, UtteranceStartRunnable, + * UtteranceRangeStartCallback, UtteranceCompleteRunnable, EventId) + */ + void speak( + CharSequence text, + int queueMode, + int flags, + @Nullable Bundle speechParams, + @Nullable EventId eventId); + + /** + * @see #speak(CharSequence, Set, Set, int, int, int, Bundle, Bundle, UtteranceStartRunnable, + * UtteranceRangeStartCallback, UtteranceCompleteRunnable, EventId) + */ + void speak( + CharSequence text, + int queueMode, + int flags, + Bundle speechParams, + UtteranceStartRunnable startingAction, + UtteranceRangeStartCallback rangeStartCallback, + UtteranceCompleteRunnable completedAction, + EventId eventId); + + /** + * @see #speak(CharSequence, Set, Set, int, int, int, Bundle, Bundle, UtteranceStartRunnable, + * UtteranceRangeStartCallback, UtteranceCompleteRunnable, EventId) + */ + void speak( + CharSequence text, + Set earcons, + Set haptics, + int queueMode, + int flags, + int uttranceGroup, + @Nullable Bundle speechParams, + Bundle nonSpeechParams, + @Nullable EventId eventId); + + /** + * @see #speak(CharSequence, Set, Set, int, int, int, Bundle, Bundle, UtteranceStartRunnable, + * UtteranceRangeStartCallback, UtteranceCompleteRunnable, EventId) + */ + void speak(CharSequence text, @Nullable EventId eventId, @Nullable SpeakOptions options); + + boolean isSpeaking(); + + boolean isSpeakingOrSpeechQueued(); + + void addObserver(Observer observer); + + void removeObserver(Observer observer); + + void setTTSChangeAnnouncementEnabled(boolean enabled); + + /** + * Stops all speech from the calling app. Stops speech from other apps if stopTtsSpeechCompletely + * is true. + */ + void interrupt(boolean stopTtsSpeechCompletely); + + /** + * Stops all speech from the calling app. + * + * @param stopTtsSpeechCompletely Whether to also stop speech from other apps + * @param callObserver Whether to notify the Observer once speech is stopped + */ + void interrupt(boolean stopTtsSpeechCompletely, boolean callObserver); + + /** + * Stops all speech from the calling app. + * + * @param stopTtsSpeechCompletely Whether to also stop speech from other apps + * @param callObserver Whether to notify the Observer once speech is stopped + * @param interruptItemsThatCanIgnoreInterrupts Whether to interrupt and remove FeedbackItems that + * are in the QUEUE_MODE_CAN_IGNORE_INTERRUPTS or + * QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH_CAN_IGNORE_INTERRUPTS mode when this method is + * called + */ + void interrupt( + boolean stopTtsSpeechCompletely, + boolean callObserver, + boolean interruptItemsThatCanIgnoreInterrupts); + + + int peekNextUtteranceId(); + + // TODO: Check if it can be defined as a private method. + void addUtteranceStartAction(int index, UtteranceStartRunnable runnable); + + // TODO: Check if it can be defined as a private method. + /** + * Set the callback to update with the range of utterance being spoken. + * + * @param utteranceId The id of the utterance. + * @param callback The callback to be invoked. + */ + void setUtteranceRangeStartCallback(int utteranceId, UtteranceRangeStartCallback callback); + + // TODO: Check if it can be defined as a private method. + void addUtteranceCompleteAction(int index, UtteranceCompleteRunnable runnable); + + /** + * Sets whether the SpeechControllerImpl should inject utterance completed callbacks for advancing + * continuous reading. + */ + void setShouldInjectAutoReadingCallbacks( + boolean shouldInject, UtteranceCompleteRunnable nextItemCallback); + + /** + * Gets the {@link FailoverTextToSpeech} instance that is serving as a text-to-speech service. + * + * @return The text-to-speech service. + */ + FailoverTextToSpeech getFailoverTts(); + + /** Sets the listener for starting, stopping and queuing speech. */ + void setSpeechListener(SpeechControllerListener speechListener); + + /** + * Sets whether to handle TTS callback in main thread. If {@code false}, the callback will be + * handled in TTS thread. + */ + void setHandleTtsCallbackInMainThread(boolean shouldHandleInMainThread); + + /** + * Stops current feedbackFragment but don't callback UtteranceCompleteAction since it's an + * suspended state. {@link Observer#onSpeechPaused()} will be called if it works successfully. + */ + void pause(); + + /** + * Speaks remaining sentence of suspended feedbackFragment. It works properly when {@link + * FailoverTextToSpeech.FailoverTtsListener#onUtteranceRangeStarted(String, int, int)} could be + * called by TTS engine(ex Samsung TTS engine doesn't support it ) and Android version should be + * above Oreo, otherwise it would speak whole text of the feedbackFragment. {@link + * Observer#onSpeechStarting()} will be called if it works successfully. + */ + void resume(); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechControllerImpl.java b/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechControllerImpl.java new file mode 100644 index 0000000..3bf0fb2 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechControllerImpl.java @@ -0,0 +1,2186 @@ +/* + * Copyright (C) 2016 Google Inc. + * + * 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.google.android.accessibility.utils.output; + +import static android.text.Spanned.SPAN_INCLUSIVE_EXCLUSIVE; +import static com.google.android.accessibility.utils.Performance.EVENT_ID_UNTRACKED; +import static com.google.android.accessibility.utils.output.FeedbackItem.FLAG_SOURCE_IS_VOLUME_CONTROL; +import static java.lang.Math.min; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.AudioRecordingConfiguration; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.SystemClock; +import android.speech.tts.TextToSpeech.Engine; +import android.speech.tts.Voice; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.ReplacementSpan; +import android.text.style.TtsSpan; +import android.util.Range; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import com.google.android.accessibility.utils.BuildVersionUtils; +import com.google.android.accessibility.utils.FeatureSupport; +import com.google.android.accessibility.utils.Performance; +import com.google.android.accessibility.utils.Performance.EventId; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.SpannableUtils; +import com.google.android.accessibility.utils.StringBuilderUtils; +import com.google.android.accessibility.utils.output.FailoverTextToSpeech.SpeechParam; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.ListIterator; +import java.util.Locale; +import java.util.PriorityQueue; +import java.util.Set; +import org.checkerframework.checker.initialization.qual.UnknownInitialization; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Handles text-to-speech. */ +public class SpeechControllerImpl implements SpeechController { + /** Feedback type of speaking capital letter. */ + @IntDef({ + CAPITAL_LETTERS_TYPE_SPEAK_CAP, + CAPITAL_LETTERS_TYPE_PITCH, + CAPITAL_LETTERS_TYPE_SOUND_FEEDBACK, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface CapitalLetterHandlingMethod {} + + public static final int CAPITAL_LETTERS_TYPE_SPEAK_CAP = 1; + public static final int CAPITAL_LETTERS_TYPE_PITCH = 2; + public static final int CAPITAL_LETTERS_TYPE_SOUND_FEEDBACK = 3; + + private static final String TAG = "SpeechControllerImpl"; + + /** Prefix for utterance IDs. */ + private static final String UTTERANCE_ID_PREFIX = "talkback_"; + + /** The number of recently-spoken items to keep in history. */ + private static final int MAX_HISTORY_ITEMS = 10; + + /** + * The delay, in ms, after which a recently-spoken item will be considered for duplicate removal + * in the event that a new feedback item has the flag {@link FeedbackItem#FLAG_SKIP_DUPLICATE}. + * (The delay does not apply to queued items that haven't been spoken yet or to the currently + * speaking item; these items will always be considered.) + */ + private static final int SKIP_DUPLICATES_DELAY = 1000; + /** + * Assume the time between the Touch explore interaction start event & Pause gesture event is no + * greater than this value (milli-second). + */ + private static final long SAVED_FEEDBACK_FOR_PAUSE_TIME = 800; + + /** Reusable map used for passing parameters to the TextToSpeech. */ + private final HashMap mSpeechParametersMap = new HashMap<>(); + + /** + * Priority queue of actions to perform before utterances start, ordered by ascending utterance + * index. + */ + private final PriorityQueue mUtteranceStartActions = new PriorityQueue<>(); + + /** + * Priority queue of actions to perform when utterances are completed, ordered by ascending + * utterance index. + */ + private final PriorityQueue mUtteranceCompleteActions = + new PriorityQueue<>(); + + /** Maps from utterance index to UtteranceRangeStartCallback. */ + private final HashMap mUtteranceRangeStartCallbacks = + new HashMap<>(); + + /** The list of items to be spoken. */ + private ArrayList feedbackQueue = new ArrayList<>(); + /** The list for stopping or resuming voice feedback. */ + private ArrayList savedFeedbackQueue; + /** Keep the feedbackSavedTime to correlate it to the Pause gesture */ + private long feedbackSavedTime; + + /** The list of recently-spoken items. */ + private final LinkedList mFeedbackHistory = new LinkedList<>(); + + /** Talkback speech deliberately saved by a caller of saveLastUtterance() */ + private @Nullable FeedbackItem savedUtterance; + + private final Context mContext; + + /** The SpeechController delegate, used to provide callbacks. */ + private final Delegate mDelegate; + + /** The audio manager, used to query ringer volume. */ + private final AudioManager mAudioManager; + + /** The feedback controller, used for playing auditory icons and vibration */ + private final FeedbackController mFeedbackController; + + /** The text-to-speech service, used for speaking. */ + private final FailoverTextToSpeech mFailoverTts; + + private boolean mShouldHandleTtsCallBackInMainThread = true; + + /** Listener used for testing. */ + private SpeechControllerListener mSpeechListener; + + private final Set mObservers = new HashSet<>(); + + /** An iterator of fragments currently being processed */ + private @Nullable FeedbackFragmentsIterator currentFragmentIterator = null; + /** An iterator for stopping or resuming voice feedback. */ + private @Nullable FeedbackFragmentsIterator savedFragmentIterator = null; + + /** The item current being spoken, or {@code null} if the TTS is idle. */ + private @Nullable FeedbackItem mCurrentFeedbackItem; + + /** The saved item before initializing the item current being spoken */ + private @Nullable FeedbackItem savedFeedbackItem; + + /** Whether we should request audio focus during speech. */ + private boolean mUseAudioFocus = false; + + /** The text-to-speech screen overlay. */ + private TextToSpeechOverlay mTtsOverlay; + + /** Whether the speech controller should add utterance callbacks to FullScreenReadActor */ + private boolean mInjectFullScreenReadCallbacks; + + /** The utterance completed callback for FullScreenReadActor */ + private UtteranceCompleteRunnable mFullScreenReadNextCallback; + + /** + * The next utterance index; each utterance value will be constructed from this ever-increasing + * index. + */ + private int mNextUtteranceIndex = 0; + + /** Whether rate and pitch can change. */ + private boolean mUseIntonation = true; + + /** Whether reading punctuation can change. */ + private boolean mUsePunctuation = false; + + /** The feedback of capital letter (default is "Cap") */ + @CapitalLetterHandlingMethod private int capLetterFeedback = CAPITAL_LETTERS_TYPE_SPEAK_CAP; + + /** The speech pitch adjustment for capital letters. */ + private static final float CAPITAL_LETTER_PITCH_RATE = 1.8f; + + private static final float CAPITAL_LETTER_PITCH_RATE_UPPER_BOUND = 2.0f; + + /** The speech rate adjustment (default is 1.0). */ + private float mSpeechRate = 1.0f; + + /** The speech pitch adjustment (default is 1.0). */ + private float mSpeechPitch = 1.0f; + + /** The speech volume adjustment (default is 1.0). */ + private float mSpeechVolume = 1.0f; + + /** + * Whether the controller is currently speaking utterances. Used to check consistency of internal + * speaking state. + */ + private boolean mIsSpeaking; + + /** Indicates that we want to switch TTS silently, i.e. don't say "Using XYZ engine". */ + private boolean mSkipNextTTSChangeAnnouncement = false; + + private boolean ttsChangeAnnouncementEnabled = true; + + /** Records pause request */ + private boolean requestPause = false; + + /** Whether voice feedback is mute. */ + private boolean isMuteSpeech = false; + + /** Records whether should silence speech */ + private boolean shouldSilentSpeech = false; + + /** Records whether should silence speech */ + private boolean sourceIsVolumeControl = false; + + public SpeechControllerImpl( + Context context, Delegate delegate, FeedbackController feedbackController) { + this(context, delegate, feedbackController, new FailoverTextToSpeech(context)); + } + + public SpeechControllerImpl( + Context context, + Delegate delegate, + FeedbackController feedbackController, + FailoverTextToSpeech failOverTts) { + mContext = context; + mDelegate = delegate; + + mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); + + mFailoverTts = failOverTts; + mFailoverTts.addListener( + new FailoverTextToSpeech.FailoverTtsListener() { + @Override + public void onTtsInitialized(boolean wasSwitchingEngines) { + SpeechControllerImpl.this.onTtsInitialized(wasSwitchingEngines); + } + + @Override + public void onUtteranceStarted(String utteranceId) { + SpeechControllerImpl.this.onFragmentStarted(utteranceId); + } + + @Override + public void onUtteranceRangeStarted(String utteranceId, int start, int end) { + SpeechControllerImpl.this.onFragmentRangeStarted(utteranceId, start, end); + } + + @Override + public void onUtteranceCompleted(String utteranceId, boolean success) { + // Utterances from FailoverTts are considered fragments in SpeechControllerImpl + SpeechControllerImpl.this.onFragmentCompleted( + utteranceId, success, true /* advance */, true /* notifyObserver */); + } + }); + + mFeedbackController = feedbackController; + mInjectFullScreenReadCallbacks = false; + } + + @Override + public void setTTSChangeAnnouncementEnabled(boolean enabled) { + ttsChangeAnnouncementEnabled = enabled; + } + + /** @return {@code true} if the speech controller is currently speaking. */ + @Override + public boolean isSpeaking() { + return mIsSpeaking; + } + + @Override + public void addObserver(@UnknownInitialization(Observer.class) Observer observer) { + mObservers.add(observer); + } + + @Override + public void removeObserver(SpeechController.Observer observer) { + mObservers.remove(observer); + } + + public void setUseAudioFocus(boolean useAudioFocus) { + mUseAudioFocus = useAudioFocus; + if (!mUseAudioFocus) { + mAudioManager.abandonAudioFocus(mAudioFocusListener); + } + } + + public void setUseIntonation(boolean useIntonation) { + mUseIntonation = useIntonation; + } + + public void setUsePunctuation(boolean usePunctuation) { + mUsePunctuation = usePunctuation; + } + + public void setCapLetterFeedback(@CapitalLetterHandlingMethod int capLetterFeedback) { + this.capLetterFeedback = capLetterFeedback; + } + + public void setSpeechPitch(float speechPitch) { + mSpeechPitch = speechPitch; + } + + public void setSpeechRate(float speechRate) { + mSpeechRate = speechRate; + } + + public void setSpeechVolume(float speechVolume) { + mSpeechVolume = speechVolume; + } + + /** @return {@code true} if the speech controller has feedback queued up to speak */ + private boolean isSpeechQueued() { + return !feedbackQueue.isEmpty(); + } + + @Override + public boolean isSpeakingOrSpeechQueued() { + return isSpeaking() || isSpeechQueued(); + } + + /** Read-only limited interface for reading speech state from parent SpeechController. */ + public class State { + public boolean isSpeaking() { + return SpeechControllerImpl.this.isSpeaking(); + } + + /** + * In addition to check any feedback items are queued or spoken by {@link + * #isSpeakingOrSpeechQueued()}, this method excludes the feedback items which are tagged with + * {@code FeedbackItem#FLAG_SOURCE_IS_VOLUME_CONTROL}. That is used to identify the items + * generated by Volume control UI. + */ + public boolean isSpeakingOrQueuedAndNotSourceIsVolumeAnnouncment() { + return !sourceIsVolumeControl && SpeechControllerImpl.this.isSpeakingOrSpeechQueued(); + } + + public @Nullable Set getVoices() { + return SpeechControllerImpl.this.getVoices(); + } + } + + /** Read-only interface for reading speech state. */ + public final State state = new State(); + + @Override + public void setSpeechListener(SpeechControllerListener speechListener) { + mSpeechListener = speechListener; + } + + @Override + public void setHandleTtsCallbackInMainThread(boolean shouldHandleInMainThread) { + mShouldHandleTtsCallBackInMainThread = shouldHandleInMainThread; + mFailoverTts.setHandleTtsCallbackInMainThread(shouldHandleInMainThread); + } + + /** + * Sets whether the SpeechControllerImpl should inject utterance completed callbacks for advancing + * continuous reading. + */ + @Override + public void setShouldInjectAutoReadingCallbacks( + boolean shouldInject, UtteranceCompleteRunnable nextItemCallback) { + mFullScreenReadNextCallback = (shouldInject) ? nextItemCallback : null; + mInjectFullScreenReadCallbacks = shouldInject; + + if (!shouldInject) { + removeUtteranceCompleteAction(nextItemCallback); + } + } + + /** + * Forces a reload of the user's preferred TTS engine, if it is available and the current TTS + * engine is not the preferred engine. + * + * @param quiet suppresses the "Using XYZ engine" message if the TTS engine changes + */ + public void updateTtsEngine(boolean quiet) { + mSkipNextTTSChangeAnnouncement = quiet; + mFailoverTts.updateDefaultEngine(); + } + + /** + * Gets the {@link FailoverTextToSpeech} instance that is serving as a text-to-speech service. + * + * @return The text-to-speech service. + */ + @Override + public FailoverTextToSpeech getFailoverTts() { + return mFailoverTts; + } + + public @Nullable Set getVoices() { + try { + if (mFailoverTts.isReady()) { + return mFailoverTts.getEngineInstance().getVoices(); + } + + LogUtils.w(TAG, "Attempted to get voices before TTS was initialized."); + return null; + } catch (Exception e) { + LogUtils.e(TAG, "TTS client crashed while generating language menu items"); + e.printStackTrace(); + return null; + } + } + + /** Repeats the last spoken utterance. */ + public boolean repeatLastUtterance() { + return repeatUtterance(getLastUtterance()); + } + + /** Copies saved talkback speech to clipboard. */ + public boolean copySavedUtteranceToClipboard(EventId eventId) { + return copyUtteranceToClipboard(savedUtterance, eventId); + } + + /** Copies the last phrase spoken by TalkBack to clipboard */ + public boolean copyLastUtteranceToClipboard(EventId eventId) { + return copyUtteranceToClipboard(getLastUtterance(), eventId); + } + + /** Copies a phrase spoken by TalkBack to clipboard */ + public boolean copyUtteranceToClipboard(FeedbackItem item, EventId eventId) { + if (item == null) { + return false; + } + + final ClipboardManager clipboard = + (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText(null, item.getAggregateText()); + clipboard.setPrimaryClip(clip); + + // Verify that we actually have the utterance on the clipboard + if (FeatureSupport.supportReadClipboard()) { + clip = clipboard.getPrimaryClip(); + } + if (clip != null && clip.getItemCount() > 0 && clip.getItemAt(0).getText() != null) { + speak( + mContext.getString( + R.string.template_text_copied, clip.getItemAt(0).getText().toString()) /* text */, + QUEUE_MODE_INTERRUPT /* queue mode */, + 0 /* flags */, + null /* speech params */, + eventId); + return true; + } else { + return false; + } + } + + /** Saves most recent talkback speech. */ + public void saveLastUtterance() { + savedUtterance = getLastUtterance(); + } + + /** Returns {@code true} if SpeechControllerImpl should interrupt speech */ + public boolean getShouldSilentSpeech() { + return shouldSilentSpeech; + } + + public void setSilenceSpeech(boolean shouldSlientSpeech) { + this.shouldSilentSpeech = shouldSlientSpeech; + } + + /** Returns the last spoken utterance. */ + public @Nullable FeedbackItem getLastUtterance() { + if (mFeedbackHistory.isEmpty()) { + return null; + } + return mFeedbackHistory.getLast(); + } + + /** Re-speaks saved talkback speech. */ + public boolean repeatSavedUtterance() { + return repeatUtterance(savedUtterance); + } + + /** Repeats the provided utterance. */ + public boolean repeatUtterance(@Nullable FeedbackItem item) { + if (item == null) { + return false; + } + /* + * We copy and speak the last item with history enabled. + * This guarantees that it is consistently the last item in the history. + * Otherwise the window title will be spoken when the context menu closes + * and if the user invokes this action again, it will repeat the window title. + */ + final FeedbackItem newItem = new FeedbackItem(item); + newItem.addFlag( + FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_AUDIO_PLAYBACK_ACTIVE + | FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_MICROPHONE_ACTIVE + | FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_SSB_ACTIVE); + speak( + newItem, /* feedbackItem */ + QUEUE_MODE_FLUSH_ALL, /* queueMode */ + null, /* startAction */ + null, /* rangeStartCallback */ + null); /* completeAction */ + return true; + } + + /** Spells the last spoken utterance. */ + public boolean spellLastUtterance() { + FeedbackItem last = getLastUtterance(); + return (last == null) ? false : spellUtterance(last); + } + + /** Announces the spelling of saved talkback speech. */ + public boolean spellSavedUtterance() { + return (savedUtterance == null) ? false : spellUtterance(savedUtterance); + } + + /** Spells the given utterance. */ + public boolean spellUtterance(FeedbackItem utterance) { + CharSequence text = utterance.getAggregateText(); + /* + * We spell the utterance then append a copy of the original utterance to the history. + * This guarantees that it is consistently the last item in the history. + * Otherwise the window title will be spoken when the context menu closes + * and if the user invokes this action again, it will spell the window title. + */ + boolean result = spellUtterance(text); + final FeedbackItem newUtterance = new FeedbackItem(utterance); + mFeedbackHistory.addLast(newUtterance); + return result; + } + + /** Spells the text. */ + public boolean spellUtterance(CharSequence text) { + if (TextUtils.isEmpty(text)) { + return false; + } + + final SpannableStringBuilder builder = new SpannableStringBuilder(); + for (int i = 0; i < text.length(); i++) { + final String cleanedChar = SpeechCleanupUtils.getCleanValueFor(mContext, text.charAt(i)); + + StringBuilderUtils.appendWithSeparator(builder, cleanedChar); + } + SpeakOptions options = SpeakOptions.create(); + options.mQueueMode = QUEUE_MODE_FLUSH_ALL; + options.mFlags = + FeedbackItem.FLAG_NO_HISTORY + | FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_AUDIO_PLAYBACK_ACTIVE + | FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_MICROPHONE_ACTIVE + | FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_SSB_ACTIVE; + options.mUtteranceGroup = UTTERANCE_GROUP_DEFAULT; + speak(builder, /* eventId= */ null, options); + return true; + } + + /** Speaks the name of the currently active TTS engine. */ + private void speakCurrentEngine() { + final CharSequence engineLabel = mFailoverTts.getEngineLabel(); + if (TextUtils.isEmpty(engineLabel)) { + return; + } + + final String text = mContext.getString(R.string.template_current_tts_engine, engineLabel); + + EventId eventId = EVENT_ID_UNTRACKED; // Not tracking performance for TTS initialization. + speak( + text, + null, + null, + QUEUE_MODE_QUEUE, + FeedbackItem.FLAG_NO_HISTORY + | FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_AUDIO_PLAYBACK_ACTIVE + | FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_MICROPHONE_ACTIVE, + UTTERANCE_GROUP_DEFAULT, + null, + null, + eventId); + } + + /** + * @see #speak(CharSequence, Set, Set, int, int, int, Bundle, Bundle, UtteranceStartRunnable, + * UtteranceRangeStartCallback, UtteranceCompleteRunnable, EventId) + */ + @Override + public void speak( + CharSequence text, + int queueMode, + int flags, + @Nullable Bundle speechParams, + @Nullable EventId eventId) { + speak(text, null, null, queueMode, flags, UTTERANCE_GROUP_DEFAULT, speechParams, null, eventId); + } + + /** + * @see #speak(CharSequence, Set, Set, int, int, int, Bundle, Bundle, UtteranceStartRunnable, + * UtteranceRangeStartCallback, UtteranceCompleteRunnable, EventId) + */ + @Override + public void speak( + CharSequence text, + int queueMode, + int flags, + @Nullable Bundle speechParams, + @Nullable UtteranceStartRunnable startingAction, + @Nullable UtteranceRangeStartCallback rangeStartCallback, + @Nullable UtteranceCompleteRunnable completedAction, + EventId eventId) { + speak( + text, + null, + null, + queueMode, + flags, + UTTERANCE_GROUP_DEFAULT, + speechParams, + null, + startingAction, + rangeStartCallback, + completedAction, + eventId); + } + + /** + * @see #speak(CharSequence, Set, Set, int, int, int, Bundle, Bundle, UtteranceStartRunnable, + * UtteranceRangeStartCallback, UtteranceCompleteRunnable, EventId) + */ + @Override + public void speak( + CharSequence text, + @Nullable Set earcons, + @Nullable Set haptics, + int queueMode, + int flags, + int uttranceGroup, + @Nullable Bundle speechParams, + @Nullable Bundle nonSpeechParams, + @Nullable EventId eventId) { + speak( + text, + earcons, + haptics, + queueMode, + flags, + uttranceGroup, + speechParams, + nonSpeechParams, + null, + null, + null, + eventId); + } + + /** + * @see #speak(CharSequence, Set, Set, int, int, int, Bundle, Bundle, UtteranceStartRunnable, + * UtteranceRangeStartCallback, UtteranceCompleteRunnable, EventId) + */ + @Override + public void speak(CharSequence text, @Nullable EventId eventId, @Nullable SpeakOptions options) { + if (options == null) { + options = SpeakOptions.create(); + } + speak( + text, + options.mEarcons, + options.mHaptics, + options.mQueueMode, + options.mFlags, + options.mUtteranceGroup, + options.mSpeechParams, + options.mNonSpeechParams, + options.mStartingAction, + options.mRangeStartCallback, + options.mCompletedAction, + eventId); + } + + /** Toggle the voice feedback flag with additional state change notification. */ + @Override + public void toggleVoiceFeedback() { + if (isMuteSpeech) { + // Un-mute the speech, then speak the state. + isMuteSpeech = false; + speak( + mContext.getString(R.string.function_on), + QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH, + /* flags= */ 0, + /* speechParams= */ null, + EVENT_ID_UNTRACKED); + } else { + // Speak the state before mute the speech feedback. + speak( + mContext.getString(R.string.function_off), + QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH, + /* flags= */ 0, + /* speechParams= */ null, + EVENT_ID_UNTRACKED); + isMuteSpeech = true; + } + } + + @Override + public void setMute(boolean mute) { + isMuteSpeech = mute; + } + + /** + * Cleans up and speaks an utterance. The queueMode determines whether + * the speech will interrupt or wait on queued speech events. + * + *

This method does nothing if the text to speak is empty. See {@link + * TextUtils#isEmpty(CharSequence)} for implementation. + * + *

See {@link SpeechCleanupUtils#cleanUp} for text clean-up implementation. + * + * @param text The text to speak. + * @param earcons The set of earcon IDs to play. + * @param haptics The set of vibration patterns to play. + * @param queueMode The queue mode to use for speaking. One of: + *

    + *
  • {@link #QUEUE_MODE_INTERRUPT} + *
  • {@link #QUEUE_MODE_QUEUE} + *
  • {@link #QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH} + *
  • {@link #QUEUE_MODE_CAN_IGNORE_INTERRUPTS} + *
  • {@link #QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH_CAN_IGNORE_INTERRUPTS} + *
+ * + * @param flags Bit mask of speaking flags. Use {@code 0} for no flags, or a combination of the + * flags defined in {@link FeedbackItem} + * @param speechParams Speaking parameters. Not all parameters are supported by all engines. One + * of: + *
    + *
  • {@link SpeechParam#PITCH} + *
  • {@link SpeechParam#RATE} + *
  • {@link SpeechParam#VOLUME} + *
+ * + * @param nonSpeechParams Non-Speech parameters. Optional, but can include {@link + * Utterance#KEY_METADATA_EARCON_RATE} and {@link Utterance#KEY_METADATA_EARCON_VOLUME} + * @param startAction The action to run before this utterance starts. + * @param rangeStartCallback The callback to update the range of utterance being spoken. + * @param completedAction The action to run after this utterance has been spoken. + */ + @Override + public void speak( + CharSequence text, + @Nullable Set earcons, + @Nullable Set haptics, + int queueMode, + int flags, + int utteranceGroup, + @Nullable Bundle speechParams, + @Nullable Bundle nonSpeechParams, + @Nullable UtteranceStartRunnable startAction, + @Nullable UtteranceRangeStartCallback rangeStartCallback, + @Nullable UtteranceCompleteRunnable completedAction, + @Nullable EventId eventId) { + + if (isMuteSpeech) { + LogUtils.v(TAG, "Voice feedback is off."); + return; + } + + if (TextUtils.isEmpty(text) + && (earcons == null || earcons.isEmpty()) + && (haptics == null || haptics.isEmpty())) { + // don't process request with empty feedback + if ((flags & FeedbackItem.FLAG_ADVANCE_CONTINUOUS_READING) != 0) { + tryNotifyFullScreenReaderCallback(); + } + return; + } + + text = replaceSpanByContentDescription(text); + final FeedbackItem pendingItem = + FeedbackProcessingUtils.generateFeedbackItemFromInput( + mContext, + text, + earcons, + haptics, + flags, + utteranceGroup, + speechParams, + nonSpeechParams, + eventId); + + makeSpeakablePunctuation(pendingItem); + speak(pendingItem, queueMode, startAction, rangeStartCallback, completedAction); + } + + private void makeSpeakablePunctuation(FeedbackItem item) { + if (!mUsePunctuation) { + return; + } + for (FeedbackFragment originalFragment : item.getFragments()) { + CharSequence sourceText = originalFragment.getText(); + if (TextUtils.isEmpty(sourceText)) { + continue; + } + SpannableStringBuilder builder = new SpannableStringBuilder(sourceText); + // An exception list to keep the range of each span within the source text. It could help to + // check any conflict spans for the scanned punctuation. + List> rangeList = new ArrayList<>(); + // Adding the exception cases of reading punctuation. + // 1. Identifier Span + // 2. TtsSpan + // 3. Other span which would conflict to the punctuation should be added into the list + /* Don't use java.util.stream.Stream in accessibility/utils until it's supported. + Stream.of( + ((Spanned) sourceText) + .getSpans(0, sourceText.length(), SpannableUtils.IdentifierSpan.class), + ((Spanned) sourceText).getSpans(0, sourceText.length(), TtsSpan.class)) + .flatMap(Stream::of) + .forEach( + s -> + rangeList.add( + new Range(builder.getSpanStart(s), builder.getSpanEnd(s)))); + */ + List spans = new ArrayList<>(); + Collections.addAll( + spans, + ((Spanned) sourceText) + .getSpans(0, sourceText.length(), SpannableUtils.IdentifierSpan.class)); + Collections.addAll( + spans, ((Spanned) sourceText).getSpans(0, sourceText.length(), TtsSpan.class)); + for (Object span : spans) { + rangeList.add(new Range(builder.getSpanStart(span), builder.getSpanEnd(span))); + } + + boolean fragmentChanged = false; + // Traverse the entire text and locate for each symbol, which has a punctuation name and is + // not conflict with existing exception list, then add a tts span with the punctuation name. + for (int i = 0; i < sourceText.length(); i++) { + char ch = sourceText.charAt(i); + @Nullable String cleanValue = SpeechCleanupUtils.characterToName(mContext, ch); + if (cleanValue != null) { + /* Don't use java.util.stream.Stream in accessibility/utils until it's supported. + int x = i; + Optional> match = + rangeList.stream().filter(range -> range.contains(x)).findFirst(); + if (!match.isPresent()) { + fragmentChanged = true; + builder.setSpan( + new TtsSpan.TextBuilder(cleanValue).build(), i, i + 1, SPAN_INCLUSIVE_EXCLUSIVE); + } + */ + fragmentChanged |= checkAndInsertSpanForPunctuation(builder, rangeList, i, cleanValue); + } + } + if (fragmentChanged) { + originalFragment.setText(builder); + } + } + } + + /** + * Verify if the location of punctuation has conflict to any exception spans. + * + * @return true if no conflict and adds a tts span for the mapped term. + */ + private boolean checkAndInsertSpanForPunctuation( + SpannableStringBuilder builder, + List> rangeList, + int index, + @Nullable String cleanValue) { + if (cleanValue == null) { + return false; + } + for (Range range : rangeList) { + if (range.contains(index)) { + return false; + } + } + builder.setSpan( + new TtsSpan.TextBuilder(cleanValue).build(), index, index + 1, SPAN_INCLUSIVE_EXCLUSIVE); + return true; + } + + /** Find and replace any ReplacementSpan, whose content description is not null, with TTS span. */ + private static CharSequence replaceSpanByContentDescription(CharSequence text) { + if (!FeatureSupport.supportContentDescriptionInReplacementSpan() + || TextUtils.isEmpty(text) + || !(text instanceof Spanned)) { + return text; + } + ReplacementSpan[] replacementSpans = + ((Spanned) text).getSpans(0, text.length(), ReplacementSpan.class); + if (replacementSpans == null || replacementSpans.length == 0) { + return text; + } + SpannableStringBuilder spannable = new SpannableStringBuilder(text); + for (ReplacementSpan span : replacementSpans) { + CharSequence replacementText = span.getContentDescription(); + if (replacementText == null) { + continue; + } + LogUtils.v(TAG, "Replace ReplacementSpan by content description: %s", replacementText); + int spanToReplaceStart = spannable.getSpanStart(span); + int spanToReplaceEnd = spannable.getSpanEnd(span); + int spanToReplaceFlags = spannable.getSpanFlags(span); + spannable.removeSpan(span); + spannable.setSpan( + new TtsSpan.TextBuilder(replacementText.toString()).build(), + spanToReplaceStart, + spanToReplaceEnd, + spanToReplaceFlags); + } + return spannable; + } + + private void speak( + FeedbackItem item, + int queueMode, + @Nullable UtteranceStartRunnable startAction, + @Nullable UtteranceRangeStartCallback rangeStartCallback, + @Nullable UtteranceCompleteRunnable completedAction) { + + // If this FeedbackItem is flagged as NO_SPEECH, ignore speech and + // immediately process earcons and haptics without disrupting the speech + // queue. + // TODO: Consider refactoring non-speech feedback out of + // this class entirely. + if (item.hasFlag(FeedbackItem.FLAG_NO_SPEECH)) { + for (FeedbackFragment fragment : item.getFragments()) { + playEarconsFromFragment(fragment, item.getEventId()); + playHapticsFromFragment(fragment, item.getEventId()); + } + if (item.hasFlag(FeedbackItem.FLAG_ADVANCE_CONTINUOUS_READING)) { + tryNotifyFullScreenReaderCallback(); + } + return; + } + + int[] expectedUtteranceId = new int[1]; + expectedUtteranceId[0] = -1; + if (item.hasFlag(FeedbackItem.FLAG_SKIP_DUPLICATE) + && hasItemOnQueueOrSpeaking(item, expectedUtteranceId)) { + Iterator iterator = mUtteranceCompleteActions.iterator(); + while (iterator.hasNext()) { + UtteranceCompleteAction utteranceCompleteAction = iterator.next(); + if (utteranceCompleteAction.utteranceIndex == peekNextUtteranceId()) { + if (expectedUtteranceId[0] != -1) { + utteranceCompleteAction.utteranceIndex = expectedUtteranceId[0]; + } else { + iterator.remove(); + } + } + } + return; + } + + item.setUninterruptible( + queueMode == QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH + || queueMode == QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH_CAN_IGNORE_INTERRUPTS); + item.setCanIgnoreInterrupts( + queueMode == QUEUE_MODE_CAN_IGNORE_INTERRUPTS + || queueMode == QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH_CAN_IGNORE_INTERRUPTS); + item.setStartAction(startAction); + item.setRangeStartCallback(rangeStartCallback); + item.setCompletedAction(completedAction); + + boolean currentFeedbackInterrupted = false; + if (shouldClearQueue(item, queueMode)) { + FeedbackItemFilter filter = getFeedbackItemFilter(item, queueMode); + // Call onUtteranceComplete on each queue item to be cleared. + ListIterator iterator = feedbackQueue.listIterator(0); + while (iterator.hasNext()) { + FeedbackItem currentItem = iterator.next(); + if (filter.accept(currentItem)) { + iterator.remove(); + notifyItemInterrupted(currentItem); + } + } + + if (mCurrentFeedbackItem != null && filter.accept(mCurrentFeedbackItem)) { + notifyItemInterrupted(mCurrentFeedbackItem); + currentFeedbackInterrupted = true; + } + } + + feedbackQueue.add(item); + if (mSpeechListener != null) { + mSpeechListener.onUtteranceQueued(item); + } + + // If TTS isn't ready, this should be the only item in the queue. + if (!mFailoverTts.isReady()) { + LogUtils.e(TAG, "Attempted to speak before TTS was initialized."); + return; + } + + if ((mCurrentFeedbackItem == null) || currentFeedbackInterrupted) { + currentFragmentIterator = null; + speakNextItem(); + } else { + LogUtils.v( + TAG, "Queued speech item, waiting for \"%s\"", mCurrentFeedbackItem.getUtteranceId()); + } + } + + private void tryNotifyFullScreenReaderCallback() { + if (mInjectFullScreenReadCallbacks && mFullScreenReadNextCallback != null) { + if (mShouldHandleTtsCallBackInMainThread) { + mHandler.post( + new Runnable() { + @Override + public void run() { + if (mFullScreenReadNextCallback != null) { + mFullScreenReadNextCallback.run(SpeechController.STATUS_NOT_SPOKEN); + } + } + }); + } else { + mFullScreenReadNextCallback.run(SpeechController.STATUS_NOT_SPOKEN); + } + } + } + + private boolean shouldClearQueue(FeedbackItem item, int queueMode) { + // QUEUE_MODE_INTERRUPT, QUEUE_MODE_FLUSH_ALL and QUEUE_MODE_CAN_IGNORE_INTERRUPTS will clear + // the queue. + if (queueMode != QUEUE_MODE_QUEUE + && queueMode != QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH + && queueMode != QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH_CAN_IGNORE_INTERRUPTS) { + return true; + } + + // If there is utterance group different from SpeechControllerImpl.UTTERANCE_GROUP_DEFAULT + // and flag FeedbackItem.FLAG_CLEAR_QUEUED_UTTERANCES_WITH_SAME_UTTERANCE_GROUP items + // from same UTTERANCE_GRPOUP would be cleared from queue + if (item.getUtteranceGroup() != UTTERANCE_GROUP_DEFAULT + && item.hasFlag(FeedbackItem.FLAG_CLEAR_QUEUED_UTTERANCES_WITH_SAME_UTTERANCE_GROUP)) { + return true; + } + + // If there is utterance group different from SpeechControllerImpl.UTTERANCE_GROUP_DEFAULT + // and flag FeedbackItem.FLAG_INTERRUPT_CURRENT_UTTERANCE_WITH_SAME_UTTERANCE_GROUP + // currently speaking item would be interrupted if it has the same utterance group + if (item.getUtteranceGroup() != UTTERANCE_GROUP_DEFAULT + && item.hasFlag(FeedbackItem.FLAG_INTERRUPT_CURRENT_UTTERANCE_WITH_SAME_UTTERANCE_GROUP)) { + return true; + } + + return false; + } + + private FeedbackItemFilter getFeedbackItemFilter(FeedbackItem item, int queueMode) { + FeedbackItemFilter filter = new FeedbackItemFilter(); + if (queueMode != QUEUE_MODE_QUEUE + && queueMode != QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH + && queueMode != QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH_CAN_IGNORE_INTERRUPTS) { + filter.addFeedbackItemPredicate(new FeedbackItemInterruptiblePredicate()); + } + + if (item.getUtteranceGroup() != UTTERANCE_GROUP_DEFAULT + && item.hasFlag(FeedbackItem.FLAG_CLEAR_QUEUED_UTTERANCES_WITH_SAME_UTTERANCE_GROUP)) { + FeedbackItemPredicate notCurrentItemPredicate = + new FeedbackItemEqualSamplePredicate(mCurrentFeedbackItem, false); + FeedbackItemPredicate sameUtteranceGroupPredicate = + new FeedbackItemUtteranceGroupPredicate(item.getUtteranceGroup()); + FeedbackItemPredicate clearQueuePredicate = + new FeedbackItemConjunctionPredicateSet( + notCurrentItemPredicate, sameUtteranceGroupPredicate); + filter.addFeedbackItemPredicate(clearQueuePredicate); + } + + if (item.getUtteranceGroup() != UTTERANCE_GROUP_DEFAULT + && item.hasFlag(FeedbackItem.FLAG_INTERRUPT_CURRENT_UTTERANCE_WITH_SAME_UTTERANCE_GROUP)) { + FeedbackItemPredicate currentItemPredicate = + new FeedbackItemEqualSamplePredicate(mCurrentFeedbackItem, true); + FeedbackItemPredicate sameUtteranceGroupPredicate = + new FeedbackItemUtteranceGroupPredicate(item.getUtteranceGroup()); + FeedbackItemPredicate clearQueuePredicate = + new FeedbackItemConjunctionPredicateSet( + currentItemPredicate, sameUtteranceGroupPredicate); + filter.addFeedbackItemPredicate(clearQueuePredicate); + } + + return filter; + } + + private void notifyItemInterrupted(FeedbackItem item) { + final UtteranceCompleteRunnable queuedItemCompletedAction = item.getCompletedAction(); + if (queuedItemCompletedAction != null) { + queuedItemCompletedAction.run(STATUS_INTERRUPTED); + } + } + + /** + * @param item : The item which is about to read. + * @param expectedUtteranceId : When duplicate happens, this will return the expected utterance + * index, or keep unchanged when the associated hint should be dropped. + * @return whether there is any speech item reading or pending to ready which is duplicate to the + * new item. + */ + private boolean hasItemOnQueueOrSpeaking(FeedbackItem item, int[] expectedUtteranceId) { + int accumulatedUtterance = + mCurrentFeedbackItem == null + ? peekNextUtteranceId() + : Integer.parseInt( + mCurrentFeedbackItem.getUtteranceId().substring(UTTERANCE_ID_PREFIX.length())); + if (item == null) { + return false; + } + + if (feedbackTextEquals(item, mCurrentFeedbackItem)) { + expectedUtteranceId[0] = accumulatedUtterance; + return true; + } + + for (FeedbackItem queuedItem : feedbackQueue) { + accumulatedUtterance++; + if (feedbackTextEquals(item, queuedItem)) { + expectedUtteranceId[0] = accumulatedUtterance; + return true; + } + } + + long currentTime = item.getCreationTime(); + for (FeedbackItem recentItem : mFeedbackHistory) { + if (currentTime - recentItem.getCreationTime() < SKIP_DUPLICATES_DELAY) { + if (feedbackTextEquals(item, recentItem)) { + return true; + } + } + } + + return false; + } + + /** + * Compares feedback fragments based on their text only. Ignores other parameters such as earcons + * and interruptibility. + */ + private boolean feedbackTextEquals(@Nullable FeedbackItem item1, @Nullable FeedbackItem item2) { + if (item1 == null || item2 == null) { + return false; + } + + List fragments1 = item1.getFragments(); + List fragments2 = item2.getFragments(); + + if (fragments1.size() != fragments2.size()) { + return false; + } + + int size = fragments1.size(); + for (int i = 0; i < size; i++) { + FeedbackFragment fragment1 = fragments1.get(i); + FeedbackFragment fragment2 = fragments2.get(i); + + if (fragment1 != null + && fragment2 != null + && !TextUtils.equals(fragment1.getText(), fragment2.getText())) { + return false; + } + + if ((fragment1 == null && fragment2 != null) || (fragment1 != null && fragment2 == null)) { + return false; + } + } + + return true; + } + + /** + * Add a new action that will be run before the given utterance index starts. + * + * @param index The index of the utterance that should starts after this action is executed. + * @param runnable The code to execute. + */ + @Override + public void addUtteranceStartAction(int index, UtteranceStartRunnable runnable) { + final UtteranceStartAction action = new UtteranceStartAction(index, runnable); + mUtteranceStartActions.add(action); + } + + @Override + public void setUtteranceRangeStartCallback( + int utteranceId, UtteranceRangeStartCallback callback) { + mUtteranceRangeStartCallbacks.put(utteranceId, callback); + } + + /** + * Add a new action that will be run when the given utterance index completes. + * + * @param index The index of the utterance that should finish before this action is executed. + * @param runnable The code to execute. + */ + @Override + public void addUtteranceCompleteAction(int index, UtteranceCompleteRunnable runnable) { + final UtteranceCompleteAction action = new UtteranceCompleteAction(index, runnable); + mUtteranceCompleteActions.add(action); + } + + /** + * Removes all instances of the specified runnable from the utterance complete action list. + * + * @param runnable The runnable to remove. + */ + public void removeUtteranceCompleteAction(UtteranceCompleteRunnable runnable) { + final Iterator i = mUtteranceCompleteActions.iterator(); + + while (i.hasNext()) { + final UtteranceCompleteAction action = i.next(); + if (action.runnable == runnable) { + i.remove(); + } + } + } + + @Override + public void interrupt(boolean stopTtsSpeechCompletely) { + interrupt(stopTtsSpeechCompletely, true /* notifyObserver */); + } + + @Override + public void interrupt(boolean stopTtsSpeechCompletely, boolean notifyObserver) { + interrupt( + stopTtsSpeechCompletely, notifyObserver, true /* interruptItemsThatCanIgnoreInterrupts */); + } + + @Override + public void interrupt( + boolean stopTtsSpeechCompletely, + boolean notifyObserver, + boolean interruptItemsThatCanIgnoreInterrupts) { + if (!interruptItemsThatCanIgnoreInterrupts) { + if (!clearCurrentAndQueuedUtterancesThatDontIgnoreInterrupts(notifyObserver)) { + // If there are speech items that are not removed from the speech queue, or the currently + // speaking speech should not be interrupted, then TTS should not be stopped. + return; + } + } else { + // Clear all current and queued utterances. + clearCurrentAndQueuedUtterances(notifyObserver); + } + + clearUtteranceRangeStartCallbacks(); + // Clear and post all remaining completion actions. + clearUtteranceCompletionActions(true); + + if (stopTtsSpeechCompletely) { + // Stop all TTS audios. + mFailoverTts.stopAll(); + } else { + // Stop TTS audio from TalkBack. + mFailoverTts.stopFromTalkBack(); + } + } + + /** Check the last request status and then stop or resume utterance. */ + public void pauseOrResumeUtterance() { + if (requestPause) { + resume(); + } else { + pause(); + } + } + + @Override + public void pause() { + long delta = SystemClock.uptimeMillis() - feedbackSavedTime; + // The savedFeedbackQueue was copied for the last interrupted speech content. If the delta + // time between this pause method invoked and the last speech interruption is too long, we can + // determine the saved feedback is outdated. + if (savedFeedbackQueue != null && !requestPause && delta <= SAVED_FEEDBACK_FOR_PAUSE_TIME) { + mCurrentFeedbackItem = null; + requestPause = true; + mFailoverTts.stopFromTalkBack(); + } + } + + @Override + public void resume() { + if (savedFeedbackQueue != null && requestPause) { + loadSavedFeedbackInfo(); + resetSavedFeedbackInfo(); + handleSpeechStarting(); + processNextFragmentInternal(); + } + requestPause = false; + } + + /** Stops speech and shuts down this controller. */ + public void shutdown() { + interrupt(false /* stopTtsSpeechCompletely */); + + mFailoverTts.shutdown(); + + setOverlayEnabled(false); + } + + /** Returns the next utterance identifier. */ + @Override + public int peekNextUtteranceId() { + return mNextUtteranceIndex; + } + + /** Returns the next utterance identifier and increments the utterance value. */ + private int getNextUtteranceId() { + return mNextUtteranceIndex++; + } + + public void setOverlayEnabled(boolean enabled) { + if (enabled && mTtsOverlay == null) { + mTtsOverlay = new TextToSpeechOverlay(mContext); + } else if (!enabled && mTtsOverlay != null) { + mTtsOverlay.hide(); + mTtsOverlay = null; + } + } + + /** + * Returns {@code true} if speech should be silenced. Does not prevent haptic or auditory feedback + * from occurring. The controller will run utterance completion actions immediately for silenced + * utterances. + * + *

Silences speech in the following cases if not forced feedback: + * + *

    + *
  • Speech recognition is active and the user is not using a headset + *
  • Mic is recording and the user is not using a headset + *
  • Audio is playing + *
  • Phone call is active + *
+ */ + @SuppressWarnings("deprecation") + private boolean shouldSilenceSpeech(FeedbackItem item) { + // TODO: remove the legacy flag when all items are updated with the new flags + if (item.hasFlag(FeedbackItem.FLAG_FORCE_FEEDBACK)) { + return false; + } + if (shouldSilentSpeech) { + LogUtils.v(TAG, "SilenceSpeechByFlag"); + return true; + } + if (shouldSilenceSpeechWhenSsbActive(item)) { + LogUtils.v(TAG, "SilenceSpeechWhenSsbActive"); + return true; + } + if (shouldSilenceSpeechWhenMicrophoneActive(item)) { + LogUtils.v(TAG, "SilenceSpeechWhenMicrophoneActive"); + return true; + } + if (shouldSilenceSpeechWhenAudioPlaybackActive(item)) { + LogUtils.v(TAG, "SilenceSpeechWhenAudioPlaybackActive"); + return true; + } + if (shouldSilenceSpeechWhenPhoneCallActive(item)) { + LogUtils.v(TAG, "SilenceSpeechWhenPhoneCallActive"); + return true; + } + return false; + } + + /** Returns {@code true} if speech should be silenced if audio is playing. */ + @SuppressWarnings("deprecation") + private boolean shouldSilenceSpeechWhenAudioPlaybackActive(FeedbackItem item) { + return !item.hasFlag(FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_AUDIO_PLAYBACK_ACTIVE) + && mDelegate.isAudioPlaybackActive(); + } + + /** Returns {@code true} if speech should be silenced if microphone is recording. */ + @SuppressWarnings("deprecation") + private boolean shouldSilenceSpeechWhenMicrophoneActive(FeedbackItem item) { + return !item.hasFlag(FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_MICROPHONE_ACTIVE) + && mDelegate.isMicrophoneActiveAndHeadphoneOff(); + } + + /** Returns {@code true} if speech should be silenced during speech recognition/dictation. */ + @SuppressWarnings("deprecation") + private boolean shouldSilenceSpeechWhenSsbActive(FeedbackItem item) { + return !item.hasFlag(FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_SSB_ACTIVE) + && mDelegate.isSsbActiveAndHeadphoneOff(); + } + + /** Returns {@code true} if speech should be silenced during phone call. */ + @SuppressWarnings("deprecation") + private boolean shouldSilenceSpeechWhenPhoneCallActive(FeedbackItem item) { + return !item.hasFlag(FeedbackItem.FLAG_FORCE_FEEDBACK_EVEN_IF_PHONE_CALL_ACTIVE) + && mDelegate.isPhoneCallActive(); + } + + /** + * Sends the specified item to the text-to-speech engine. Manages internal speech controller + * state. + * + *

This method should only be called by {@link #speakNextItem()}. + * + * @param item The item to speak. + */ + @SuppressLint("InlinedApi") + private void speakNextItemInternal(FeedbackItem item) { + final int utteranceIndex = getNextUtteranceId(); + final String utteranceId = UTTERANCE_ID_PREFIX + utteranceIndex; + item.setUtteranceId(utteranceId); + currentFragmentIterator.setFeedBackItemUtteranceId(utteranceId); + // Track latency from event received to feedback queued. + EventId eventId = item.getEventId(); + if (eventId != null && utteranceId != null) { + Performance.getInstance().onFeedbackQueued(eventId, utteranceId); + } + + final UtteranceStartRunnable startAction = item.getStartAction(); + if (startAction != null) { + addUtteranceStartAction(utteranceIndex, startAction); + } + + final UtteranceRangeStartCallback rangeStartCallback = item.getRangeStartCallback(); + if (rangeStartCallback != null) { + setUtteranceRangeStartCallback(utteranceIndex, rangeStartCallback); + } + + final UtteranceCompleteRunnable completedAction = item.getCompletedAction(); + if (completedAction != null) { + addUtteranceCompleteAction(utteranceIndex, completedAction); + } + + if (mInjectFullScreenReadCallbacks + && item.hasFlag(FeedbackItem.FLAG_ADVANCE_CONTINUOUS_READING)) { + addUtteranceCompleteAction(utteranceIndex, mFullScreenReadNextCallback); + } + + if ((item != null) && !item.hasFlag(FeedbackItem.FLAG_NO_HISTORY)) { + while (mFeedbackHistory.size() >= MAX_HISTORY_ITEMS) { + mFeedbackHistory.remove(mFeedbackHistory.peek()); + } + mFeedbackHistory.addLast(item); + } + + if (mSpeechListener != null) { + mSpeechListener.onUtteranceStarted(item); + } + + processNextFragmentInternal(); + } + + private boolean processNextFragmentInternal() { + if (currentFragmentIterator == null || !currentFragmentIterator.hasNext()) { + return false; + } + if (mCurrentFeedbackItem == null) { + // TODO: Probably due to asynchronous overlap of onFragmentCompleted() calling + // processNextFragmentInternal(), and clearCurrentAndQueuedUtterances() setting + // mCurrentFeedbackItem to null. + return false; + } + + FeedbackFragment fragment = currentFragmentIterator.next(); + EventId eventId = mCurrentFeedbackItem.getEventId(); + playEarconsFromFragment(fragment, eventId); + playHapticsFromFragment(fragment, eventId); + + // Reuse the global instance of speech parameters. + final HashMap params = mSpeechParametersMap; + params.clear(); + + // Add all custom speech parameters. + final Bundle speechParams = fragment.getSpeechParams(); + for (String key : speechParams.keySet()) { + params.put(key, String.valueOf(speechParams.get(key))); + } + + // Utterance ID, stream, and volume override item params. + params.put(Engine.KEY_PARAM_UTTERANCE_ID, mCurrentFeedbackItem.getUtteranceId()); + params.put(Engine.KEY_PARAM_STREAM, String.valueOf(DEFAULT_STREAM)); + params.put(Engine.KEY_PARAM_VOLUME, String.valueOf(mSpeechVolume)); + + float pitch = + mSpeechPitch * (mUseIntonation ? parseFloatParam(params, SpeechParam.PITCH, 1) : 1); + final float rate = + mSpeechRate * (mUseIntonation ? parseFloatParam(params, SpeechParam.RATE, 1) : 1); + CharSequence text; + + final boolean shouldSilenceFragment = shouldSilenceSpeech(mCurrentFeedbackItem); + if (shouldSilenceFragment || TextUtils.isEmpty(fragment.getText())) { + text = null; + } else { + text = fragment.getText(); + } + final Locale locale = fragment.getLocale(); + + final boolean preventDeviceSleep = + mCurrentFeedbackItem.hasFlag(FeedbackItem.FLAG_NO_DEVICE_SLEEP); + + // for capital letter + if (text != null && text.length() == 1 && Character.isUpperCase(text.charAt(0))) { + switch (capLetterFeedback) { + case CAPITAL_LETTERS_TYPE_SPEAK_CAP: + // To resolve the limitation of [length == 1]. "Say capital" is handled by compositor + // variable. + break; + case CAPITAL_LETTERS_TYPE_PITCH: + pitch = min(pitch * CAPITAL_LETTER_PITCH_RATE, CAPITAL_LETTER_PITCH_RATE_UPPER_BOUND); + break; + case CAPITAL_LETTERS_TYPE_SOUND_FEEDBACK: + // TODO: The raw resource of sound feedback is required for capital letter. + mFeedbackController.playAuditory(R.raw.window_state, eventId); + break; + default: // fall out + } + } + + final String logText = (text == null) ? null : String.format("\"%s\"", text.toString()); + LogUtils.v( + TAG, + "Speaking fragment text %s with spans %s for event %s", + logText, + SpannableUtils.spansToStringForLogging(text), + eventId); + + if (text != null && mCurrentFeedbackItem.hasFlag(FeedbackItem.FLAG_FORCE_FEEDBACK)) { + mDelegate.onSpeakingForcedFeedback(); + } + + sourceIsVolumeControl = mCurrentFeedbackItem.hasFlag(FLAG_SOURCE_IS_VOLUME_CONTROL); + // It's okay if the utterance is empty, the fail-over TTS will + // immediately call the fragment completion listener. This process is + // important for things like continuous reading. + mFailoverTts.speak( + text, locale, pitch, rate, params, DEFAULT_STREAM, mSpeechVolume, preventDeviceSleep); + + if (mTtsOverlay != null) { + mTtsOverlay.displayText(text); + } + + return true; + } + + /** + * Plays all earcons stored in a {@link FeedbackFragment}. + * + * @param fragment The fragment to process + */ + private void playEarconsFromFragment(FeedbackFragment fragment, @Nullable EventId eventId) { + final Bundle nonSpeechParams = fragment.getNonSpeechParams(); + final float earconRate = nonSpeechParams.getFloat(Utterance.KEY_METADATA_EARCON_RATE, 1.0f); + final float earconVolume = nonSpeechParams.getFloat(Utterance.KEY_METADATA_EARCON_VOLUME, 1.0f); + + if (mFeedbackController != null) { + for (int keyResId : fragment.getEarcons()) { + mFeedbackController.playAuditory(keyResId, earconRate, earconVolume, eventId); + } + } + } + + /** + * Produces all haptic feedback stored in a {@link FeedbackFragment}. + * + * @param fragment The fragment to process + */ + private void playHapticsFromFragment(FeedbackFragment fragment, @Nullable EventId eventId) { + if (mFeedbackController != null) { + for (int keyResId : fragment.getHaptics()) { + mFeedbackController.playHaptic(keyResId, eventId); + } + } + } + + /** @return The utterance ID, or -1 if the ID is invalid. */ + private static int parseUtteranceId(String utteranceId) { + // Check for bad utterance ID. This should never happen. + if (!utteranceId.startsWith(UTTERANCE_ID_PREFIX)) { + LogUtils.e(TAG, "Bad utterance ID: %s", utteranceId); + return -1; + } + + try { + return Integer.parseInt(utteranceId.substring(UTTERANCE_ID_PREFIX.length())); + } catch (NumberFormatException e) { + e.printStackTrace(); + return -1; + } + } + + /** + * Called when transitioning from an idle state to a speaking state, e.g. the queue was empty, + * there was no current speech, a speech item was added to the queue, and {@link #resume()} is + * called when status is {@link #STATUS_PAUSE} . + * + * @see #handleSpeechCompleted(int status) + */ + @TargetApi(Build.VERSION_CODES.N) + private void handleSpeechStarting() { + for (SpeechController.Observer observer : mObservers) { + observer.onSpeechStarting(); + } + + boolean useAudioFocus = mUseAudioFocus; + if (BuildVersionUtils.isAtLeastN()) { + List recordConfigurations = + mAudioManager.getActiveRecordingConfigurations(); + if (recordConfigurations.size() != 0) { + useAudioFocus = false; + } + } + + if (useAudioFocus) { + if (BuildVersionUtils.isAtLeastO()) { + mAudioManager.requestAudioFocus(mAudioFocusRequest); + } else { + mAudioManager.requestAudioFocus( + mAudioFocusListener, DEFAULT_STREAM, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK); + } + } + + if (mIsSpeaking) { + LogUtils.e(TAG, "Started speech while already speaking!"); + } + + mIsSpeaking = true; + } + + /** + * Called when transitioning from a speaking state to an idle/pause state, e.g. all queued + * utterances have been spoken, the last utterance has completed and {@link #pause()} is called + * when {@link #isSpeaking()} is {@code true} + * + * @see #handleSpeechStarting() + */ + private void handleSpeechCompleted(int status) { + for (SpeechController.Observer observer : mObservers) { + if (status == STATUS_PAUSE) { + observer.onSpeechPaused(); + + } else if (status != STATUS_ERROR_DONT_NOTIFY_OBSERVER) { + observer.onSpeechCompleted(); + } + } + + if (mUseAudioFocus) { + if (BuildVersionUtils.isAtLeastO()) { + mAudioManager.abandonAudioFocusRequest(mAudioFocusRequest); + } else { + mAudioManager.abandonAudioFocus(mAudioFocusListener); + } + } + + if (!mIsSpeaking) { + LogUtils.e(TAG, "Completed speech while already completed!"); + } + + mIsSpeaking = false; + } + + /** + * Clears the speech queue and completes the current speech item, if any. + * + * @param notifyObserver Whether to notify the observer about the completion of the current {@link + * FeedbackItem} + */ + private void clearCurrentAndQueuedUtterances(boolean notifyObserver) { + // We should save some of the feedback information to provide Pause / Resume feedback. + // Because clearCurrentAndQueuedUtterances() clears all feedback + // before recognizing the single tap with two fingers gesture. + saveCurrentFeedbackInfo(); + + feedbackQueue.clear(); + currentFragmentIterator = null; + + if (mCurrentFeedbackItem != null) { + final String utteranceId = mCurrentFeedbackItem.getUtteranceId(); + onFragmentCompleted(utteranceId, false /* success */, true /* advance */, notifyObserver); + mCurrentFeedbackItem = null; + } + } + + /** + * Removes speech items from the speech queue and completes the current speech item if the speech + * items are in queue modes other than QUEUE_MODE_CAN_IGNORE_INTERRUPTS or + * QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH_CAN_IGNORE_INTERRUPTS. + * + * @param notifyObserver Whether to notify the observer about the completion of the current {@link + * FeedbackItem} + * @return {@code true} if speech has been cleared from the speech queue and the currently + * speaking speech should be interrupted + */ + private boolean clearCurrentAndQueuedUtterancesThatDontIgnoreInterrupts(boolean notifyObserver) { + ListIterator iterator = feedbackQueue.listIterator(0); + while (iterator.hasNext()) { + FeedbackItem currentItem = iterator.next(); + if (!currentItem.canIgnoreInterrupts()) { + iterator.remove(); + notifyItemInterrupted(currentItem); + } + } + + boolean currentFeedbackInterrupted = false; + if (mCurrentFeedbackItem != null) { + if (!mCurrentFeedbackItem.canIgnoreInterrupts()) { + notifyItemInterrupted(mCurrentFeedbackItem); + currentFeedbackInterrupted = true; + } + } + + if ((mCurrentFeedbackItem == null) || currentFeedbackInterrupted) { + currentFragmentIterator = null; + if (!feedbackQueue.isEmpty()) { + speakNextItem(); + } else if (mCurrentFeedbackItem != null) { + final String utteranceId = mCurrentFeedbackItem.getUtteranceId(); + onFragmentCompleted(utteranceId, false /* success */, true /* advance */, notifyObserver); + mCurrentFeedbackItem = null; + return true; + } + } else { + LogUtils.v( + TAG, "Queued speech item, waiting for \"%s\"", mCurrentFeedbackItem.getUtteranceId()); + } + return false; + } + + private void clearUtteranceRangeStartCallbacks() { + mUtteranceRangeStartCallbacks.clear(); + } + + /** + * Clears (and optionally posts) all pending completion actions. + * + * @param execute {@code true} to post actions to the handler. + */ + private void clearUtteranceCompletionActions(boolean execute) { + if (!execute) { + mUtteranceCompleteActions.clear(); + return; + } + + UtteranceCompleteAction action; + while ((action = mUtteranceCompleteActions.poll()) != null) { + UtteranceCompleteRunnable runnable = action.runnable; + if (runnable != null) { + runUtteranceCompleteRunnable(runnable, STATUS_INTERRUPTED); + } + } + + // Don't call handleSpeechCompleted(int status), it will be called by the TTS when + // it stops the current current utterance. + } + + /** Save the current feedback and the contents of the feedback queue. */ + @SuppressWarnings({"unchecked"}) + private void saveCurrentFeedbackInfo() { + if (!requestPause) { + feedbackSavedTime = SystemClock.uptimeMillis(); + savedFeedbackQueue = (ArrayList) feedbackQueue.clone(); + savedFeedbackItem = mCurrentFeedbackItem; + savedFragmentIterator = + currentFragmentIterator != null ? currentFragmentIterator.deepCopy() : null; + } + } + + /** Load the saved feedback and the contents of the feedback queue. */ + private void loadSavedFeedbackInfo() { + if (savedFeedbackQueue == null || savedFragmentIterator == null) { + return; + } + feedbackQueue = savedFeedbackQueue; + savedFeedbackQueue = null; + mCurrentFeedbackItem = savedFeedbackItem; + currentFragmentIterator = savedFragmentIterator; + savedFragmentIterator = null; + } + + /** Reset the saved feedback and the contents of the feedback queue. */ + private void resetSavedFeedbackInfo() { + requestPause = false; + savedFragmentIterator = null; + savedFeedbackItem = null; + if (savedFeedbackQueue != null) { + savedFeedbackQueue.clear(); + } + } + + private void onFragmentStarted(String utteranceId) { + final int utteranceIndex = SpeechControllerImpl.parseUtteranceId(utteranceId); + onUtteranceStarted(utteranceIndex); + } + + private void onFragmentRangeStarted(String utteranceId, int start, int end) { + int offset = 0; + if (currentFragmentIterator != null) { + currentFragmentIterator.onFragmentRangeStarted(utteranceId, start, end); + offset = currentFragmentIterator.getFeedbackItemOffset(); + } + final int utteranceIndex = SpeechControllerImpl.parseUtteranceId(utteranceId); + onUtteranceRangeStarted(utteranceIndex, start + offset, end + offset); + } + + /** + * Handles completion of a {@link FeedbackFragment}. + * + *

+ * + * @param utteranceId The ID of the {@link FeedbackItem} the fragment belongs to. + * @param success Whether the fragment was spoken successfully. + * @param advance Whether to advance to the next queue item. + * @param notifyObserver Whether to notify the Observer about the completion of the {@link + * FeedbackFragment}. This parameter is ignored if the corresponding {@link FeedbackFragment} + * completed successfully, or it was interrupted by another {@link FeedbackFragment}. + */ + private void onFragmentCompleted( + String utteranceId, boolean success, boolean advance, boolean notifyObserver) { + if (currentFragmentIterator != null) { + currentFragmentIterator.onFragmentCompleted(utteranceId, success); + } + + final int utteranceIndex = SpeechControllerImpl.parseUtteranceId(utteranceId); + final boolean interrupted = + (mCurrentFeedbackItem != null) + && (!mCurrentFeedbackItem.getUtteranceId().equals(utteranceId)); + + final int status; + if (interrupted) { + status = STATUS_INTERRUPTED; + } else if (requestPause) { + status = STATUS_PAUSE; + } else if (success) { + status = STATUS_SPOKEN; + } else if (!notifyObserver) { + status = STATUS_ERROR_DONT_NOTIFY_OBSERVER; + } else { + status = STATUS_ERROR; + } + + // Process the next fragment for this FeedbackItem if applicable. + if ((status != STATUS_SPOKEN) || !processNextFragmentInternal()) { + // If speaking resulted in an error, was ultimately interrupted, or + // there are no additional fragments to speak as part of the current + // FeedbackItem, finish processing of this utterance. + onUtteranceCompleted(utteranceIndex, status, interrupted, advance); + } + } + + /** + * Handles the start of an {@link Utterance}/{@link FeedbackItem}. + * + * @param utteranceIndex The ID of the utterance that starts. + */ + private void onUtteranceStarted(int utteranceIndex) { + UtteranceStartAction action; + while (((action = mUtteranceStartActions.peek()) != null) + && (action.utteranceIndex <= utteranceIndex)) { + mUtteranceStartActions.remove(action); + final UtteranceStartRunnable runnable = action.runnable; + if (runnable != null) { + if (mShouldHandleTtsCallBackInMainThread) { + mHandler.post( + new Runnable() { + @Override + public void run() { + runnable.run(); + } + }); + } else { + runnable.run(); + } + } + } + } + + private void onUtteranceRangeStarted(int utteranceIndex, final int start, final int end) { + final UtteranceRangeStartCallback callback = mUtteranceRangeStartCallbacks.get(utteranceIndex); + if (callback != null) { + if (mShouldHandleTtsCallBackInMainThread) { + mHandler.post( + new Runnable() { + @Override + public void run() { + callback.onUtteranceRangeStarted(start, end); + } + }); + } else { + callback.onUtteranceRangeStarted(start, end); + } + } + } + + /** + * Handles the completion of an {@link Utterance}/{@link FeedbackItem}. + * + * @param utteranceIndex The ID of the utterance that has completed. + * @param status One of {@link SpeechControllerImpl#STATUS_ERROR}, {@link + * SpeechControllerImpl#STATUS_INTERRUPTED}, or {@link SpeechControllerImpl#STATUS_SPOKEN} + * @param interrupted {@code true} if the utterance was interrupted, {@code false} otherwise + * @param advance Whether to advance to the next queue item. + */ + private void onUtteranceCompleted( + int utteranceIndex, int status, boolean interrupted, boolean advance) { + sourceIsVolumeControl = false; + UtteranceCompleteAction action; + while (((action = mUtteranceCompleteActions.peek()) != null) + && (action.utteranceIndex <= utteranceIndex)) { + mUtteranceCompleteActions.remove(action); + UtteranceCompleteRunnable runnable = action.runnable; + if (runnable != null) { + runUtteranceCompleteRunnable(runnable, status); + } + } + + mUtteranceRangeStartCallbacks.remove(utteranceIndex); + + if (mSpeechListener != null) { + mSpeechListener.onUtteranceCompleted(utteranceIndex, status); + } + + if (interrupted) { + // We finished an utterance, but we weren't expecting to see a + // completion. This means we interrupted a previous utterance and + // can safely ignore this callback. + LogUtils.v( + TAG, "Interrupted %d with %s", utteranceIndex, mCurrentFeedbackItem.getUtteranceId()); + return; + } + + if (advance && !speakNextItem()) { + handleSpeechCompleted(status); + } + } + + private void onTtsInitialized(boolean wasSwitchingEngines) { + // The previous engine may not have shut down correctly, so make sure to + // clear the "current" speech item. + if (mCurrentFeedbackItem != null) { + onFragmentCompleted( + mCurrentFeedbackItem.getUtteranceId(), + false /* success */, + false /* advance */, + true /* notifyObserver */); + mCurrentFeedbackItem = null; + } + + if (wasSwitchingEngines && ttsChangeAnnouncementEnabled && !mSkipNextTTSChangeAnnouncement) { + speakCurrentEngine(); + } else if (!feedbackQueue.isEmpty()) { + speakNextItem(); + } + mSkipNextTTSChangeAnnouncement = false; + } + + private void runUtteranceCompleteRunnable( + @NonNull UtteranceCompleteRunnable runnable, int status) { + CompletionRunner runner = new CompletionRunner(runnable, status); + if (mShouldHandleTtsCallBackInMainThread) { + mHandler.post(runner); + } else { + runner.run(); + } + } + + /** + * Removes and speaks the next {@link FeedbackItem} in the queue, interrupting the current + * utterance if necessary. + * + * @return {@code false} if there are no more queued speech items. + */ + private boolean speakNextItem() { + final FeedbackItem previousItem = mCurrentFeedbackItem; + final FeedbackItem nextItem = (feedbackQueue.isEmpty() ? null : feedbackQueue.remove(0)); + + mCurrentFeedbackItem = nextItem; + + if (nextItem == null) { + LogUtils.v(TAG, "No next item, stopping speech queue"); + return false; + } + + if (previousItem == null) { + handleSpeechStarting(); + } + + currentFragmentIterator = new FeedbackFragmentsIterator(nextItem.getFragments().iterator()); + speakNextItemInternal(nextItem); + return true; + } + + /** + * Attempts to parse a float value from a {@link HashMap} of strings. + * + * @param params The map to obtain the value from. + * @param key The key that the value is assigned to. + * @param defaultValue The default value. + * @return The parsed float value, or the default value on failure. + */ + private static float parseFloatParam( + HashMap params, String key, float defaultValue) { + final String value = params.get(key); + + if (value == null) { + return defaultValue; + } + + try { + return Float.parseFloat(value); + } catch (NumberFormatException e) { + LogUtils.e(TAG, "value '%s' is not a string", value); + } + + return defaultValue; + } + + private final Handler mHandler = new Handler(); + + private final AudioManager.OnAudioFocusChangeListener mAudioFocusListener = + new AudioManager.OnAudioFocusChangeListener() { + @Override + public void onAudioFocusChange(int focusChange) { + LogUtils.d(TAG, "Saw audio focus change: %d", focusChange); + } + }; + + private final @Nullable AudioFocusRequest mAudioFocusRequest = + BuildVersionUtils.isAtLeastO() + ? new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setOnAudioFocusChangeListener(mAudioFocusListener, mHandler) + .setAudioAttributes( + new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) + .build()) + .build() + : null; + + /** An action that should be performed before a particular utterance index starts. */ + private static class UtteranceStartAction implements Comparable { + public UtteranceStartAction(int utteranceIndex, UtteranceStartRunnable runnable) { + this.utteranceIndex = utteranceIndex; + this.runnable = runnable; + } + + /** The maximum utterance index that can be spoken before this action should be performed. */ + public int utteranceIndex; + + /** The action to execute. */ + public UtteranceStartRunnable runnable; + + @Override + public int compareTo(@NonNull UtteranceStartAction another) { + return (utteranceIndex - another.utteranceIndex); + } + } + + /** An action that should be performed after a particular utterance index completes. */ + private static class UtteranceCompleteAction implements Comparable { + public UtteranceCompleteAction(int utteranceIndex, UtteranceCompleteRunnable runnable) { + this.utteranceIndex = utteranceIndex; + this.runnable = runnable; + } + + /** The minimum utterance index that must complete before this action should be performed. */ + public int utteranceIndex; + + /** The action to execute. */ + public UtteranceCompleteRunnable runnable; + + @Override + public int compareTo(@NonNull UtteranceCompleteAction another) { + return (utteranceIndex - another.utteranceIndex); + } + } + + private interface FeedbackItemPredicate { + public boolean accept(FeedbackItem item); + } + + private static class FeedbackItemDisjunctionPredicateSet implements FeedbackItemPredicate { + private FeedbackItemPredicate mPredicate1; + private FeedbackItemPredicate mPredicate2; + + public FeedbackItemDisjunctionPredicateSet( + FeedbackItemPredicate predicate1, FeedbackItemPredicate predicate2) { + mPredicate1 = predicate1; + mPredicate2 = predicate2; + } + + @Override + public boolean accept(FeedbackItem item) { + return mPredicate1.accept(item) || mPredicate2.accept(item); + } + } + + private static class FeedbackItemConjunctionPredicateSet implements FeedbackItemPredicate { + private FeedbackItemPredicate mPredicate1; + private FeedbackItemPredicate mPredicate2; + + public FeedbackItemConjunctionPredicateSet( + FeedbackItemPredicate predicate1, FeedbackItemPredicate predicate2) { + mPredicate1 = predicate1; + mPredicate2 = predicate2; + } + + @Override + public boolean accept(FeedbackItem item) { + return mPredicate1.accept(item) && mPredicate2.accept(item); + } + } + + private static class FeedbackItemInterruptiblePredicate implements FeedbackItemPredicate { + @Override + public boolean accept(FeedbackItem item) { + if (item == null) { + return false; + } + + return item.isInterruptible(); + } + } + + private static class FeedbackItemEqualSamplePredicate implements FeedbackItemPredicate { + + private final @Nullable FeedbackItem mSample; + private final boolean mEqual; + + public FeedbackItemEqualSamplePredicate(@Nullable FeedbackItem sample, boolean equal) { + mSample = sample; + mEqual = equal; + } + + @Override + public boolean accept(FeedbackItem item) { + if (mEqual) { + return mSample == item; + } + + return mSample != item; + } + } + + private static class FeedbackItemUtteranceGroupPredicate implements FeedbackItemPredicate { + + private int mUtteranceGroup; + + public FeedbackItemUtteranceGroupPredicate(int utteranceGroup) { + mUtteranceGroup = utteranceGroup; + } + + @Override + public boolean accept(FeedbackItem item) { + if (item == null) { + return false; + } + + return item.getUtteranceGroup() == mUtteranceGroup; + } + } + + private static class FeedbackItemFilter { + + private FeedbackItemPredicate mPredicate; + + public void addFeedbackItemPredicate(FeedbackItemPredicate predicate) { + if (predicate == null) { + return; + } + + if (mPredicate == null) { + mPredicate = predicate; + } else { + mPredicate = new FeedbackItemDisjunctionPredicateSet(mPredicate, predicate); + } + } + + public boolean accept(FeedbackItem item) { + return mPredicate != null && mPredicate.accept(item); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/TextToSpeechOverlay.java b/utils/src/main/java/com/google/android/accessibility/utils/output/TextToSpeechOverlay.java new file mode 100644 index 0000000..37cdff9 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/TextToSpeechOverlay.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2012 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.google.android.accessibility.utils.output; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PixelFormat; +import android.os.Message; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.WindowManager.BadTokenException; +import android.widget.FrameLayout; +import android.widget.TextView; +import com.google.android.accessibility.utils.R; +import com.google.android.accessibility.utils.WeakReferenceHandler; +import com.google.android.accessibility.utils.widget.DialogUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.android.libraries.accessibility.widgets.simple.SimpleOverlay; + +/** + * Displays text on the screen. The class currently is used by {@link SpeechControllerImpl} and + * AccessibilityMenuService. + */ +public class TextToSpeechOverlay extends SimpleOverlay { + + private static final String LOG_TAG = "TextToSpeechOverlay"; + private static final int DISPLAY_MS = 2000; + private static final int MSG_SET_TEXT = 1; + private static final int MSG_CLEAR_TEXT = 2; + private final OverlayHandler handler = new OverlayHandler(this); + private final TextView text; + + public TextToSpeechOverlay(Context context) { + this(context, /* id= */ 0, /* sendsAccessibilityEvents= */ false); + } + + public TextToSpeechOverlay(Context context, int id, final boolean sendsAccessibilityEvents) { + super(context, id, sendsAccessibilityEvents); + + final WindowManager.LayoutParams params = getParams(); + params.type = DialogUtils.getDialogType(); + params.format = PixelFormat.TRANSPARENT; + params.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; + params.flags |= WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE; + params.width = WindowManager.LayoutParams.WRAP_CONTENT; + params.height = WindowManager.LayoutParams.WRAP_CONTENT; + params.gravity = Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM; + setParams(params); + + int padding = context.getResources().getDimensionPixelSize(R.dimen.tts_overlay_text_padding); + int bottomMargin = + context.getResources().getDimensionPixelSize(R.dimen.tts_overlay_text_bottom_margin); + + text = new TextView(context); + text.setBackgroundColor(0xAA000000); + text.setTextColor(Color.WHITE); + text.setPadding(padding, padding, padding, padding); + text.setGravity(Gravity.CENTER); + + FrameLayout layout = new FrameLayout(context); + FrameLayout.LayoutParams layoutParams = + new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + layoutParams.setMargins(0, 0, 0, bottomMargin); + layout.addView(text, layoutParams); + + setContentView(layout); + } + + public void displayText(CharSequence text) { + if (TextUtils.isEmpty(text)) { + handler.sendEmptyMessage(MSG_CLEAR_TEXT); + return; + } + final long displayTimeMs = Math.max(DISPLAY_MS, text.length() * 100); + handler.removeMessages(MSG_CLEAR_TEXT); + handler.sendMessage(Message.obtain(handler, MSG_SET_TEXT, text.toString().trim())); + handler.sendEmptyMessageDelayed(MSG_CLEAR_TEXT, displayTimeMs); + } + + private static class OverlayHandler extends WeakReferenceHandler { + public OverlayHandler(TextToSpeechOverlay parent) { + super(parent); + } + + @Override + protected void handleMessage(Message msg, TextToSpeechOverlay parent) { + switch (msg.what) { + case MSG_SET_TEXT: + try { + parent.show(); + } catch (BadTokenException e) { + LogUtils.e(LOG_TAG, e, "Caught WindowManager.BadTokenException while displaying text."); + } + parent.text.setText((CharSequence) msg.obj); + break; + case MSG_CLEAR_TEXT: + parent.text.setText(""); + parent.hide(); + break; + default: // fall out + } + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/TextToSpeechUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/output/TextToSpeechUtils.java new file mode 100644 index 0000000..8441d51 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/TextToSpeechUtils.java @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.output; + +import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ServiceInfo; +import android.provider.Settings.Secure; +import android.speech.tts.TextToSpeech; +import android.text.TextUtils; +import com.google.android.accessibility.utils.compat.provider.SettingsCompatUtils; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +class TextToSpeechUtils { + + /** + * Reloads the list of installed TTS engines. + * + * @param pm The package manager. + * @param results The list to populate with installed TTS engines. + * @return The package for the system default TTS. + */ + @SuppressLint("WrongConstant") // Allow queryIntentServices() with GET_SERVICES + public static @Nullable String reloadInstalledTtsEngines( + PackageManager pm, List results) { + final Intent intent = new Intent(TextToSpeech.Engine.INTENT_ACTION_TTS_SERVICE); + // The following call to queryIntentServices() with GET_SERVICES is possible due to + // QUERY_ALL_PACKAGES being present in the AndroidManifest.xml file. + final List resolveInfos = + pm.queryIntentServices(intent, PackageManager.GET_SERVICES); + + String systemTtsEngine = null; + + for (ResolveInfo resolveInfo : resolveInfos) { + final ServiceInfo serviceInfo = resolveInfo.serviceInfo; + final ApplicationInfo appInfo = serviceInfo.applicationInfo; + final String packageName = serviceInfo.packageName; + final boolean isSystemApp = ((appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0); + + results.add(serviceInfo.packageName); + + if (isSystemApp) { + systemTtsEngine = packageName; + } + } + + return systemTtsEngine; + } + + /** + * Attempts to shutdown the specified TTS engine, ignoring any errors. + * + * @param tts The TTS engine to shutdown. + */ + static void attemptTtsShutdown(TextToSpeech tts) { + try { + tts.shutdown(); + } catch (Exception e) { + // Don't care, we're shutting down. + } + } + + /** + * Returns the localized name of the TTS engine with the specified package name. + * + * @param context The parent context. + * @param enginePackage The package name of the TTS engine. + * @return The localized name of the TTS engine. + */ + static @Nullable CharSequence getLabelForEngine(Context context, String enginePackage) { + if (enginePackage == null) { + return null; + } + + final PackageManager pm = context.getPackageManager(); + final Intent intent = new Intent(TextToSpeech.Engine.INTENT_ACTION_TTS_SERVICE); + intent.setPackage(enginePackage); + + final List resolveInfos = + pm.queryIntentServices(intent, PackageManager.MATCH_DEFAULT_ONLY); + + if ((resolveInfos == null) || resolveInfos.isEmpty()) { + return null; + } + + final ResolveInfo resolveInfo = resolveInfos.get(0); + final ServiceInfo serviceInfo = resolveInfo.serviceInfo; + + if (serviceInfo == null) { + return null; + } + + return serviceInfo.loadLabel(pm); + } + + static @Nullable String getDefaultLocaleForEngine(ContentResolver cr, String engineName) { + final String defaultLocales = + Secure.getString(cr, SettingsCompatUtils.SecureCompatUtils.TTS_DEFAULT_LOCALE); + return parseEnginePrefFromList(defaultLocales, engineName); + } + + /** + * Parses a comma separated list of engine locale preferences. The list is of the form {@code + * "engine_name_1:locale_1,engine_name_2:locale2"} and so on and so forth. Returns null if the + * list is empty, malformed or if there is no engine specific preference in the list. + */ + private static @Nullable String parseEnginePrefFromList(String prefValue, String engineName) { + if (TextUtils.isEmpty(prefValue)) { + return null; + } + + String[] prefValues = prefValue.split(","); + + for (String value : prefValues) { + final int delimiter = value.indexOf(':'); + if (delimiter > 0) { + if (engineName.equals(value.substring(0, delimiter))) { + return value.substring(delimiter + 1); + } + } + } + + return null; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/Utterance.java b/utils/src/main/java/com/google/android/accessibility/utils/output/Utterance.java new file mode 100644 index 0000000..019a94a --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/Utterance.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2009 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.google.android.accessibility.utils.output; + +import android.os.Bundle; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * This class represents an utterance composed of text to be spoken and meta data about how this + * text to be spoken. + */ +public class Utterance { + + /** Key for obtaining the queuing meta-data property. */ + public static final String KEY_METADATA_QUEUING = "queuing"; + + /** Key for specifying utterance group */ + public static final String KEY_UTTERANCE_GROUP = "utterance_group"; + + /** Key for obtaining the earcon rate meta-data property. */ + public static final String KEY_METADATA_EARCON_RATE = "earcon_rate"; + + /** Key for obtaining the earcon volume meta-data property. */ + public static final String KEY_METADATA_EARCON_VOLUME = "earcon_volume"; + + /** Key for obtaining the speech parameters meta-data property. Must contain a {@link Bundle}. */ + public static final String KEY_METADATA_SPEECH_PARAMS = "speech_params"; + + /** Key for obtaining the speech flags meta-data property. */ + public static final String KEY_METADATA_SPEECH_FLAGS = "speech_flags"; + + /** Meta-data of how the utterance should be spoken. */ + private final Bundle mMetadata = new Bundle(); + + /** The list of text to speak. */ + private final List mSpokenFeedback = new ArrayList<>(); + + /** The list of auditory feedback identifiers to play. */ + private final Set mAuditoryFeedback = new HashSet<>(); + + /** The list of haptic feedback identifiers to play. */ + private final Set mHapticFeedback = new HashSet<>(); + + public Utterance() {} + + /** + * Adds spoken feedback to this utterance. + * + * @param text The text to speak. + */ + public void addSpoken(CharSequence text) { + mSpokenFeedback.add(text); + } + + /** + * Adds a spoken feedback flag to this utterance's metadata. + * + * @param flag The flag to add. One of: + *

    + *
  • {@link FeedbackItem#FLAG_FORCE_FEEDBACK} + *
  • {@link FeedbackItem#FLAG_NO_HISTORY} + *
  • {@link FeedbackItem#FLAG_ADVANCE_CONTINUOUS_READING} + *
  • {@link FeedbackItem#FLAG_CLEAR_QUEUED_UTTERANCES_WITH_SAME_UTTERANCE_GROUP} + *
  • {@link FeedbackItem#FLAG_INTERRUPT_CURRENT_UTTERANCE_WITH_SAME_UTTERANCE_GROUP} + *
+ */ + public void addSpokenFlag(int flag) { + final int flags = mMetadata.getInt(KEY_METADATA_SPEECH_FLAGS, 0); + mMetadata.putInt(KEY_METADATA_SPEECH_FLAGS, flags | flag); + } + + /** + * Adds auditory feedback to this utterance. + * + * @param id The value associated with the auditory feedback to play. + */ + public void addAuditory(int id) { + mAuditoryFeedback.add(id); + } + + /** + * Adds auditory feedback to this utterance. + * + * @param ids A collection of identifiers associated with the auditory feedback to play. + */ + public void addAllAuditory(Collection ids) { + mAuditoryFeedback.addAll(ids); + } + + /** + * Adds haptic feedback to this utterance. + * + * @param id The value associated with the haptic feedback to play. + */ + public void addHaptic(int id) { + mHapticFeedback.add(id); + } + + /** + * Adds haptic feedback to this utterance. + * + * @param ids A collection of identifiers associated with the haptic feedback to play. + */ + public void addAllHaptic(Collection ids) { + mHapticFeedback.addAll(ids); + } + + /** + * Gets the meta-data of this utterance. + * + * @return The utterance meta-data. + */ + public Bundle getMetadata() { + return mMetadata; + } + + /** @return An unmodifiable list of spoken text attached to this utterance. */ + public List getSpoken() { + return Collections.unmodifiableList(mSpokenFeedback); + } + + /** @return An unmodifiable set of auditory feedback identifiers attached to this utterance. */ + public Set getAuditory() { + return Collections.unmodifiableSet(mAuditoryFeedback); + } + + /** @return An unmodifiable set of haptic feedback identifiers attached to this utterance. */ + public Set getHaptic() { + return Collections.unmodifiableSet(mHapticFeedback); + } + + @Override + public String toString() { + return "Text:{" + mSpokenFeedback + "}, Metadata:" + mMetadata; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTree.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTree.java new file mode 100644 index 0000000..be3dbd1 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTree.java @@ -0,0 +1,2085 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import android.content.res.Resources; +import android.text.TextUtils; +import android.util.Pair; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Manages data for determining the output of events based on a JSON description. The output can be + * based on a combination of the event type, variables, and constants.
+ * During initialization, errors will throw an InvalidStateException.
+ * During event handling, errors will be logged, and safe default behavior will be performed.
+ * + *

Types

+ * + *
    + *
  • Boolean: Can be true or false + *
  • Integer: Can be any integral value + *
  • Number: Double precision floating point value + *
  • String: A string that may have formatting information. + *
  • Enum: An integer that has a value from a list of named values. + *
  • Reference: Returns a variable delegate + *
  • Array: An array with each element being a String + *
  • Child Array: An array where each element returns a variable delegate. + *
+ * + *

Events

+ * + *

Each named event can be evaluated for each named output. + * + *

Outputs

+ * + *

Each Output can be a Boolean, Integer, Number, Enum, or a String. It will be evaluated when + * parseEventTo*() is called, and a default value will be returned if the output is not defined for + * the event. + * + *

Constants

+ * + *

Constants can be a Boolean, Integer, Number or a String. They are evaluated when the tree is + * built, and cannot be changed. + * + *

Variables

+ * + *

Variables can be any type, and are evaluated when an Event is evaluated. The value will be + * requested from the VariableDelegate that is passed to the parseEventTo*() function. + * + *

Functions

+ * + *

Functions can be provided to provide an arbitrary transformation of data. They should always + * produce the same result within a call to parseEventTo*(), given the same VariableDelegate. The + * function should be annotated with @UsedByReflection. + * + *

JSON format

+ * + *

The JSON file should contain a map with two top level entries: "events" and "named_nodes" + * + *

Events

+ * + *

"events" is a map with keys for each named event, each containing a map. This can have an + * entry for each named output, and should map that output to the definition of a parse tree. + * + *

Named Nodes

+ * + *

"named_nodes" is a map from an arbitrary name to a the definition of a parse tree. + * + *

Parse Tree

+ * + *

A parse tree is a section of JSON that defines a tree of nodes that can be resolved to a + * value. Each node can be a basic type (bool, int, float) that resolves directly to a value, or a + * string, array or map that is evaluated into a node tree. + * + *

Strings are evaluated based on their context. + * + *

    + *
  • If they represent a string value, constants, named nodes, resources, variables and + * functions + *
  • will be evaluated in place, and all other characters will be left in place. Otherwise, they + *
  • will be evaluated to a boolean, integer, number, array, or child array as appropriate. + *
+ * + *

JSON Arrays are evaluated to an Array node. Each entry is evaluated as a node to either a + * string or array as appropriate. Strings are added to the array, and arrays have all their + * elements added. + * + *

Example
+ * + *
{@code
+ * {
+ *   "node1": ["pie", "%node_2", "%node3"],
+ *   "node2": ["cake", "rum"],
+ *   "node3": "cola"
+ * }
+ *
+ * }
+ * + *

node1 would evaluate to the array: ["pie, "cake", "rum", "cola"] + * + *

JSON Objects are evaluated based on their members. They should contain a member named the type + * of node they represent. + * + *

If: An "if" node should have a condition, then a parse tree to evaluate based on the output of + * that condition. At least one of "then" and "else" is required. + * + *

Members
+ * + *
    + *
  • "if": The condition to evaluate. This should resolve to a boolean value. + *
  • "then": The parse tree to evaluate if the condition is true + *
  • "else": The parse tree to evaluate if the condition is false + *
+ * + *
Example
+ * + *
{@code
+ * {
+ *   "if": "$variable.integer == 16",
+ *   "then": "@string/true_string",
+ *   "else": "%false_node"
+ * }
+ *
+ * }
+ * + *

Fallback: A "fallback" node should have an array. Each node in the array will be evaluated in + * order, and the first one that is not an empty string will be returned. + * + *

Members
+ * + *
    + *
  • "fallback": The array of nodes to evaluate + *
+ * + *
Example
+ * + *
{@code
+ * {
+ *   "fallback": [
+ *     "%first_node",
+ *     { "if": "%condition_node", "then": "$second_variable" },
+ *     "@string/final_value"
+ *   ]
+ * }
+ *
+ * }
+ * + *

Join: A "join" node takes an array, and concatenates the strings. + * + *

Members
+ * + *
    + *
  • "join": Array of values to join. + *
  • "separator": A string value that will be inserted between the values. Defaults to an empty + * string. + *
  • "prune_empty": A boolean value. If true, empty values in the array will be skipped. + * Defaults to false. + *
+ * + *
Example
+ * + *
{@code
+ * {
+ *   "join": [ "@string/first_string", "%second_value", "$variable.third_value ],
+ *   "separator: ", ",
+ *   "prune_empty": true
+ * }
+ *
+ * }
+ * + *

Switch: Takes an enum, and evaluates the node tree associated with its value. + * + *

Members
+ * + *
    + *
  • "switch": An enum value. The case associated with it's value will be evaluated. + *
  • "cases": A map from each of the enum's possible values to the parse tree to evaluate. + *
  • "default": The parse tree to evaluate if the value isn't found in "cases". Optional. + *
+ * + *
Example
+ * + *
{@code
+ * {
+ *   "switch": "$variable.enum",
+ *   "cases": {
+ *     "one": "@string/first_result",
+ *     "two": "%second_result"
+ *   },
+ *   "default": "@string/not_found"
+ * }
+ *
+ * }
+ * + *

For Reference: The provided variable must be of type "reference". The provided tree will be + * evaluated using the delegate returned by the variable. The result will be the type of the + * evaluated node. + * + *

Members
+ * + *
    + *
  • "for_reference": A reference variable. + *
  • "evaluate": A parse tree to evaluate for the reference. + *
+ * + *
Example
+ * + *
{@code
+ * {
+ *   "for_reference": "$variable.child",
+ *   "evaluate": "$child.first_variable $child.second_variable"
+ * }
+ *
+ * }
+ * + *

For Each Child: The provided variable must be of type "child array". For each element, the + * provided tree will be evaluated using the delegate returned by the variable. The result will be + * an array with the elements corresponding to each child in the original array. + * + *

Members
+ * + *
    + *
  • "for_each_child": A child array. + *
  • "evaluate": A parse tree to evaluate for each element in the "for_each_child" array. + *
+ * + *
Example
+ * + *
{@code
+ * {
+ *   "for_each_child": "$variable.child_array",
+ *   "evaluate": "$child.first_variable $child.second_variable"
+ * }
+ *
+ * }
+ * + *

References

+ * + *
    + *
  • Constants: "#\w+", e.g. "#CONSTANT_NAME" + *
  • Named nodes: "%\w+", e.g. "%node_name" + *
  • Resources: "@(string|plurals|raw|array)/\w+" e.g. "@string/resource_name" + *
      + *
    • String resources can be formatted with a parameter list enclosed in parentheses, e.g + * "@string/formatted_string(123, 456, %node_param)" + *
    + *
  • Variables: "\$(\w+\.)*\w+" e.g. "$variable.name" + *
  • Functions: "\w[\w0-9]*" e.g. "FunctionName(123, 456, %node_param)" + *
+ */ +public class ParseTree { + + private static final String TAG = "ParseTree"; + + public ParseTree(Resources resources, String packageName) { + mTreeInfo = new TreeInfo(resources, packageName); + } + + /** An interface for supplying variables to the ParseTree */ + public interface VariableDelegate { + boolean getBoolean(int variableId); + + int getInteger(int variableId); + + double getNumber(int variableId); + + @Nullable + CharSequence getString(int variableId); + + int getEnum(int variableId); + + @Nullable + VariableDelegate getReference(int variableId); + + int getArrayLength(int variableId); + + @Nullable + CharSequence getArrayStringElement(int variableId, int index); + + @Nullable + VariableDelegate getArrayChildElement(int variableId, int index); + } + + /** Enum representing the variable types. */ + @IntDef({ + VARIABLE_BOOL, + VARIABLE_INTEGER, + VARIABLE_NUMBER, + VARIABLE_STRING, + VARIABLE_ENUM, + VARIABLE_REFERENCE, + VARIABLE_ARRAY, + VARIABLE_CHILD_ARRAY + }) + @Retention(RetentionPolicy.SOURCE) + public @interface VariableType {} + + static final int VARIABLE_BOOL = 0; + static final int VARIABLE_INTEGER = 1; + static final int VARIABLE_NUMBER = 2; + static final int VARIABLE_STRING = 3; + static final int VARIABLE_ENUM = 4; + static final int VARIABLE_REFERENCE = 5; + static final int VARIABLE_ARRAY = 6; + static final int VARIABLE_CHILD_ARRAY = 7; + + @IntDef({ + OPERATOR_CLASS_NONE, + OPERATOR_CLASS_PLUS, + OPERATOR_CLASS_MULTIPLY, + OPERATOR_CLASS_EQUALS, + OPERATOR_CLASS_AND, + OPERATOR_CLASS_TOKEN + }) + @Retention(RetentionPolicy.SOURCE) + @interface OperatorClass {} + + // These values are in decreasing order of precedence. + private static final int OPERATOR_CLASS_TOKEN = 0; + private static final int OPERATOR_CLASS_MULTIPLY = 1; + private static final int OPERATOR_CLASS_PLUS = 2; + private static final int OPERATOR_CLASS_EQUALS = 3; + private static final int OPERATOR_CLASS_AND = 4; + private static final int OPERATOR_CLASS_NONE = 5; + + @IntDef({ + OPERATOR_PLUS, + OPERATOR_MINUS, + OPERATOR_MULTIPLY, + OPERATOR_DIVIDE, + OPERATOR_EQUALS, + OPERATOR_NEQUALS, + OPERATOR_LT, + OPERATOR_GT, + OPERATOR_LE, + OPERATOR_GE, + OPERATOR_AND, + OPERATOR_OR, + OPERATOR_POW + }) + @Retention(RetentionPolicy.SOURCE) + @interface Operator {} + + static final int OPERATOR_PLUS = 1; + static final int OPERATOR_MINUS = 2; + static final int OPERATOR_MULTIPLY = 3; + static final int OPERATOR_DIVIDE = 4; + static final int OPERATOR_EQUALS = 5; + static final int OPERATOR_NEQUALS = 6; + static final int OPERATOR_LT = 7; + static final int OPERATOR_GT = 8; + static final int OPERATOR_LE = 9; + static final int OPERATOR_GE = 10; + static final int OPERATOR_AND = 11; + static final int OPERATOR_OR = 12; + static final int OPERATOR_POW = 13; + + private static class VariableInfo { + VariableInfo(String inName, @VariableType int inVariableType) { + name = inName; + variableType = inVariableType; + enumType = 0; + id = 0; + } + + VariableInfo(String inName, @VariableType int inVariableType, int inId) { + name = inName; + variableType = inVariableType; + enumType = 0; + id = inId; + } + + VariableInfo(String inName, @VariableType int inVariableType, int inEnumType, int inId) { + name = inName; + variableType = inVariableType; + enumType = inEnumType; + id = inId; + } + + @VariableType final int variableType; + final int enumType; + final int id; + final String name; + } + + private static class TreeInfo { + private final Resources resources; + private final String packageName; + private final Map mNamedNodes = new HashMap<>(); + private final JSONObject mEventTree = new JSONObject(); + private final JSONObject mNodes = new JSONObject(); + private final Map mEventNames = new HashMap<>(); + private final Map mOutputNames = new HashMap<>(); + private final Map mOutputs = new HashMap<>(); + private final Map mConstants = new HashMap<>(); + private final Map mVariables = new HashMap<>(); + private final Map> mEnums = new HashMap<>(); + private final Map> mFunctions = new HashMap<>(); + private final Set mPendingNamedNodes = new HashSet<>(); + private final List> mDeferredForEachChildNodes = + new ArrayList<>(); + + private TreeInfo(Resources resources, String packageName) { + this.resources = resources; + this.packageName = packageName; + } + } + + private final Map, ParseTreeNode> mEvents = new HashMap<>(); + + // Data used to build the parse tree. It's released once the tree is built. + @Nullable private TreeInfo mTreeInfo; + + private static final Pattern CONSTANT_PATTERN = Pattern.compile("#\\w+"); + private static final Pattern NODE_PATTERN = Pattern.compile("%\\w+"); + private static final Pattern RESOURCE_PATTERN = + Pattern.compile("@(string|plurals|raw|array)/\\w+"); + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$(\\w+\\.)*\\w+"); + private static final Pattern NUMBER_PATTERN = Pattern.compile("-?\\d*\\.?\\d+"); + private static final Pattern OPERATOR_PATTERN = Pattern.compile("(\\|\\||&&|[!=<>]=|[-+/*<>^])"); + private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("\\w[\\w0-9]*"); + + private static final Pattern OPERATOR_CLASS_PLUS_PATTERN = Pattern.compile("[-+]"); + private static final Pattern OPERATOR_CLASS_MULTIPLY_PATTERN = Pattern.compile("[/*^]"); + private static final Pattern OPERATOR_CLASS_EQUALS_PATTERN = Pattern.compile("([!=<>]=|[<>])"); + private static final Pattern OPERATOR_CLASS_AND_PATTERN = Pattern.compile("(\\|\\||&&)"); + + private static final String EVENT_FORMAT = "Getting output %s for event %s"; + + /** + * Creates an enum with the specified ID, and stores the mapping of its valid values. + * + * @param enumId ID of the enum. Must be unique. + * @param values Mapping of enum value to the name of the value. This must uniquely map the two in + * either direction. + */ + public void addEnum(int enumId, Map values) { + if (mTreeInfo != null) { + TreeInfo treeInfo = mTreeInfo; + if (treeInfo.mEnums.containsKey(enumId)) { + throw new IllegalStateException("Can't add enum, ID " + enumId + " already in use"); + } + + Map reverseEnum = new HashMap<>(); + for (Map.Entry entry : values.entrySet()) { + if (reverseEnum.containsKey(entry.getValue())) { + throw new IllegalStateException( + "Duplicate name: " + entry.getValue() + " in enum definition"); + } + + reverseEnum.put(entry.getValue(), entry.getKey()); + } + + treeInfo.mEnums.put(enumId, reverseEnum); + } else { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + } + } + + /** + * Assigns an id to a named event. + * + * @param eventName Name of the event. + * @param eventId ID used to invoke the event. Must be unique. + */ + public void addEvent(String eventName, int eventId) { + if (mTreeInfo != null) { + TreeInfo treeInfo = mTreeInfo; + + if (treeInfo.mEventNames.containsKey(eventId)) { + throw new IllegalStateException( + "Can't add event: " + eventName + ", ID " + eventId + " already in use"); + } + + treeInfo.mEventNames.put(eventId, eventName); + } else { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + } + } + + /** + * Assigns an id to a named output with a boolean value. + * + * @param outputName Name of the output. + * @param outputId ID used to parse the output. Must be unique. + */ + public void addBooleanOutput(String outputName, int outputId) { + addOutput(outputName, new VariableInfo(outputName, VARIABLE_BOOL, outputId)); + } + + /** + * Assigns an id to a named output with a integral value. + * + * @param outputName Name of the output. + * @param outputId ID used to parse the output. Must be unique. + */ + public void addIntegerOutput(String outputName, int outputId) { + addOutput(outputName, new VariableInfo(outputName, VARIABLE_INTEGER, outputId)); + } + + /** + * Assigns an id to a named output with a floating point value. + * + * @param outputName Name of the output. + * @param outputId ID used to parse the output. Must be unique. + */ + public void addNumberOutput(String outputName, int outputId) { + addOutput(outputName, new VariableInfo(outputName, VARIABLE_NUMBER, outputId)); + } + + /** + * Assigns an id to a named output with a string value. + * + * @param outputName Name of the output. + * @param outputId ID used to parse the output. Must be unique. + */ + public void addStringOutput(String outputName, int outputId) { + addOutput(outputName, new VariableInfo(outputName, VARIABLE_STRING, outputId)); + } + + /** + * Assigns an id to a named output with an enum value. + * + * @param outputName Name of the output. + * @param outputId ID used to parse the output. Must be unique. + */ + public void addEnumOutput(String outputName, int outputId, int enumType) { + addOutput(outputName, new VariableInfo(outputName, VARIABLE_ENUM, enumType, outputId)); + } + + /** + * Assigns an id to a named variable of Boolean type. + * + * @param varName Name of the variable. + * @param varId ID used to identify the variable. Must be unique. + */ + public void addBooleanVariable(String varName, int varId) { + addVariable(varName, new VariableInfo(varName, VARIABLE_BOOL, varId)); + } + + /** + * Assigns an id to a named variable of Integer type. + * + * @param varName Name of the variable. + * @param varId ID used to identify the variable. Must be unique. + */ + public void addIntegerVariable(String varName, int varId) { + addVariable(varName, new VariableInfo(varName, VARIABLE_INTEGER, varId)); + } + + /** + * Assigns an id to a named variable of Number type. + * + * @param varName Name of the variable. + * @param varId ID used to identify the variable. Must be unique. + */ + public void addNumberVariable(String varName, int varId) { + addVariable(varName, new VariableInfo(varName, VARIABLE_NUMBER, varId)); + } + + /** + * Assigns an id to a named variable of String type. + * + * @param varName Name of the variable. + * @param varId ID used to identify the variable. Must be unique. + */ + public void addStringVariable(String varName, int varId) { + addVariable(varName, new VariableInfo(varName, VARIABLE_STRING, varId)); + } + + /** + * Assigns an id to a named variable of Enum type. + * + * @param varName Name of the variable. + * @param varId ID used to identify the variable. Must be unique. + */ + public void addEnumVariable(String varName, int varId, int enumType) { + addVariable(varName, new VariableInfo(varName, VARIABLE_ENUM, enumType, varId)); + } + + /** + * Assigns an id to a named variable of reference type. + * + * @param varName Name of the variable. + * @param varId ID used to identify the variable. Must be unique. + */ + public void addReferenceVariable(String varName, int varId) { + addVariable(varName, new VariableInfo(varName, VARIABLE_REFERENCE, varId)); + } + + /** + * Assigns an id to a named variable of Array type. + * + * @param varName Name of the variable. + * @param varId ID used to identify the variable. Must be unique. + */ + public void addArrayVariable(String varName, int varId) { + addVariable(varName, new VariableInfo(varName, VARIABLE_ARRAY, varId)); + } + + /** + * Assigns an id to a named variable of Child Array type. + * + * @param varName Name of the variable. + * @param varId ID used to identify the variable. Must be unique. + */ + public void addChildArrayVariable(String varName, int varId) { + addVariable(varName, new VariableInfo(varName, VARIABLE_CHILD_ARRAY, varId)); + } + + public void addFunction(String name, Object delegate) { + Method method = null; + for (Method currentMethod : delegate.getClass().getDeclaredMethods()) { + if (currentMethod.getName().equals(name)) { + if (method != null) { + throw new IllegalStateException( + "Function name '" + name + "' is ambiguous for delegate: " + delegate); + } + method = currentMethod; + } + } + + if (method == null) { + throw new IllegalStateException( + "No matching method name, or method wasn't annotated with @UsedByReflection(): " + name); + } + + addFunction(name, delegate, method); + } + + public void addFunction(String name, Object delegate, Method method) { + if (mTreeInfo == null) { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + return; + } + + mTreeInfo.mFunctions.put(name, Pair.create(delegate, method)); + } + + /** + * Merges a JSON tree into the parse tree definition. This overwrites any existing nodes or events + * with the new one in definition. + * + * @param definition Contains the JSON representing the parse tree data to be added. + */ + public void mergeTree(JSONObject definition) { + if (mTreeInfo == null) { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + return; + } + + try { + TreeInfo treeInfo = mTreeInfo; + if (definition.has("events")) { + JSONObject events = definition.getJSONObject("events"); + Iterator eventNames = events.keys(); + while (eventNames.hasNext()) { + String eventName = eventNames.next(); + + // Unknown events are not allowed. + if (!treeInfo.mEventNames.containsValue(eventName)) { + throw new IllegalStateException("Unknown event name: " + eventName); + } + + // Ensure there is an event registered for |eventName| + JSONObject event; + if (treeInfo.mEventTree.has(eventName)) { + event = treeInfo.mEventTree.getJSONObject(eventName); + } else { + event = new JSONObject(); + treeInfo.mEventTree.put(eventName, event); + } + + // Put the new event's outputs into the tree. + JSONObject newEvent = events.getJSONObject(eventName); + Iterator outputs = newEvent.keys(); + while (outputs.hasNext()) { + String outputName = outputs.next(); + // Unknown outputs are not allowed. + if (!treeInfo.mOutputNames.containsValue(outputName)) { + throw new IllegalStateException("Unknown output name: " + outputName); + } + event.put(outputName, newEvent.get(outputName)); + } + } + } + if (definition.has("named_nodes")) { + JSONObject nodes = definition.getJSONObject("named_nodes"); + Iterator nodeNames = nodes.keys(); + while (nodeNames.hasNext()) { + String nodeName = nodeNames.next(); + treeInfo.mNodes.put(nodeName, nodes.get(nodeName)); + } + } + } catch (JSONException e) { + throw new IllegalStateException(e.toString()); + } + } + + public void setConstantBool(String name, boolean value) { + if (mTreeInfo != null) { + mTreeInfo.mConstants.put(name, new ParseTreeBooleanConstantNode(value)); + } else { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + } + } + + public void setConstantInteger(String name, int value) { + if (mTreeInfo != null) { + mTreeInfo.mConstants.put(name, new ParseTreeIntegerConstantNode(value)); + } else { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + } + } + + public void setConstantNumber(String name, double value) { + if (mTreeInfo != null) { + mTreeInfo.mConstants.put(name, new ParseTreeNumberConstantNode(value)); + } else { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + } + } + + public void setConstantEnum(String name, int enumType, int value) { + if (mTreeInfo != null) { + mTreeInfo.mConstants.put(name, new ParseTreeIntegerConstantNode(value, enumType)); + } else { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + } + } + + public void setConstantString(String name, CharSequence value) { + if (mTreeInfo != null) { + mTreeInfo.mConstants.put(name, new ParseTreeStringConstantNode(value)); + } else { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + } + } + + /** + * Build the parse tree. Once this function is called, the parse tree can no longer be modified. + * The only valid functions to call then are parseEventTo* + */ + // incompatible types in argument. + @SuppressWarnings("nullness:argument") + public void build() { + if (mTreeInfo == null) { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + return; + } + + TreeInfo treeInfo = mTreeInfo; + mTreeInfo = null; + for (int eventId : treeInfo.mEventNames.keySet()) { + for (String outputName : treeInfo.mOutputs.keySet()) { + VariableInfo outputInfo = treeInfo.mOutputs.get(outputName); + String eventName = treeInfo.mEventNames.get(eventId); + JSONObject eventDefinition = treeInfo.mEventTree.optJSONObject(eventName); + if (eventDefinition != null && eventDefinition.has(outputName)) { + switch (outputInfo.variableType) { + case ParseTree.VARIABLE_BOOL: + case ParseTree.VARIABLE_INTEGER: + case ParseTree.VARIABLE_NUMBER: + case ParseTree.VARIABLE_ENUM: + case ParseTree.VARIABLE_STRING: + mEvents.put( + Pair.create(eventId, outputInfo.id), + new ParseTreeCommentNode( + createParseTreeFromObject( + treeInfo, eventDefinition.opt(outputName), outputInfo), + EVENT_FORMAT, + new Object[] {outputName, eventName})); + break; + case ParseTree.VARIABLE_REFERENCE: + case ParseTree.VARIABLE_ARRAY: + case ParseTree.VARIABLE_CHILD_ARRAY: + default: + throw new IllegalStateException( + "Bad output type for: " + eventName + ":" + outputName); + } + } + } + } + + while (!treeInfo.mDeferredForEachChildNodes.isEmpty()) { + Pair current = + treeInfo.mDeferredForEachChildNodes.remove(0); + + current.first.setFunction( + createParseTreeFromObject( + treeInfo, + current.second.opt("evaluate"), + new VariableInfo("function...", VARIABLE_STRING))); + } + } + + /** + * Evaluates the specified event, returning the result as a boolean. + * + * @param eventId ID of the event to evaluate. + * @param outputId Which of the event's outputs to evaluate. + * @param defaultValue The value to return if the event is undefined. + * @param delegate The delegate to retrieve variables from + * @return The result of the event, coerced to a bool. + */ + public boolean parseEventToBool( + int eventId, int outputId, boolean defaultValue, VariableDelegate delegate) { + ParseTreeNode eventNode = mEvents.get(Pair.create(eventId, outputId)); + if (eventNode != null) { + return eventNode.resolveToBoolean(delegate, ""); + } + return defaultValue; + } + + /** + * Evaluates the specified event, returning the result as an integer. + * + * @param eventId ID of the event to evaluate. + * @param outputId Which of the event's outputs to evaluate. + * @param defaultValue The value to return if the event is undefined. + * @param delegate The delegate to retrieve variables from + * @return The result of the event, coerced to an integer. + */ + public int parseEventToInteger( + int eventId, int outputId, int defaultValue, VariableDelegate delegate) { + ParseTreeNode eventNode = mEvents.get(Pair.create(eventId, outputId)); + if (eventNode != null) { + return eventNode.resolveToInteger(delegate, ""); + } + return defaultValue; + } + + /** + * Evaluates the specified event, returning the result as a number. + * + * @param eventId ID of the event to evaluate. + * @param outputId Which of the event's outputs to evaluate. + * @param defaultValue The value to return if the event is undefined. + * @param delegate The delegate to retrieve variables from + * @return The result of the event, coerced to a number. + */ + public double parseEventToNumber( + int eventId, int outputId, double defaultValue, VariableDelegate delegate) { + ParseTreeNode eventNode = mEvents.get(Pair.create(eventId, outputId)); + if (eventNode != null) { + return eventNode.resolveToNumber(delegate, ""); + } + return defaultValue; + } + + /** + * Evaluates the specified event, building a string as the output. + * + * @param eventId ID of the event to evaluate. + * @param outputId Which of the event's outputs to evaluate. + * @param delegate The delegate to retrieve variables from + * @return A string built from evaluating the event's output definition. + */ + @Nullable + public CharSequence parseEventToString(int eventId, int outputId, VariableDelegate delegate) { + ParseTreeNode eventNode = mEvents.get(Pair.create(eventId, outputId)); + if (eventNode != null) { + return eventNode.resolveToString(delegate, ""); + } + return null; + } + + /** + * Evaluates the specified event, mapping the output to an enumerated value. + * + * @param eventId ID of the event to evaluate. + * @param outputId Which of the event's outputs to evaluate. + * @param defaultValue The value to return if the mapping is unsuccessful. + * @param delegate The delegate to retrieve variables from + * @return An integer value corresponding to the value mapped the the string value of the output, + * or defaultValue if no mapping exists. + */ + public int parseEventToEnum( + int eventId, int outputId, int defaultValue, VariableDelegate delegate) { + ParseTreeNode eventNode = mEvents.get(Pair.create(eventId, outputId)); + if (eventNode != null) { + return eventNode.resolveToInteger(delegate, ""); + } + return defaultValue; + } + + private void addVariable(String varName, VariableInfo varInfo) { + if (mTreeInfo != null) { + TreeInfo treeInfo = mTreeInfo; + if (treeInfo.mVariables.containsKey(varName)) { + throw new IllegalStateException("Can't add variable: " + varName + ", name already in use"); + } + + for (VariableInfo info : treeInfo.mVariables.values()) { + if (varInfo.id == info.id) { + throw new IllegalStateException( + "Can't add variable: " + varName + ", ID " + varInfo.id + " already in use"); + } + } + + treeInfo.mVariables.put(varName, varInfo); + } else { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + } + } + + private void addOutput(String outputName, VariableInfo type) { + if (mTreeInfo != null) { + TreeInfo treeInfo = mTreeInfo; + if (treeInfo.mOutputs.containsKey(outputName)) { + throw new IllegalStateException("Can't add output: " + outputName + " already in use"); + } + + if (treeInfo.mOutputNames.containsKey(type.id)) { + throw new IllegalStateException( + "Can't add output: " + outputName + ", ID " + type.id + " already in use"); + } + + treeInfo.mOutputNames.put(type.id, outputName); + treeInfo.mOutputs.put(outputName, type); + } else { + LogUtils.w(TAG, "Parse tree has been built and is immutable"); + } + } + + /** + * Creates a ParseTreeNode from an object in the JSON parse tree definition. + * + * @param value The value in the JSON tree that represents this node. + * @param hint The type of value that is expected to be returned by this node. + * @return A ParseTreeNode that will follow the logic defined by |value| when evaluated. + */ + private static ParseTreeNode createParseTreeFromObject( + TreeInfo treeInfo, Object value, VariableInfo hint) { + if (value instanceof Boolean) { + // Boolean values are always constants. + return new ParseTreeBooleanConstantNode((Boolean) value); + } else if (value instanceof Double) { + // Double values are always Number constants + return new ParseTreeNumberConstantNode((Double) value); + } else if (value instanceof Integer) { + // Integer values are always constants. + return new ParseTreeIntegerConstantNode((Integer) value); + } else if (value instanceof String) { + // Strings can be evaluated differently depending on the expected value. + switch (hint.variableType) { + case ParseTree.VARIABLE_BOOL: + case ParseTree.VARIABLE_INTEGER: + case ParseTree.VARIABLE_NUMBER: + { + // Booleans and number types imply that the string is a statement that should be + // evaluated. + ParseTreeNode result = + createParseTreeFromStatement( + treeInfo, (String) value, 0, ((String) value).length()); + return new ParseTreeCommentNode(result, "Evaluating: %s", new Object[] {value}); + } + + case ParseTree.VARIABLE_ENUM: + // Enums imply that the value of the string should be looked up in the enum + // definition. + return createEnumParseTreeFromString(treeInfo, (String) value, hint.enumType); + + case ParseTree.VARIABLE_STRING: + { + // Strings should have variables, constants, nodes and resources expanded. + ParseTreeNode result = createStringParseTreeFromString(treeInfo, (String) value); + return new ParseTreeCommentNode(result, "Evaluating: %s", new Object[] {value}); + } + + case ParseTree.VARIABLE_ARRAY: + // If it's an array, this value will either be expanded to an array, or added as + // a string. + return createArrayChildParseTreeFromString(treeInfo, (String) value); + + case ParseTree.VARIABLE_REFERENCE: + case ParseTree.VARIABLE_CHILD_ARRAY: + default: + throw new IllegalStateException( + "String cannot be expanded to type: " + hint.variableType); + } + } else if (value instanceof JSONArray) { + // Arrays should create a node that each element is merged into. + JSONArray jsonArray = (JSONArray) value; + int length = jsonArray.length(); + List children = new ArrayList<>(); + for (int i = 0; i < length; i++) { + Object childTree = jsonArray.opt(i); + if (childTree != null) { + children.add( + createParseTreeFromObject( + treeInfo, childTree, new VariableInfo("array...", VARIABLE_ARRAY))); + } else if (i != length - 1) { + // We allow the last element in an array to be null, since that just means there is a + // trailing comma. + throw new IllegalStateException("Array contains a null element at: " + i); + } + } + return new ParseTreeArrayNode(children); + } else if (value instanceof JSONObject) { + // JSONObject implies a function of some sort. + JSONObject jsonObject = (JSONObject) value; + if (jsonObject.has("if")) { + return createIfParseTreeFromObject(treeInfo, jsonObject, hint); + } else if (jsonObject.has("join")) { + return createJoinParseTreeFromObject(treeInfo, jsonObject); + } else if (jsonObject.has("fallback")) { + return createFallbackParseTreeFromObject(treeInfo, jsonObject); + } else if (jsonObject.has("switch")) { + return createSwitchParseTreeFromObject(treeInfo, jsonObject, hint); + } else if (jsonObject.has("for_reference")) { + return createForReferenceParseTreeNodeFromObject(treeInfo, jsonObject, hint); + } else if (jsonObject.has("for_each_child")) { + return createForEachChildParseTreeNodeFromObject(treeInfo, jsonObject); + } else { + StringBuilder keys = new StringBuilder(); + Iterator keyIter = jsonObject.keys(); + while (keyIter.hasNext()) { + keys.append(" "); + keys.append(keyIter.next()); + } + + throw new IllegalStateException("Unknown function: " + keys); + } + } else { + throw new IllegalStateException("Unknown type: " + value); + } + } + + private static ParseTreeNode createArrayParseTreeFromObject(TreeInfo treeInfo, Object value) { + // If the object already evaluates to an array, return it. Otherwise, it needs to be + // wrapped. + ParseTreeNode child = + createParseTreeFromObject(treeInfo, value, new VariableInfo("array...", VARIABLE_ARRAY)); + if (child.getType() == VARIABLE_ARRAY) { + return child; + } else { + List children = new ArrayList<>(); + children.add(child); + return new ParseTreeArrayNode(children); + } + } + + private static ParseTreeNode createParseTreeFromStatement( + TreeInfo treeInfo, String value, int start, int end) { + ParseTreeNode lvalue = null; + + int offset = start; + // Initialize to OPERATOR_PLUS to avoid a compile error. The default value should + // never be read. + @Operator int operator = OPERATOR_PLUS; + while (offset < end) { + offset = skipWhitespace(value, offset); + char current = value.charAt(offset); + + if (lvalue == null) { + if (current == '!') { + // Currently only applicable to "not" operator. + offset++; + + int statementEnd = findStatementEnd(value, offset, OPERATOR_CLASS_TOKEN); + ParseTreeNode childNode = + createParseTreeFromStatement(treeInfo, value, offset, statementEnd); + if (!childNode.canCoerceTo(VARIABLE_BOOL)) { + throw new IllegalStateException( + String.format( + "Cannot coerce not node child to bool (%d, %s): \"%s\"", + offset, variableTypeToString(childNode.getType()), value)); + } + lvalue = new ParseTreeNotNode(childNode); + offset = statementEnd; + } else if (current == '$') { + // Variable follows. + int tokenEnd = findTokenEnd(value, offset); + String name = value.substring(offset + 1, tokenEnd); + lvalue = createVariableNode(treeInfo, name); + offset = tokenEnd; + } else if (current == '#') { + // Constant follows. + int tokenEnd = findTokenEnd(value, offset); + String name = value.substring(offset + 1, tokenEnd); + lvalue = createConstantNode(treeInfo, name); + offset = tokenEnd; + } else if (current == '@') { + // Resource follows. + int tokenEnd = findTokenEnd(value, offset); + // Since this is handled as an integer, we ignore any parameters. + lvalue = + new ParseTreeResourceNode( + treeInfo.resources, value.substring(offset, tokenEnd), treeInfo.packageName); + offset = tokenEnd; + } else if (current == '%') { + int tokenEnd = findTokenEnd(value, offset); + String variableText = value.substring(offset + 1, tokenEnd); + lvalue = + getOrCreateNamedNode( + treeInfo, variableText, new VariableInfo(variableText, VARIABLE_NUMBER)); + offset = tokenEnd; + } else if (isNumberStart(value, offset)) { + int numberEnd = findNumberEnd(value, offset); + String valueString = value.substring(offset, numberEnd); + if (valueString.indexOf('.') == -1) { + lvalue = new ParseTreeIntegerConstantNode(Integer.parseInt(valueString)); + } else { + lvalue = new ParseTreeNumberConstantNode(Double.parseDouble(valueString)); + } + offset = numberEnd; + } else if (isFunctionStart(current)) { + int tokenEnd = findTokenEnd(value, offset); + if (tokenEnd >= end || value.charAt(tokenEnd) != '(') { + throw new IllegalStateException("Function is missing parameter list: " + value); + } + int paramEnd = findMatchingParen(value, tokenEnd); + lvalue = createFunctionNode(treeInfo, value, offset, tokenEnd, paramEnd); + offset = paramEnd; + } else if (current == '(') { + int statementEnd = findMatchingParen(value, offset); + lvalue = createParseTreeFromStatement(treeInfo, value, offset + 1, statementEnd - 1); + offset = statementEnd; + } else { + throw new IllegalStateException("Cannot parse statement: " + value); + } + } else { + // Since lvalue in not null, a full loop has been completed and operator should have + // been set. + ParseTreeNode rvalue; + + if ((operator == OPERATOR_EQUALS || operator == OPERATOR_NEQUALS) + && lvalue.getType() == VARIABLE_ENUM + && current == '\'') { + // If an enum is being directly compared to a string constant, convert the + // string to an enum value. + int stringEnd = findStringEnd(value, offset); + String enumValue = getString(value, offset, stringEnd); + rvalue = createEnumParseTreeFromString(treeInfo, enumValue, lvalue.getEnumType()); + offset = stringEnd; + } else { + // Evaluate the next portion of the string as an rvalue. + int statementEnd = findStatementEnd(value, offset, getOperatorClass(operator)); + rvalue = createParseTreeFromStatement(treeInfo, value, offset, statementEnd); + offset = statementEnd; + } + + if (!isValidLvalueType(lvalue.getType())) { + throw new IllegalStateException( + "Invalid lvalue type: " + variableTypeToString(lvalue.getType())); + } + + if (!isValidRvalueType(rvalue.getType())) { + throw new IllegalStateException( + "Invalid rvalue type: " + variableTypeToString(rvalue.getType())); + } + + lvalue = new ParseTreeOperatorNode(operator, lvalue, rvalue); + } + + offset = skipWhitespace(value, offset); + + if (offset >= end) { + break; + } + + current = value.charAt(offset); + + if (!isOperatorStart(current)) { + throw new IllegalStateException("Invalid operator in statement: " + value); + } + + int operatorEnd = findOperatorEnd(value, offset); + operator = getOperator(value.substring(offset, operatorEnd)); + offset = operatorEnd; + } + + if (lvalue == null) { + if (end == 0) { + lvalue = new ParseTreeCommentNode(null, "Empty Node", true); + + } else { + throw new IllegalStateException("Could not parse statement: " + value); + } + } + return lvalue; + } + + private static ParseTreeNode createStringParseTreeFromString(TreeInfo treeInfo, String value) { + List parts = new ArrayList<>(); + + int offset = 0; + final int valueLength = value.length(); + while (offset < valueLength) { + int nextStart = findNextTokenStartInString(value, offset); + if (nextStart > 0) { + // If there is anything before the first token, add it as a string constant. + parts.add(new ParseTreeStringConstantNode(value.substring(offset, nextStart))); + } + + offset = nextStart; + if (offset < valueLength) { + // Find the next function, variable, constant, node, or resource in the string and + // add a node for it. + int tokenEnd = findTokenEnd(value, offset); + char current = value.charAt(offset); + if (isFunctionStart(current)) { + if (tokenEnd >= valueLength || value.charAt(tokenEnd) != '(') { + throw new IllegalStateException("Function is missing parameter list: " + value); + } + int paramEnd = findMatchingParen(value, tokenEnd); + parts.add(createFunctionNode(treeInfo, value, offset, tokenEnd, paramEnd)); + offset = paramEnd; + } else { + switch (current) { + case '$': + parts.add(createVariableNode(treeInfo, value.substring(offset + 1, tokenEnd))); + offset = tokenEnd; + break; + case '#': + parts.add(createConstantNode(treeInfo, value.substring(offset + 1, tokenEnd))); + offset = tokenEnd; + break; + case '@': + ParseTreeResourceNode node = + new ParseTreeResourceNode( + treeInfo.resources, value.substring(offset, tokenEnd), treeInfo.packageName); + offset = tokenEnd; + if (offset < valueLength && value.charAt(offset) == '(') { + int paramEnd = findMatchingParen(value, offset); + node.addParams(createParamListFromString(treeInfo, value, offset)); + offset = paramEnd; + } + parts.add(node); + break; + case '%': + { + String variableText = value.substring(offset + 1, tokenEnd); + parts.add( + getOrCreateNamedNode( + treeInfo, variableText, new VariableInfo(variableText, VARIABLE_STRING))); + offset = tokenEnd; + } + break; + default: // fall out + } + } + } + } + + if (parts.isEmpty()) { + return new ParseTreeStringConstantNode(""); + } else if (parts.size() == 1) { + return parts.get(0); + } else { + ParseTreeNode arrayNode = new ParseTreeArrayNode(parts); + return new ParseTreeJoinNode(arrayNode, null, false); + } + } + + private static ParseTreeNode createEnumParseTreeFromString( + TreeInfo treeInfo, String value, int enumType) { + if (TextUtils.isEmpty(value)) { + throw new IllegalStateException("Empty value is invalid for enum"); + } + + if (value.charAt(0) == '$') { + return createVariableNode(treeInfo, value.substring(1)); + } else if (value.charAt(0) == '%') { + String variableText = value.substring(1); + return getOrCreateNamedNode( + treeInfo, + variableText, + new VariableInfo(variableText, VARIABLE_ENUM, enumType, 0 /* inId */)); + } else { + Map enumMap = treeInfo.mEnums.get(enumType); + if (enumMap == null) { + throw new IllegalStateException("Unknown enum type: " + enumType); + } + + Integer enumValue = enumMap.get(value); + if (enumValue == null) { + throw new IllegalStateException("Invalid value for enum(" + enumType + ") type: " + value); + } + + return new ParseTreeIntegerConstantNode(enumValue); + } + } + + private static ParseTreeNode createArrayChildParseTreeFromString( + TreeInfo treeInfo, String value) { + if (CONSTANT_PATTERN.matcher(value).matches()) { + return createConstantNode(treeInfo, value.substring(1)); + } else if (NODE_PATTERN.matcher(value).matches()) { + String variableText = value.substring(1); + return getOrCreateNamedNode( + treeInfo, variableText, new VariableInfo(variableText, VARIABLE_ARRAY)); + } else if (VARIABLE_PATTERN.matcher(value).matches()) { + return createVariableNode(treeInfo, value.substring(1)); + } else { + return createStringParseTreeFromString(treeInfo, value); + } + } + + private static final String IF_FORMAT = "if (%s):"; + + private static ParseTreeNode createIfParseTreeFromObject( + TreeInfo treeInfo, JSONObject value, VariableInfo hint) { + Object ifDefinition = value.opt("if"); + Object thenDefinition = value.opt("then"); + Object elseDefinition = value.opt("else"); + + if (thenDefinition == null && elseDefinition == null) { + throw new IllegalStateException("'if' requires either 'then' or 'else'"); + } + + // incompatible types in argument. + @SuppressWarnings("nullness:argument") + ParseTreeNode ifNode = + createParseTreeFromObject(treeInfo, ifDefinition, new VariableInfo("if...", VARIABLE_BOOL)); + ParseTreeNode onTrue = null; + ParseTreeNode onFalse = null; + if (thenDefinition != null) { + onTrue = createParseTreeFromObject(treeInfo, thenDefinition, hint); + } + + if (elseDefinition != null) { + onFalse = createParseTreeFromObject(treeInfo, elseDefinition, hint); + } + + ParseTreeNode result = new ParseTreeIfNode(ifNode, onTrue, onFalse); + + String ifString = ifDefinition instanceof String ? (String) ifDefinition : "node"; + return new ParseTreeCommentNode(result, IF_FORMAT, new Object[] {ifString}); + } + + // incompatible types in argument. + @SuppressWarnings("nullness:argument") + private static ParseTreeNode createJoinParseTreeFromObject(TreeInfo treeInfo, JSONObject value) { + Object joinDefinition = value.opt("join"); + String separator = value.optString("separator", ", "); + boolean pruneEmpty = value.optBoolean("prune_empty", true); + + return new ParseTreeJoinNode( + createArrayParseTreeFromObject(treeInfo, joinDefinition), separator, pruneEmpty); + } + + private static final String FALLBACK_FORMAT = "fallback (%d items):"; + + private static ParseTreeNode createFallbackParseTreeFromObject( + TreeInfo treeInfo, JSONObject value) { + JSONArray fallbackDefinition = value.optJSONArray("fallback"); + if (fallbackDefinition == null) { + throw new IllegalStateException("'fallback' must be an Array"); + } + + List children = new ArrayList<>(); + int length = fallbackDefinition.length(); + for (int i = 0; i < length; i++) { + children.add( + createParseTreeFromObject( + treeInfo, + fallbackDefinition.opt(i), + new VariableInfo("fallback...", VARIABLE_STRING))); + } + ParseTreeNode result = new ParseTreeFallbackNode(children); + return new ParseTreeCommentNode(result, FALLBACK_FORMAT, new Object[] {length}); + } + + private static final String SWITCH_FORMAT = "switch (%s):"; + private static final String CASE_FORMAT = "case %s:"; + + private static ParseTreeNode createSwitchParseTreeFromObject( + TreeInfo treeInfo, JSONObject value, VariableInfo hint) { + String switchVariable = value.optString("switch"); + JSONObject casesDefinition = value.optJSONObject("cases"); + Object defaultDefinition = value.opt("default"); + + if (switchVariable == null) { + throw new IllegalStateException("'switch' condition is missing condition: " + value); + } + + if (casesDefinition == null) { + throw new IllegalStateException("'switch' requires valid cases"); + } + + ParseTreeNode switchNode; + if (VARIABLE_PATTERN.matcher(switchVariable).matches()) { + String switchVariableName = switchVariable.substring(1); + VariableInfo variableInfo = treeInfo.mVariables.get(switchVariableName); + if (variableInfo == null) { + throw new IllegalStateException("Unknown variable: " + switchVariable); + } + + if (variableInfo.variableType != VARIABLE_ENUM + || !treeInfo.mEnums.containsKey(variableInfo.enumType)) { + throw new IllegalStateException("'switch' requires a valid enum: " + switchVariable); + } + + switchNode = createVariableNode(treeInfo, switchVariableName); + } else if (CONSTANT_PATTERN.matcher(switchVariable).matches()) { + switchNode = createConstantNode(treeInfo, switchVariable.substring(1)); + } else { + throw new IllegalStateException( + "'switch' condition must be a variable or constant: " + switchVariable); + } + + int enumType = switchNode.getEnumType(); + Map enums = treeInfo.mEnums.get(enumType); + if (enums == null) { + throw new IllegalStateException("Enum type " + enumType + " doesn't exist"); + } + Map cases = new HashMap<>(); + + ParseTreeNode defaultNode = null; + if (defaultDefinition != null) { + defaultNode = createParseTreeFromObject(treeInfo, defaultDefinition, hint); + } + + Iterator caseNames = casesDefinition.keys(); + while (caseNames.hasNext()) { + String caseName = caseNames.next(); + Integer enumValue = enums.get(caseName); + if (enumValue == null) { + throw new IllegalStateException( + "Enum type " + enumType + " doesn't contain value: " + caseName); + } + // incompatible types in argument. + @SuppressWarnings("nullness:argument") + ParseTreeNode node = createParseTreeFromObject(treeInfo, casesDefinition.opt(caseName), hint); + cases.put( + enumValue, new ParseTreeCommentNode(node, CASE_FORMAT, new Object[] {caseName}, false)); + } + + ParseTreeNode result = new ParseTreeSwitchNode(switchNode, cases, defaultNode); + return new ParseTreeCommentNode(result, SWITCH_FORMAT, new Object[] {switchVariable}); + } + + private static final String FOR_REFERENCE_FORMAT = "for_reference (%s):"; + + private static ParseTreeNode createForReferenceParseTreeNodeFromObject( + TreeInfo treeInfo, JSONObject value, VariableInfo hint) { + String forReferenceVariable = value.optString("for_reference"); + + if (forReferenceVariable == null || !VARIABLE_PATTERN.matcher(forReferenceVariable).matches()) { + throw new IllegalStateException( + "'for_reference' parameter must be a variable: " + forReferenceVariable); + } + + if (!value.has("evaluate")) { + throw new IllegalStateException("'for_reference' must have a node to evaluate"); + } + + String forReferenceVariableName = forReferenceVariable.substring(1); + VariableInfo variableInfo = treeInfo.mVariables.get(forReferenceVariableName); + if (variableInfo == null) { + throw new IllegalStateException("Unknown variable: " + forReferenceVariable); + } + + if (variableInfo.variableType != VARIABLE_REFERENCE) { + throw new IllegalStateException( + "'for_reference' requires a reference: " + forReferenceVariable); + } + + // incompatible types in argument. + @SuppressWarnings("nullness:argument") + ParseTreeNode function = createParseTreeFromObject(treeInfo, value.opt("evaluate"), hint); + ParseTreeForReferenceNode result = + new ParseTreeForReferenceNode( + createVariableNode(treeInfo, forReferenceVariableName), function); + return new ParseTreeCommentNode( + result, FOR_REFERENCE_FORMAT, new Object[] {forReferenceVariable}); + } + + private static final String FOR_EACH_CHILD_FORMAT = "for_each_child (%s):"; + + private static ParseTreeNode createForEachChildParseTreeNodeFromObject( + TreeInfo treeInfo, JSONObject value) { + String forEachChildVariable = value.optString("for_each_child"); + + if (forEachChildVariable == null || !VARIABLE_PATTERN.matcher(forEachChildVariable).matches()) { + throw new IllegalStateException( + "'for_each_child' parameter must be a variable: " + forEachChildVariable); + } + + if (!value.has("evaluate")) { + throw new IllegalStateException("'for_each_child' must have a node to evaluate"); + } + + String forEachChildVariableName = forEachChildVariable.substring(1); + VariableInfo variableInfo = treeInfo.mVariables.get(forEachChildVariableName); + if (variableInfo == null) { + throw new IllegalStateException("Unknown variable: " + forEachChildVariable); + } + + if (variableInfo.variableType != VARIABLE_CHILD_ARRAY) { + throw new IllegalStateException( + "'for_each_child' requires a child array: " + forEachChildVariable); + } + + ParseTreeForEachChildNode result = + new ParseTreeForEachChildNode(createVariableNode(treeInfo, forEachChildVariableName)); + treeInfo.mDeferredForEachChildNodes.add(Pair.create(result, value)); + return new ParseTreeCommentNode( + result, FOR_EACH_CHILD_FORMAT, new Object[] {forEachChildVariable}); + } + + private static final String NAMED_NODE_FORMAT = "%%%s"; + + private static ParseTreeNode getOrCreateNamedNode( + TreeInfo treeInfo, String name, VariableInfo hint) { + ParseTreeNode node = treeInfo.mNamedNodes.get(name); + if (node != null) { + return node; + } + + if (treeInfo.mPendingNamedNodes.contains(name)) { + throw new IllegalStateException("Named node creates a cycle: " + name); + } + + Object nodeDefinition = treeInfo.mNodes.opt(name); + if (nodeDefinition == null) { + throw new IllegalStateException("Missing named node: " + name); + } + treeInfo.mPendingNamedNodes.add(name); + node = createParseTreeFromObject(treeInfo, nodeDefinition, hint); + node = new ParseTreeCommentNode(node, NAMED_NODE_FORMAT, new Object[] {name}); // Wrap in trace. + treeInfo.mNamedNodes.put(name, node); + treeInfo.mPendingNamedNodes.remove(name); + return node; + } + + private static ParseTreeNode createVariableNode(TreeInfo treeInfo, String name) { + VariableInfo varInfo = treeInfo.mVariables.get(name); + if (varInfo == null) { + throw new IllegalStateException("Unknown variable: " + name); + } + if (varInfo.variableType == VARIABLE_ENUM) { + return new ParseTreeVariableNode( + varInfo.name, varInfo.variableType, varInfo.id, varInfo.enumType); + } else { + return new ParseTreeVariableNode(varInfo.name, varInfo.variableType, varInfo.id); + } + } + + private static ParseTreeNode createConstantNode(TreeInfo treeInfo, String name) { + ParseTreeNode node = treeInfo.mConstants.get(name); + if (node == null) { + throw new IllegalStateException("Unknown constant: " + name); + } + return node; + } + + private static ParseTreeNode createFunctionNode( + TreeInfo treeInfo, String value, int nameOffset, int paramOffset, int paramEnd) { + ParseTreeNode result = null; + String name = value.substring(nameOffset, paramOffset); + if (TextUtils.equals(name, "length")) { + List params = createParamListFromString(treeInfo, value, paramOffset); + if (params.size() != 1) { + throw new IllegalStateException("length() takes exactly one argument: " + value); + } + result = new ParseTreeLengthNode(params.get(0)); + } else { + Pair function = treeInfo.mFunctions.get(name); + if (function == null) { + throw new IllegalStateException("Unknown function: " + name); + } + List params = createParamListFromString(treeInfo, value, paramOffset); + result = new ParseTreeFunctionNode(function.first, function.second, params); + } + + return new ParseTreeCommentNode( + result, "Evaluating: %s", new Object[] {value.substring(nameOffset, paramEnd)}); + } + + /** + * Creates a list of ParseTreeNode from a comma separated list of statements. This method assumes + * value is enclosed in () + * + * @param value String to extract the parameter list from. + * @param offset Offset in the string to start from. + * @return A list of ParseTreeNodes generated from each statement in the list. + */ + private static List createParamListFromString( + TreeInfo treeInfo, String value, int offset) { + List result = new ArrayList<>(); + + // Move the offset past the initial '(' + offset += 1; + while (true) { + offset = skipWhitespace(value, offset); + + int end = findStatementEnd(value, offset, OPERATOR_CLASS_NONE); + result.add(createParseTreeFromStatement(treeInfo, value, offset, end)); + offset = end; + + offset = skipWhitespace(value, offset); + + if (value.charAt(offset) == ')') { + break; + } else if (value.charAt(offset) != ',') { + throw new IllegalStateException("Invalid param list: " + value); + } + offset++; + } + return result; + } + + /** Returns the offset to the first non-whitespace character in value after offset. */ + private static int skipWhitespace(String value, int offset) { + int length = value.length(); + while (offset < length && Character.isWhitespace(value.charAt(offset))) { + offset++; + } + return offset; + } + + /** Returns the offset to the character after the ')' that matches the current '(' */ + private static int findMatchingParen(String value, int offset) { + if (value.charAt(offset) != '(') { + throw new IllegalStateException("Expected '(' (" + offset + "): " + value); + } + offset++; + int parenCount = 1; + int length = value.length(); + while (offset < length) { + char current = value.charAt(offset); + if (current == '(') { + parenCount++; + } else if (current == ')') { + parenCount--; + if (parenCount == 0) { + return offset + 1; + } + } + offset++; + } + + throw new IllegalStateException("Missing ending paren: " + value); + } + + /** Returns the offset to the next token. Should only be used when parsing to a string. */ + private static int findNextTokenStartInString(String value, int offset) { + final int length = value.length(); + while (offset < length) { + if (isTokenStart(value, offset)) { + return offset; + } + offset++; + } + return offset; + } + + /** Returns true if the character at offset starts a token */ + private static boolean isTokenStart(String value, int offset) { + char current = value.charAt(offset); + return current == '@' + || current == '#' + || current == '$' + || current == '%' + || isFunctionStart(current); + } + + /** Returns true if the character at offset starts a number */ + private static boolean isNumberStart(String value, int offset) { + char current = value.charAt(offset); + return current == '-' || current == '.' || Character.isDigit(current); + } + + /** Returns true if the character starts an operator */ + private static boolean isOperatorStart(char value) { + return value == '!' + || value == '=' + || value == '>' + || value == '<' + || value == '+' + || value == '-' + || value == '/' + || value == '*' + || value == '|' + || value == '&' + || value == '^'; + } + + /** Returns true if the character starts a function */ + private static boolean isFunctionStart(char value) { + return Character.isAlphabetic(value); + } + + /** + * Find the end of the statement in value starting from offset. A statement resolves to a single + * node. + * + * @param value String to search for a statement + * @param offset Starting point to search from. + * @return Offset in the string to the end of the current statement. + */ + private static int findStatementEnd(String value, int offset, @OperatorClass int leftOperator) { + int valueLength = value.length(); + offset = skipWhitespace(value, offset); + + if (offset >= valueLength) { + throw new IllegalStateException("Invalid statement(" + offset + "): " + value); + } + + while (true) { + if (value.charAt(offset) == '!') { + offset++; + } + + if (value.charAt(offset) == '(') { + offset = findMatchingParen(value, offset); + } else if (value.charAt(offset) == '\'') { + offset = findStringEnd(value, offset); + } else if (isFunctionStart(value.charAt(offset))) { + offset = findTokenEnd(value, offset); + offset = skipWhitespace(value, offset); + offset = findMatchingParen(value, offset); + } else if (isTokenStart(value, offset)) { + offset = findTokenEnd(value, offset); + } else if (isNumberStart(value, offset)) { + offset = findNumberEnd(value, offset); + } else { + throw new IllegalStateException("Invalid statement(" + offset + "): " + value); + } + + int result = offset; + + offset = skipWhitespace(value, offset); + + if (offset >= valueLength) { + return result; + } + + // If the next operator has precedence over leftOperator, then read and consume it and + // the next operand. + char current = value.charAt(offset); + if (isOperatorStart(current)) { + int end = findOperatorEnd(value, offset); + @OperatorClass int operatorClass = getOperatorClass(value.substring(offset, end)); + + if (leftOperator <= operatorClass) { + return result; + } else { + offset = end; + } + offset = skipWhitespace(value, offset); + } else if (current == ',' || current == ')') { + return result; + } else { + throw new IllegalStateException("Invalid statement(" + offset + "): " + value); + } + } + } + + /** + * Find the end of the next token in the string. + * + * @param value String to search for a parsable element + * @return Index of the character immediately after the end of the parsable string. + */ + private static int findTokenEnd(String value, int offset) { + final int length = value.length(); + offset = skipWhitespace(value, offset); + + if (offset >= length) { + throw new IllegalStateException("Could not find token: " + value); + } + + char current = value.charAt(offset); + switch (current) { + case '@': + { + Matcher matcher = RESOURCE_PATTERN.matcher(value); + if (!matcher.find(offset)) { + throw new IllegalStateException("Invalid resource string: " + value); + } + return matcher.end(); + } + + case '#': + { + Matcher matcher = CONSTANT_PATTERN.matcher(value); + if (!matcher.find(offset)) { + throw new IllegalStateException("Invalid constant string: " + value); + } + return matcher.end(); + } + + case '$': + { + Matcher matcher = VARIABLE_PATTERN.matcher(value); + if (!matcher.find(offset)) { + throw new IllegalStateException("Invalid variable string: " + value); + } + return matcher.end(); + } + + case '%': + { + Matcher matcher = NODE_PATTERN.matcher(value); + if (!matcher.find(offset)) { + throw new IllegalStateException("Invalid node string: " + value); + } + return matcher.end(); + } + + default: + break; + } + + if (isFunctionStart(current)) { + Matcher matcher = IDENTIFIER_PATTERN.matcher(value); + if (matcher.find(offset)) { + return matcher.end(); + } + } + throw new IllegalStateException("Could not find token: " + value); + } + + /** + * Finds the end of a string constant starting at the current character. + * + * @param value String to search for a number. + * @param offset Position in the string to start from. + * @return offset of the character after the end of the string. + */ + private static int findStringEnd(String value, int offset) { + if (value.charAt(offset) != '\'') { + throw new IllegalStateException("String doesn't start with ': " + value); + } + + // Move past the initial "'" + offset++; + + // Find the matching "'". Skip any escaped characters. + char current = value.charAt(offset); + int length = value.length(); + while (current != '\'') { + if (current == '\\') { + offset++; + } + offset++; + if (offset >= length) { + throw new IllegalStateException("String missing end \"'\": " + value); + } + current = value.charAt(offset); + } + return offset + 1; + } + + /** + * Finds the end of a number starting at the current character. + * + * @param value String to search for a number. + * @param offset Position in the string to start from. + * @return offset of the character after the end of the number. + */ + private static int findNumberEnd(String value, int offset) { + Matcher matcher = NUMBER_PATTERN.matcher(value); + if (!matcher.find(offset)) { + throw new IllegalStateException("Invalid number in statement: " + value); + } + return matcher.end(); + } + + /** + * Finds the end of an operator starting at the current character. + * + * @param value String to search for an operator. + * @param offset Position in the string to start from. + * @return offset of the character after the end of the operator. + */ + private static int findOperatorEnd(String value, int offset) { + Matcher matcher = OPERATOR_PATTERN.matcher(value); + if (!matcher.find(offset)) { + throw new IllegalStateException("Invalid operator in statement: " + value); + } + return matcher.end(); + } + + @ParseTree.OperatorClass + private static int getOperatorClass(String value) { + if (OPERATOR_CLASS_PLUS_PATTERN.matcher(value).matches()) { + return OPERATOR_CLASS_PLUS; + } else if (OPERATOR_CLASS_MULTIPLY_PATTERN.matcher(value).matches()) { + return OPERATOR_CLASS_MULTIPLY; + } else if (OPERATOR_CLASS_EQUALS_PATTERN.matcher(value).matches()) { + return OPERATOR_CLASS_EQUALS; + } else if (OPERATOR_CLASS_AND_PATTERN.matcher(value).matches()) { + return OPERATOR_CLASS_AND; + } + throw new IllegalStateException("Unknown operator: " + value); + } + + @ParseTree.OperatorClass + private static int getOperatorClass(@ParseTree.Operator int operator) { + switch (operator) { + case OPERATOR_MULTIPLY: + case OPERATOR_DIVIDE: + case OPERATOR_POW: + return OPERATOR_CLASS_MULTIPLY; + case OPERATOR_PLUS: + case OPERATOR_MINUS: + return OPERATOR_CLASS_PLUS; + case OPERATOR_EQUALS: + case OPERATOR_NEQUALS: + case OPERATOR_GT: + case OPERATOR_LT: + case OPERATOR_GE: + case OPERATOR_LE: + return OPERATOR_CLASS_EQUALS; + case OPERATOR_AND: + case OPERATOR_OR: + return OPERATOR_CLASS_AND; + default: // fall out + } + throw new IllegalStateException("Unknown operator: " + operator); + } + + @ParseTree.Operator + private static int getOperator(String value) { + if (value.length() == 1) { + switch (value.charAt(0)) { + case '+': + return OPERATOR_PLUS; + case '-': + return OPERATOR_MINUS; + case '*': + return OPERATOR_MULTIPLY; + case '/': + return OPERATOR_DIVIDE; + case '<': + return OPERATOR_LT; + case '>': + return OPERATOR_GT; + case '^': + return OPERATOR_POW; + default: // fall out + } + } else if (value.length() == 2) { + if (value.equals("&&")) { + return OPERATOR_AND; + } else if (value.equals("||")) { + return OPERATOR_OR; + } else if (value.charAt(1) == '=') { + switch (value.charAt(0)) { + case '=': + return OPERATOR_EQUALS; + case '!': + return OPERATOR_NEQUALS; + case '<': + return OPERATOR_LE; + case '>': + return OPERATOR_GE; + default: // fall out + } + } + } + throw new IllegalStateException("Unknown operator: " + value); + } + + private static String getString(String value, int start, int end) { + int offset = start; + if (value.charAt(offset) != '\'') { + throw new IllegalStateException("String doesn't start with ': " + value); + } + + // Move past the initial "'" + offset++; + + // Find the matching "'". Evaluate any escaped characters. + char current = value.charAt(offset); + StringBuilder output = new StringBuilder(); + while (current != '\'') { + if (current == '\\') { + offset++; + if (offset >= end) { + throw new IllegalStateException("String missing end \"'\": " + value); + } + current = value.charAt(offset); + switch (current) { + case 'n': + current = '\n'; + break; + case 't': + current = '\t'; + break; + case '\\': + current = '\\'; + break; + case '\'': + current = '\''; + break; + case '"': + current = '"'; + break; + default: + /* Do nothing */ + break; + } + } + + // Add the current value to the output. + output.append(current); + + offset++; + if (offset >= end) { + throw new IllegalStateException("String missing end \"'\": " + value); + } + + current = value.charAt(offset); + } + return output.toString(); + } + + private static boolean isValidLvalueType(@VariableType int varType) { + switch (varType) { + case VARIABLE_BOOL: + case VARIABLE_INTEGER: + case VARIABLE_NUMBER: + case VARIABLE_STRING: + case VARIABLE_ENUM: + return true; + + default: + return false; + } + } + + private static boolean isValidRvalueType(@VariableType int varType) { + // Currently, the rules for valid LValue and RValue are the same. + return isValidLvalueType(varType); + } + + public static String variableTypeToString(@VariableType int varType) { + switch (varType) { + case VARIABLE_BOOL: + return "VARIABLE_BOOL"; + case VARIABLE_INTEGER: + return "VARIABLE_INTEGER"; + case VARIABLE_NUMBER: + return "VARIABLE_NUMBER"; + case VARIABLE_STRING: + return "VARIABLE_STRING"; + case VARIABLE_ENUM: + return "VARIABLE_ENUM"; + case VARIABLE_REFERENCE: + return "VARIABLE_REFERENCE"; + case VARIABLE_ARRAY: + return "VARIABLE_ARRAY"; + case VARIABLE_CHILD_ARRAY: + return "VARIABLE_CHILD_ARRAY"; + default: + return "(unhandled)"; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeArrayNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeArrayNode.java new file mode 100644 index 0000000..2fe845c --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeArrayNode.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import java.util.ArrayList; +import java.util.List; + +class ParseTreeArrayNode extends ParseTreeNode { + private final List mChildren = new ArrayList<>(); + + ParseTreeArrayNode(List children) { + for (ParseTreeNode child : children) { + if (!child.canCoerceTo(ParseTree.VARIABLE_STRING) + && !child.canCoerceTo(ParseTree.VARIABLE_ARRAY)) { + throw new IllegalStateException("Only strings and arrays can be children of arrays."); + } + } + mChildren.addAll(children); + } + + @Override + public int getType() { + return ParseTree.VARIABLE_ARRAY; + } + + @Override + public List resolveToArray(ParseTree.VariableDelegate delegate, String logIndent) { + List result = new ArrayList<>(); + for (ParseTreeNode child : mChildren) { + if (child.canCoerceTo(ParseTree.VARIABLE_STRING)) { + result.add(child.resolveToString(delegate, logIndent)); + } else if (child.canCoerceTo(ParseTree.VARIABLE_ARRAY)) { + result.addAll(child.resolveToArray(delegate, logIndent)); + } + } + return result; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeBooleanConstantNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeBooleanConstantNode.java new file mode 100644 index 0000000..fa5849b --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeBooleanConstantNode.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +class ParseTreeBooleanConstantNode extends ParseTreeNode { + private final boolean mValue; + + ParseTreeBooleanConstantNode(boolean value) { + mValue = value; + } + + @Override + public int getType() { + return ParseTree.VARIABLE_BOOL; + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + return mValue; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeCommentNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeCommentNode.java new file mode 100644 index 0000000..2075cd7 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeCommentNode.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import androidx.annotation.Nullable; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.ArrayList; +import java.util.List; + +/** Outputs a comment to verbose logging, and optionally increases the indent level. */ +class ParseTreeCommentNode extends ParseTreeNode { + + private static final String TAG = "ParseTreeCommentNode"; + + @Nullable private final ParseTreeNode mChild; + private final String mCommentFormat; + private final Object[] mArgs; + private final boolean mIndent; + + ParseTreeCommentNode(@Nullable ParseTreeNode child, String commentFormat, Object[] args) { + mChild = child; + mCommentFormat = commentFormat; + mArgs = args; + mIndent = true; + } + + ParseTreeCommentNode(@Nullable ParseTreeNode child, String commentFormat, boolean indent) { + mChild = child; + mCommentFormat = commentFormat; + mArgs = new Object[0]; + mIndent = indent; + } + + ParseTreeCommentNode( + @Nullable ParseTreeNode child, String commentFormat, Object[] args, boolean indent) { + mChild = child; + mCommentFormat = commentFormat; + mArgs = args; + mIndent = indent; + } + + @Override + public int getType() { + return mChild != null ? mChild.getType() : ParseTree.VARIABLE_STRING; + } + + @Override + public boolean canCoerceTo(@ParseTree.VariableType int type) { + return mChild != null && mChild.canCoerceTo(type); + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + logIndent = updateIndent(logIndent); + LogUtils.v(TAG, "%s%s", logIndent, String.format(mCommentFormat, mArgs)); + return mChild != null && mChild.resolveToBoolean(delegate, logIndent); + } + + @Override + public int resolveToInteger(ParseTree.VariableDelegate delegate, String logIndent) { + logIndent = updateIndent(logIndent); + LogUtils.v(TAG, "%s%s", logIndent, String.format(mCommentFormat, mArgs)); + if (mChild != null) { + return mChild.resolveToInteger(delegate, logIndent); + } + return 0; + } + + @Override + public double resolveToNumber(ParseTree.VariableDelegate delegate, String logIndent) { + logIndent = updateIndent(logIndent); + LogUtils.v(TAG, "%s%s", logIndent, String.format(mCommentFormat, mArgs)); + if (mChild != null) { + return mChild.resolveToNumber(delegate, logIndent); + } + return 0; + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + logIndent = updateIndent(logIndent); + LogUtils.v(TAG, "%s%s", logIndent, String.format(mCommentFormat, mArgs)); + if (mChild != null) { + return mChild.resolveToString(delegate, logIndent); + } + return ""; + } + + @Override + @Nullable + public ParseTree.VariableDelegate resolveToReference( + ParseTree.VariableDelegate delegate, String logIndent) { + logIndent = updateIndent(logIndent); + LogUtils.v(TAG, "%s%s", logIndent, String.format(mCommentFormat, mArgs)); + if (mChild != null) { + return mChild.resolveToReference(delegate, logIndent); + } + return null; + } + + @Override + public List resolveToArray(ParseTree.VariableDelegate delegate, String logIndent) { + logIndent = updateIndent(logIndent); + LogUtils.v(TAG, "%s%s", logIndent, String.format(mCommentFormat, mArgs)); + if (mChild != null) { + return mChild.resolveToArray(delegate, logIndent); + } + return new ArrayList<>(); + } + + @Override + public List resolveToChildArray( + ParseTree.VariableDelegate delegate, String logIndent) { + logIndent = updateIndent(logIndent); + LogUtils.v(TAG, "%s%s", logIndent, String.format(mCommentFormat, mArgs)); + if (mChild != null) { + return mChild.resolveToChildArray(delegate, logIndent); + } + return new ArrayList<>(); + } + + private String updateIndent(String logIndent) { + return mIndent ? logIndent += " " : logIndent; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeFallbackNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeFallbackNode.java new file mode 100644 index 0000000..edd405c --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeFallbackNode.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import android.text.TextUtils; +import java.util.ArrayList; +import java.util.List; + +/** + * This class implements a ParseTreeNode that evaluates an array of child nodes in order, and + * returns the result of the first one that returns a non-Empty String. + */ +class ParseTreeFallbackNode extends ParseTreeNode { + private final List mChildren = new ArrayList<>(); + + ParseTreeFallbackNode(List children) { + for (ParseTreeNode child : children) { + if (!child.canCoerceTo(ParseTree.VARIABLE_STRING)) { + throw new IllegalStateException("Only strings can be children of fallback."); + } + } + mChildren.addAll(children); + } + + @Override + public int getType() { + return ParseTree.VARIABLE_STRING; + } + + @Override + public boolean canCoerceTo(@ParseTree.VariableType int type) { + return type == ParseTree.VARIABLE_BOOL || type == ParseTree.VARIABLE_STRING; + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + return !TextUtils.isEmpty(resolveToString(delegate, logIndent)); + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + for (ParseTreeNode child : mChildren) { + CharSequence result = child.resolveToString(delegate, logIndent); + if (!TextUtils.isEmpty(result)) { + return result; + } + } + return ""; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeForEachChildNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeForEachChildNode.java new file mode 100644 index 0000000..6f48f4f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeForEachChildNode.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.ArrayList; +import java.util.List; + +/** This class implements a ParseTreeNode that evaluates a node for each entry in a child array. */ +class ParseTreeForEachChildNode extends ParseTreeNode { + + private static final String TAG = "ParseTreeForEachChildNode"; + + private final ParseTreeNode mChild; + private ParseTreeNode mFunction; + + ParseTreeForEachChildNode(ParseTreeNode child) { + if (!child.canCoerceTo(ParseTree.VARIABLE_CHILD_ARRAY)) { + throw new IllegalStateException("Only child arrays can be children of 'for_each_child'"); + } + + mChild = child; + } + + public void setFunction(ParseTreeNode function) { + mFunction = function; + } + + @Override + public int getType() { + return ParseTree.VARIABLE_ARRAY; + } + + @Override + public List resolveToArray(ParseTree.VariableDelegate delegate, String logIndent) { + if (mFunction == null) { + LogUtils.e(TAG, "Missing function node"); + return new ArrayList<>(); + } + + List result = new ArrayList<>(); + + List children = mChild.resolveToChildArray(delegate, logIndent); + for (ParseTree.VariableDelegate child : children) { + result.add(mFunction.resolveToString(child, logIndent)); + } + return result; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeForReferenceNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeForReferenceNode.java new file mode 100644 index 0000000..c6a2760 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeForReferenceNode.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2017 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.google.android.accessibility.utils.parsetree; + +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; + +class ParseTreeForReferenceNode extends ParseTreeNode { + private final ParseTreeNode mReference; + private final ParseTreeNode mFunction; + + ParseTreeForReferenceNode(ParseTreeNode reference, ParseTreeNode function) { + if (!reference.canCoerceTo(ParseTree.VARIABLE_REFERENCE)) { + throw new IllegalStateException("Only references can be children of 'for_reference'"); + } + + mReference = reference; + mFunction = function; + } + + @Override + public int getType() { + return mFunction.getType(); + } + + @Override + public boolean canCoerceTo(@ParseTree.VariableType int type) { + return mFunction.canCoerceTo(type); + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + ParseTree.VariableDelegate referenceDelegate = + mReference.resolveToReference(delegate, logIndent); + if (referenceDelegate != null) { + boolean result = mFunction.resolveToBoolean(referenceDelegate, logIndent); + return result; + } else { + return false; + } + } + + @Override + public int resolveToInteger(ParseTree.VariableDelegate delegate, String logIndent) { + ParseTree.VariableDelegate referenceDelegate = + mReference.resolveToReference(delegate, logIndent); + if (referenceDelegate != null) { + int result = mFunction.resolveToInteger(referenceDelegate, logIndent); + return result; + } else { + return 0; + } + } + + @Override + public double resolveToNumber(ParseTree.VariableDelegate delegate, String logIndent) { + ParseTree.VariableDelegate referenceDelegate = + mReference.resolveToReference(delegate, logIndent); + if (referenceDelegate != null) { + double result = mFunction.resolveToNumber(referenceDelegate, logIndent); + return result; + } else { + return 0; + } + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + ParseTree.VariableDelegate referenceDelegate = + mReference.resolveToReference(delegate, logIndent); + if (referenceDelegate != null) { + CharSequence result = mFunction.resolveToString(referenceDelegate, logIndent); + return result; + } else { + return ""; + } + } + + @Override + @Nullable + public ParseTree.VariableDelegate resolveToReference( + ParseTree.VariableDelegate delegate, String logIndent) { + ParseTree.VariableDelegate referenceDelegate = + mReference.resolveToReference(delegate, logIndent); + if (referenceDelegate != null) { + ParseTree.VariableDelegate result = + mFunction.resolveToReference(referenceDelegate, logIndent); + return result; + } else { + return null; + } + } + + @Override + public List resolveToArray(ParseTree.VariableDelegate delegate, String logIndent) { + ParseTree.VariableDelegate referenceDelegate = + mReference.resolveToReference(delegate, logIndent); + if (referenceDelegate != null) { + List result = mFunction.resolveToArray(referenceDelegate, logIndent); + return result; + } else { + return new ArrayList<>(); + } + } + + @Override + public List resolveToChildArray( + ParseTree.VariableDelegate delegate, String logIndent) { + ParseTree.VariableDelegate referenceDelegate = + mReference.resolveToReference(delegate, logIndent); + if (referenceDelegate != null) { + List result = + mFunction.resolveToChildArray(referenceDelegate, logIndent); + return result; + } else { + return new ArrayList<>(); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeFunctionNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeFunctionNode.java new file mode 100644 index 0000000..6e9359f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeFunctionNode.java @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +/** ParseTreeFunctionNode */ +class ParseTreeFunctionNode extends ParseTreeNode { + + private static final String TAG = "ParseTreeFunctionNode"; + + @ParseTree.VariableType private final int mType; + private final Object mDelegate; + private final Method mFunction; + private final List mParams = new ArrayList<>(); + private final int[] mParamTypes; + + ParseTreeFunctionNode(Object delegate, Method function, List params) { + Class[] paramTypes = function.getParameterTypes(); + if (params.size() != paramTypes.length) { + throw new IllegalStateException("Incorrect number of params for: " + function); + } + + // Store the parameter types. + mParamTypes = new int[paramTypes.length]; + for (int i = 0; i < paramTypes.length; i++) { + mParamTypes[i] = getVariableType(paramTypes[i]); + if (!params.get(i).canCoerceTo(mParamTypes[i])) { + throw new IllegalStateException("Cannot coerce parameter " + i + " to " + paramTypes[i]); + } + } + + mType = getVariableType(function.getReturnType()); + mDelegate = delegate; + mFunction = function; + // Make sure we can access the function, even if the visibility isn't public. + mFunction.setAccessible(true); + mParams.addAll(params); + } + + @Override + public int getType() { + return mType; + } + + @Override + public boolean canCoerceTo(@ParseTree.VariableType int type) { + if (type == mType) { + return true; + } + + switch (mType) { + case ParseTree.VARIABLE_INTEGER: + return type == ParseTree.VARIABLE_NUMBER; + + case ParseTree.VARIABLE_BOOL: + case ParseTree.VARIABLE_NUMBER: + case ParseTree.VARIABLE_STRING: + case ParseTree.VARIABLE_ENUM: + case ParseTree.VARIABLE_ARRAY: + case ParseTree.VARIABLE_CHILD_ARRAY: + default: + return false; + } + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + if (mType != ParseTree.VARIABLE_BOOL) { + LogUtils.e(TAG, "Cannot coerce to Boolean"); + return false; + } + try { + Boolean result = (Boolean) mFunction.invoke(mDelegate, getParams(delegate, logIndent)); + if (result == null) { + return false; + } + return result; + } catch (Exception e) { + LogUtils.e(TAG, e.toString()); + return false; + } + } + + @Override + public int resolveToInteger(ParseTree.VariableDelegate delegate, String logIndent) { + if (mType != ParseTree.VARIABLE_INTEGER) { + LogUtils.e(TAG, "Cannot coerce to Integer"); + return 0; + } + try { + Integer result = (Integer) mFunction.invoke(mDelegate, getParams(delegate, logIndent)); + if (result == null) { + return 0; + } + return result; + } catch (Exception e) { + LogUtils.e(TAG, e.toString()); + return 0; + } + } + + @Override + public double resolveToNumber(ParseTree.VariableDelegate delegate, String logIndent) { + Object result; + try { + result = mFunction.invoke(mDelegate, getParams(delegate, logIndent)); + if (result == null) { + return 0; + } + } catch (Exception e) { + LogUtils.e(TAG, e.toString()); + return 0; + } + if (mType == ParseTree.VARIABLE_INTEGER) { + return (Integer) result; + } else if (mType == ParseTree.VARIABLE_NUMBER) { + return (Double) result; + } else { + LogUtils.e(TAG, "Cannot coerce to a Number"); + return 0; + } + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + Object result; + try { + result = mFunction.invoke(mDelegate, getParams(delegate, logIndent)); + if (result == null) { + return ""; + } + } catch (Exception e) { + LogUtils.e(TAG, e.toString()); + return ""; + } + if (mType == ParseTree.VARIABLE_STRING) { + return (CharSequence) result; + } else { + return result.toString(); + } + } + + @SuppressWarnings("unchecked") + @Override + public List resolveToArray(ParseTree.VariableDelegate delegate, String logIndent) { + if (mType == ParseTree.VARIABLE_ARRAY) { + try { + List result = + (List) mFunction.invoke(mDelegate, getParams(delegate, logIndent)); + if (result != null) { + return result; + } + } catch (Exception e) { + LogUtils.e(TAG, e.toString()); + } + } else { + LogUtils.e(TAG, "Cannot coerce to an Array"); + } + return new ArrayList<>(); + } + + private Object[] getParams(ParseTree.VariableDelegate delegate, String logIndent) { + Object[] result = new Object[mParamTypes.length]; + for (int i = 0; i < mParamTypes.length; i++) { + switch (mParamTypes[i]) { + case ParseTree.VARIABLE_BOOL: + result[i] = mParams.get(i).resolveToBoolean(delegate, logIndent); + break; + case ParseTree.VARIABLE_INTEGER: + result[i] = mParams.get(i).resolveToInteger(delegate, logIndent); + break; + case ParseTree.VARIABLE_NUMBER: + result[i] = mParams.get(i).resolveToNumber(delegate, logIndent); + break; + case ParseTree.VARIABLE_STRING: + result[i] = mParams.get(i).resolveToString(delegate, logIndent); + break; + case ParseTree.VARIABLE_ARRAY: + result[i] = mParams.get(i).resolveToArray(delegate, logIndent); + break; + case ParseTree.VARIABLE_ENUM: + case ParseTree.VARIABLE_CHILD_ARRAY: + default: + // This should never happen. + LogUtils.e(TAG, "Cannot resolve param " + i); + break; + } + } + return result; + } + + @ParseTree.VariableType + private static int getVariableType(Class clazz) { + if (clazz == boolean.class) { + return ParseTree.VARIABLE_BOOL; + } else if (clazz == int.class) { + return ParseTree.VARIABLE_INTEGER; + } else if (clazz == double.class) { + return ParseTree.VARIABLE_NUMBER; + } else if (clazz == CharSequence.class) { + return ParseTree.VARIABLE_STRING; + } else if (clazz == List.class) { + return ParseTree.VARIABLE_ARRAY; + } + throw new IllegalStateException("Unsupported variable type: " + clazz); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeIfNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeIfNode.java new file mode 100644 index 0000000..3c3d56c --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeIfNode.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import androidx.annotation.Nullable; +import java.util.List; + +class ParseTreeIfNode extends ParseTreeNode { + private final ParseTreeNode mCondition; + private final ParseTreeNode mOnTrue; + private final ParseTreeNode mOnFalse; + private final ParseTreeNode mTypeDelegate; + + ParseTreeIfNode( + ParseTreeNode condition, @Nullable ParseTreeNode onTrue, @Nullable ParseTreeNode onFalse) { + if (onTrue != null) { + mTypeDelegate = onTrue; + } else if (onFalse != null) { + mTypeDelegate = onFalse; + } else { + throw new IllegalStateException("\"if\" requires at least one output condition"); + } + mCondition = condition; + mOnTrue = new ParseTreeCommentNode(onTrue, "if (true)", false); + mOnFalse = new ParseTreeCommentNode(onFalse, "if (false)", false); + } + + @Override + public int getType() { + return mTypeDelegate.getType(); + } + + @Override + public int getEnumType() { + return mTypeDelegate.getEnumType(); + } + + @Override + public boolean canCoerceTo(@ParseTree.VariableType int type) { + return mTypeDelegate.canCoerceTo(type); + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + if (mCondition.resolveToBoolean(delegate, logIndent)) { + return mOnTrue.resolveToBoolean(delegate, logIndent); + } else { + return mOnFalse.resolveToBoolean(delegate, logIndent); + } + } + + @Override + public int resolveToInteger(ParseTree.VariableDelegate delegate, String logIndent) { + if (mCondition.resolveToBoolean(delegate, logIndent)) { + return mOnTrue.resolveToInteger(delegate, logIndent); + } else { + return mOnFalse.resolveToInteger(delegate, logIndent); + } + } + + @Override + public double resolveToNumber(ParseTree.VariableDelegate delegate, String logIndent) { + if (mCondition.resolveToBoolean(delegate, logIndent)) { + return mOnTrue.resolveToNumber(delegate, logIndent); + } else { + return mOnFalse.resolveToNumber(delegate, logIndent); + } + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + if (mCondition.resolveToBoolean(delegate, logIndent)) { + return mOnTrue.resolveToString(delegate, logIndent); + } else { + return mOnFalse.resolveToString(delegate, logIndent); + } + } + + @Override + @Nullable + public ParseTree.VariableDelegate resolveToReference( + ParseTree.VariableDelegate delegate, String logIndent) { + if (mCondition.resolveToBoolean(delegate, logIndent)) { + return mOnTrue.resolveToReference(delegate, logIndent); + } else { + return mOnFalse.resolveToReference(delegate, logIndent); + } + } + + @Override + public List resolveToArray(ParseTree.VariableDelegate delegate, String logIndent) { + if (mCondition.resolveToBoolean(delegate, logIndent)) { + return mOnTrue.resolveToArray(delegate, logIndent); + } else { + return mOnFalse.resolveToArray(delegate, logIndent); + } + } + + @Override + public List resolveToChildArray( + ParseTree.VariableDelegate delegate, String logIndent) { + if (mCondition.resolveToBoolean(delegate, logIndent)) { + return mOnTrue.resolveToChildArray(delegate, logIndent); + } else { + return mOnFalse.resolveToChildArray(delegate, logIndent); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeIntegerConstantNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeIntegerConstantNode.java new file mode 100644 index 0000000..32a176d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeIntegerConstantNode.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +class ParseTreeIntegerConstantNode extends ParseTreeNode { + private final int mValue; + private final int mEnumType; + + ParseTreeIntegerConstantNode(int value) { + mValue = value; + mEnumType = -1; + } + + ParseTreeIntegerConstantNode(int value, int enumType) { + mValue = value; + mEnumType = enumType; + } + + @Override + public int getType() { + if (mEnumType != -1) { + return ParseTree.VARIABLE_ENUM; + } else { + return ParseTree.VARIABLE_INTEGER; + } + } + + @Override + public int getEnumType() { + if (mEnumType == -1) { + return super.getEnumType(); + } else { + return mEnumType; + } + } + + @Override + public boolean canCoerceTo(@ParseTree.VariableType int type) { + if (mEnumType != -1 && type == ParseTree.VARIABLE_ENUM) { + return true; + } + return type == ParseTree.VARIABLE_BOOL + || type == ParseTree.VARIABLE_INTEGER + || type == ParseTree.VARIABLE_NUMBER + || type == ParseTree.VARIABLE_STRING; + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + return mValue != 0; + } + + @Override + public int resolveToInteger(ParseTree.VariableDelegate delegate, String logIndent) { + return mValue; + } + + @Override + public double resolveToNumber(ParseTree.VariableDelegate delegate, String logIndent) { + return mValue; + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + return Integer.toString(mValue); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeJoinNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeJoinNode.java new file mode 100644 index 0000000..a6cc584 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeJoinNode.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import com.google.android.accessibility.utils.SpannableUtils; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** This class implements a ParseTreeNode that joins a child array into a single string. */ +public class ParseTreeJoinNode extends ParseTreeNode { + private final ParseTreeNode mChild; + private final @Nullable CharSequence mSeparator; + private final boolean mPruneEmpty; + + ParseTreeJoinNode(ParseTreeNode child, @Nullable CharSequence separator, boolean pruneEmpty) { + if (!child.canCoerceTo(ParseTree.VARIABLE_ARRAY)) { + throw new IllegalStateException("Only arrays can be children of joins."); + } + + mChild = child; + mSeparator = separator; + mPruneEmpty = pruneEmpty; + } + + @Override + public int getType() { + return ParseTree.VARIABLE_STRING; + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + List values = mChild.resolveToArray(delegate, logIndent); + return joinCharSequences(values, mSeparator, mPruneEmpty); + } + + public static CharSequence joinCharSequences( + List values, @Nullable CharSequence separator, boolean pruneEmpty) { + SpannableStringBuilder builder = new SpannableStringBuilder(); + boolean first = true; + for (CharSequence value : values) { + if (!pruneEmpty || !TextUtils.isEmpty(value)) { + if (separator != null) { + if (first) { + first = false; + } else { + // We have to wrap each separator with a different span, because a single span object + // can only be used once in a CharSequence. + builder.append(SpannableUtils.wrapWithIdentifierSpan(separator)); + } + } + builder.append(value); + } + } + return builder; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeLengthNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeLengthNode.java new file mode 100644 index 0000000..b5f12f7 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeLengthNode.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +class ParseTreeLengthNode extends ParseTreeNode { + private final ParseTreeNode mParam; + + ParseTreeLengthNode(ParseTreeNode param) { + @ParseTree.VariableType int paramType = param.getType(); + if (paramType != ParseTree.VARIABLE_STRING + && paramType != ParseTree.VARIABLE_ARRAY + && paramType != ParseTree.VARIABLE_CHILD_ARRAY) { + throw new IllegalStateException( + "length() only takes strings, arrays, and child arrays as a parameter."); + } + mParam = param; + } + + @Override + public int getType() { + return ParseTree.VARIABLE_INTEGER; + } + + @Override + public boolean canCoerceTo(@ParseTree.VariableType int type) { + return type == ParseTree.VARIABLE_BOOL + || type == ParseTree.VARIABLE_INTEGER + || type == ParseTree.VARIABLE_NUMBER; + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + return getLength(delegate, logIndent) != 0; + } + + @Override + public int resolveToInteger(ParseTree.VariableDelegate delegate, String logIndent) { + return getLength(delegate, logIndent); + } + + @Override + public double resolveToNumber(ParseTree.VariableDelegate delegate, String logIndent) { + return getLength(delegate, logIndent); + } + + private int getLength(ParseTree.VariableDelegate delegate, String logIndent) { + if (mParam.getType() == ParseTree.VARIABLE_STRING) { + CharSequence value = mParam.resolveToString(delegate, logIndent); + return value == null ? 0 : value.length(); + } else { + return mParam.getArrayLength(delegate, logIndent); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeNode.java new file mode 100644 index 0000000..c6dfed4 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeNode.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import androidx.annotation.Nullable; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.ArrayList; +import java.util.List; + +abstract class ParseTreeNode { + + private static final String TAG = "ParseTreeNode"; + + // Returns the type of value this node represents. + @ParseTree.VariableType + public abstract int getType(); + + // Returns the enum type of this node. + public int getEnumType() { + LogUtils.e(TAG, "Cannot coerce " + getClass() + " to Enum"); + return -1; + } + + // Returns true if this node can be resolved to the specified type. + public boolean canCoerceTo(@ParseTree.VariableType int type) { + return type == getType(); + } + + // Resolve the value of this node to a boolean value. + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + LogUtils.e(TAG, "Cannot coerce " + getClass() + " to Boolean"); + return false; + } + + // Resolve the value of this node to an integer value. + public int resolveToInteger(ParseTree.VariableDelegate delegate, String logIndent) { + LogUtils.e(TAG, "Cannot coerce " + getClass() + " to Integer"); + return 0; + } + + // Resolve the value of this node to a number value. + public double resolveToNumber(ParseTree.VariableDelegate delegate, String logIndent) { + LogUtils.e(TAG, "Cannot coerce " + getClass() + " to Number"); + return 0; + } + + // Resolve the value of this node to a string. + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + LogUtils.e(TAG, "Cannot coerce " + getClass() + " to String"); + return ""; + } + + // Resolve the value of this node to a reference. + @Nullable + public ParseTree.VariableDelegate resolveToReference( + ParseTree.VariableDelegate delegate, String logIndent) { + LogUtils.e(TAG, "Cannot coerce " + getClass() + " to Reference"); + return null; + } + + // Resolve the value of this node to an array. + public List resolveToArray(ParseTree.VariableDelegate delegate, String logIndent) { + LogUtils.e(TAG, "Cannot coerce " + getClass() + " to Array"); + return new ArrayList<>(); + } + + // Resolve the value of this node to a child array. + public List resolveToChildArray( + ParseTree.VariableDelegate delegate, String logIndent) { + LogUtils.e(TAG, "Cannot coerce " + getClass() + " to Child Array"); + return new ArrayList<>(); + } + + // Query the length as an array. + int getArrayLength(ParseTree.VariableDelegate delegate, String logIndent) { + LogUtils.e(TAG, "Cannot query array length of " + getClass()); + return 0; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeNotNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeNotNode.java new file mode 100644 index 0000000..372c56f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeNotNode.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +class ParseTreeNotNode extends ParseTreeNode { + private final ParseTreeNode mChild; + + ParseTreeNotNode(ParseTreeNode child) { + if (!child.canCoerceTo(ParseTree.VARIABLE_BOOL)) { + throw new IllegalStateException("Cannot coerce child node to boolean"); + } + + mChild = child; + } + + @Override + public int getType() { + return ParseTree.VARIABLE_BOOL; + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + return !mChild.resolveToBoolean(delegate, logIndent); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeNumberConstantNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeNumberConstantNode.java new file mode 100644 index 0000000..83312b3 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeNumberConstantNode.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +class ParseTreeNumberConstantNode extends ParseTreeNode { + private final double mValue; + + ParseTreeNumberConstantNode(double value) { + mValue = value; + } + + @Override + public int getType() { + return ParseTree.VARIABLE_NUMBER; + } + + @Override + public boolean canCoerceTo(@ParseTree.VariableType int type) { + return type == ParseTree.VARIABLE_BOOL + || type == ParseTree.VARIABLE_NUMBER + || type == ParseTree.VARIABLE_STRING; + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + return mValue != 0; + } + + @Override + public double resolveToNumber(ParseTree.VariableDelegate delegate, String logIndent) { + return mValue; + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + return Double.toString(mValue); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeOperatorNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeOperatorNode.java new file mode 100644 index 0000000..f09b9a3 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeOperatorNode.java @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import com.google.android.libraries.accessibility.utils.log.LogUtils; + +class ParseTreeOperatorNode extends ParseTreeNode { + + private static final String TAG = "ParseTreeOperatorNode"; + + @ParseTree.Operator private final int mOperator; + private final ParseTreeNode mLvalue; + private final ParseTreeNode mRvalue; + + ParseTreeOperatorNode( + @ParseTree.Operator int operator, ParseTreeNode lvalue, ParseTreeNode rvalue) { + mOperator = operator; + mLvalue = lvalue; + mRvalue = rvalue; + } + + @Override + public int getType() { + switch (mOperator) { + case ParseTree.OPERATOR_PLUS: + case ParseTree.OPERATOR_MINUS: + case ParseTree.OPERATOR_MULTIPLY: + case ParseTree.OPERATOR_DIVIDE: + case ParseTree.OPERATOR_POW: + if (mLvalue.getType() == ParseTree.VARIABLE_NUMBER + || mRvalue.getType() == ParseTree.VARIABLE_NUMBER) { + return ParseTree.VARIABLE_NUMBER; + } else { + return ParseTree.VARIABLE_INTEGER; + } + + case ParseTree.OPERATOR_EQUALS: + case ParseTree.OPERATOR_NEQUALS: + case ParseTree.OPERATOR_GT: + case ParseTree.OPERATOR_LT: + case ParseTree.OPERATOR_GE: + case ParseTree.OPERATOR_LE: + case ParseTree.OPERATOR_AND: + case ParseTree.OPERATOR_OR: + return ParseTree.VARIABLE_BOOL; + default: + return ParseTree.VARIABLE_BOOL; + } + } + + @Override + public boolean canCoerceTo(@ParseTree.VariableType int type) { + switch (mOperator) { + case ParseTree.OPERATOR_PLUS: + case ParseTree.OPERATOR_MINUS: + case ParseTree.OPERATOR_MULTIPLY: + case ParseTree.OPERATOR_DIVIDE: + case ParseTree.OPERATOR_POW: + return type == ParseTree.VARIABLE_NUMBER + || type == ParseTree.VARIABLE_INTEGER + || type == ParseTree.VARIABLE_STRING; + + case ParseTree.OPERATOR_EQUALS: + case ParseTree.OPERATOR_NEQUALS: + case ParseTree.OPERATOR_GT: + case ParseTree.OPERATOR_LT: + case ParseTree.OPERATOR_GE: + case ParseTree.OPERATOR_LE: + case ParseTree.OPERATOR_AND: + case ParseTree.OPERATOR_OR: + return type == ParseTree.VARIABLE_BOOL; + default: + return false; + } + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + switch (mOperator) { + case ParseTree.OPERATOR_PLUS: + case ParseTree.OPERATOR_MINUS: + case ParseTree.OPERATOR_MULTIPLY: + case ParseTree.OPERATOR_DIVIDE: + case ParseTree.OPERATOR_POW: + LogUtils.e(TAG, "Cannot coerce Number to Boolean"); + return false; + + case ParseTree.OPERATOR_EQUALS: + return checkEquals(delegate, logIndent); + case ParseTree.OPERATOR_NEQUALS: + return !checkEquals(delegate, logIndent); + case ParseTree.OPERATOR_GT: + return mLvalue.resolveToNumber(delegate, logIndent) + > mRvalue.resolveToNumber(delegate, logIndent); + case ParseTree.OPERATOR_LT: + return mLvalue.resolveToNumber(delegate, logIndent) + < mRvalue.resolveToNumber(delegate, logIndent); + case ParseTree.OPERATOR_GE: + return mLvalue.resolveToNumber(delegate, logIndent) + >= mRvalue.resolveToNumber(delegate, logIndent); + case ParseTree.OPERATOR_LE: + return mLvalue.resolveToNumber(delegate, logIndent) + <= mRvalue.resolveToNumber(delegate, logIndent); + case ParseTree.OPERATOR_AND: + return mLvalue.resolveToBoolean(delegate, logIndent) + && mRvalue.resolveToBoolean(delegate, logIndent); + case ParseTree.OPERATOR_OR: + return mLvalue.resolveToBoolean(delegate, logIndent) + || mRvalue.resolveToBoolean(delegate, logIndent); + + default: + return false; + } + } + + @Override + public int resolveToInteger(ParseTree.VariableDelegate delegate, String logIndent) { + switch (mOperator) { + case ParseTree.OPERATOR_PLUS: + return (int) + (mLvalue.resolveToNumber(delegate, logIndent) + + mRvalue.resolveToNumber(delegate, logIndent)); + case ParseTree.OPERATOR_MINUS: + return (int) + (mLvalue.resolveToNumber(delegate, logIndent) + - mRvalue.resolveToNumber(delegate, logIndent)); + case ParseTree.OPERATOR_MULTIPLY: + return (int) + (mLvalue.resolveToNumber(delegate, logIndent) + * mRvalue.resolveToNumber(delegate, logIndent)); + case ParseTree.OPERATOR_DIVIDE: + return (int) + (mLvalue.resolveToNumber(delegate, logIndent) + / mRvalue.resolveToNumber(delegate, logIndent)); + case ParseTree.OPERATOR_POW: + return (int) + Math.pow( + mLvalue.resolveToNumber(delegate, logIndent), + mRvalue.resolveToNumber(delegate, logIndent)); + + case ParseTree.OPERATOR_EQUALS: + case ParseTree.OPERATOR_NEQUALS: + case ParseTree.OPERATOR_GT: + case ParseTree.OPERATOR_LT: + case ParseTree.OPERATOR_GE: + case ParseTree.OPERATOR_LE: + case ParseTree.OPERATOR_AND: + case ParseTree.OPERATOR_OR: + default: + LogUtils.e(TAG, "Cannot coerce Boolean to Integer"); + return 0; + } + } + + @Override + public double resolveToNumber(ParseTree.VariableDelegate delegate, String logIndent) { + switch (mOperator) { + case ParseTree.OPERATOR_PLUS: + return mLvalue.resolveToNumber(delegate, logIndent) + + mRvalue.resolveToNumber(delegate, logIndent); + case ParseTree.OPERATOR_MINUS: + return mLvalue.resolveToNumber(delegate, logIndent) + - mRvalue.resolveToNumber(delegate, logIndent); + case ParseTree.OPERATOR_MULTIPLY: + return mLvalue.resolveToNumber(delegate, logIndent) + * mRvalue.resolveToNumber(delegate, logIndent); + case ParseTree.OPERATOR_DIVIDE: + return mLvalue.resolveToNumber(delegate, logIndent) + / mRvalue.resolveToNumber(delegate, logIndent); + case ParseTree.OPERATOR_POW: + return Math.pow( + mLvalue.resolveToNumber(delegate, logIndent), + mRvalue.resolveToNumber(delegate, logIndent)); + + case ParseTree.OPERATOR_EQUALS: + case ParseTree.OPERATOR_NEQUALS: + case ParseTree.OPERATOR_GT: + case ParseTree.OPERATOR_LT: + case ParseTree.OPERATOR_GE: + case ParseTree.OPERATOR_LE: + case ParseTree.OPERATOR_AND: + case ParseTree.OPERATOR_OR: + default: + LogUtils.e(TAG, "Cannot coerce Boolean to Number"); + return 0; + } + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + return Double.toString(resolveToNumber(delegate, logIndent)); + } + + private boolean checkEquals(ParseTree.VariableDelegate delegate, String logIndent) { + @ParseTree.VariableType int ltype = mLvalue.getType(); + @ParseTree.VariableType int rtype = mRvalue.getType(); + if (ltype == ParseTree.VARIABLE_BOOL && rtype == ParseTree.VARIABLE_BOOL) { + return mLvalue.resolveToBoolean(delegate, logIndent) + == mRvalue.resolveToBoolean(delegate, logIndent); + } else if ((ltype == ParseTree.VARIABLE_INTEGER || ltype == ParseTree.VARIABLE_ENUM) + && (rtype == ParseTree.VARIABLE_INTEGER || rtype == ParseTree.VARIABLE_ENUM)) { + return mLvalue.resolveToInteger(delegate, logIndent) + == mRvalue.resolveToInteger(delegate, logIndent); + } else if (ltype == ParseTree.VARIABLE_INTEGER && rtype == ParseTree.VARIABLE_NUMBER) { + return mLvalue.resolveToInteger(delegate, logIndent) + == mRvalue.resolveToNumber(delegate, logIndent); + } else if (ltype == ParseTree.VARIABLE_NUMBER && rtype == ParseTree.VARIABLE_INTEGER) { + return mLvalue.resolveToNumber(delegate, logIndent) + == mRvalue.resolveToInteger(delegate, logIndent); + } else if (ltype == ParseTree.VARIABLE_NUMBER && rtype == ParseTree.VARIABLE_NUMBER) { + return mLvalue.resolveToNumber(delegate, logIndent) + == mRvalue.resolveToNumber(delegate, logIndent); + } + LogUtils.e(TAG, "Incompatible types in compare: %d, %d", ltype, rtype); + return false; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeResourceNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeResourceNode.java new file mode 100644 index 0000000..2bbf26d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeResourceNode.java @@ -0,0 +1,320 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import android.content.res.Resources; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import androidx.annotation.IntDef; +import androidx.annotation.VisibleForTesting; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +class ParseTreeResourceNode extends ParseTreeNode { + + private static final String TAG = "ParseTreeResourceNode"; + + private static final Pattern RESOURCE_PATTERN = + Pattern.compile("@(string|plurals|raw|array)/(\\w+)"); + + @IntDef({TYPE_STRING, TYPE_PLURALS, TYPE_RESOURCE_ID}) + @Retention(RetentionPolicy.SOURCE) + @interface Type {} + + static final int TYPE_STRING = 0; + static final int TYPE_PLURALS = 1; + static final int TYPE_RESOURCE_ID = 2; + + private final Resources mResources; + private final int mResourceId; + @Type private final int mType; + private final List mParams = new ArrayList<>(); + + ParseTreeResourceNode(Resources resources, String resourceName, String packageName) { + mResources = resources; + + Matcher matcher = RESOURCE_PATTERN.matcher(resourceName); + if (!matcher.matches()) { + throw new IllegalArgumentException("Resource parameter is malformed: " + resourceName); + } + + String type = matcher.group(1); + String name = matcher.group(2); + if (type == null || name == null) { + throw new IllegalArgumentException("Resource parameter is malformed: " + resourceName); + } + + switch (type) { + case "string": + mType = TYPE_STRING; + break; + case "plurals": + mType = TYPE_PLURALS; + break; + case "raw": + case "array": + mType = TYPE_RESOURCE_ID; + break; + default: + throw new IllegalArgumentException("Unknown resource type: " + type); + } + + mResourceId = mResources.getIdentifier(name, type, packageName); + + if (mResourceId == 0) { + throw new IllegalStateException("Missing resource: " + resourceName); + } + } + + void addParams(List params) { + mParams.addAll(params); + } + + @Override + public int getType() { + switch (mType) { + case TYPE_STRING: + case TYPE_PLURALS: + return ParseTree.VARIABLE_STRING; + + case TYPE_RESOURCE_ID: + default: + return ParseTree.VARIABLE_INTEGER; + } + } + + @Override + public int resolveToInteger(ParseTree.VariableDelegate delegate, String logIndent) { + return mResourceId; + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + switch (mType) { + case TYPE_STRING: + Object[] stringParamList = getParamList(mParams, 0, delegate, logIndent); + String templateString = mResources.getString(mResourceId); + return SpannedStringUtils.getSpannedFormattedString(templateString, stringParamList); + + case TYPE_PLURALS: + if (mParams.isEmpty() || mParams.get(0).getType() != ParseTree.VARIABLE_INTEGER) { + LogUtils.e(TAG, "First parameter for plurals must be the count"); + return ""; + } + + Object[] pluralParamList = getParamList(mParams, 1, delegate, logIndent); + String templatePlural = + mResources.getQuantityString( + mResourceId, mParams.get(0).resolveToInteger(delegate, logIndent)); + return SpannedStringUtils.getSpannedFormattedString(templatePlural, pluralParamList); + + case TYPE_RESOURCE_ID: + LogUtils.e(TAG, "Cannot resolve resource ID to string"); + return ""; + + default: + LogUtils.e(TAG, "Unknown resource type: " + mType); + return ""; + } + } + + private static Object[] getParamList( + List params, + int start, + ParseTree.VariableDelegate delegate, + String logIndent) { + List result = new ArrayList<>(); + for (ParseTreeNode node : params.subList(start, params.size())) { + switch (node.getType()) { + case ParseTree.VARIABLE_BOOL: + result.add(node.resolveToBoolean(delegate, logIndent)); + break; + + case ParseTree.VARIABLE_STRING: + result.add(node.resolveToString(delegate, logIndent)); + break; + + case ParseTree.VARIABLE_INTEGER: + result.add(node.resolveToInteger(delegate, logIndent)); + break; + + case ParseTree.VARIABLE_NUMBER: + result.add(node.resolveToNumber(delegate, logIndent)); + break; + + case ParseTree.VARIABLE_ENUM: + case ParseTree.VARIABLE_ARRAY: + case ParseTree.VARIABLE_CHILD_ARRAY: + LogUtils.e(TAG, "Cannot format string with type: " + node.getType()); + break; + default: // fall out + } + } + return result.toArray(); + } + + /** The utility class provide ways to keep spans in template string. */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + protected static class SpannedStringUtils { + /** + * Creates CharSequence from {@code templateString} and its {@code parameters}. And spans in + * parameters are keep in result. + * + * @param templateString template string that may contains parameters with spans. + * @param parameters object arrays that are supposed but not necessary to be Spanned. If it is + * Spanned, the spans are keep in result Spannable. + * @return CharSequence that composed by formatted template string and parameters. + */ + private static CharSequence getSpannedFormattedString( + String templateString, Object[] parameters) { + List stringTypeList = new ArrayList<>(); + for (Object param : parameters) { + if (param instanceof CharSequence) { + stringTypeList.add((CharSequence) param); + } + } + + String formattedString = String.format(templateString, parameters); + if (stringTypeList.isEmpty()) { + return formattedString; + } + + CharSequence expandableTemplate = toExpandableTemplate(templateString, parameters); + try { + // It will throw IllegalArgumentException if the template requests a value that was not + // provided, or if more than 9 values are provided. + return TextUtils.expandTemplate( + expandableTemplate, stringTypeList.toArray(new CharSequence[stringTypeList.size()])); + } catch (IllegalArgumentException exception) { + LogUtils.e( + TAG, + "TextUtils.expandTemplate fail then try copySpansFromTemplateParameters." + + " Exception=%s ", + exception); + // This is a fall-back method that may copy spans inaccurately + return copySpansFromTemplateParameters( + formattedString, stringTypeList.toArray(new CharSequence[stringTypeList.size()])); + } + } + + /** + * Creates CharSequence from template string by its parameters. The template string will be + * transformed to contain "^1"-style placeholder values dynamically to match the format of + * {@link TextUtils#expandTemplate(CharSequence, CharSequence...)} and formatted by other + * none-string type parameters. + * + * @param templateString template string that may contains parameters with strings. + * @param parameters object arrays that are supposed but not necessary to be string. If it is + * string, the corresponding placeholder value will be changed to "^1"-style. If not string + * type, the placeholder is kept and adjust the index. + * @return CharSequence that composed by template string with "^1"-style placeholder values. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + protected static CharSequence toExpandableTemplate(String templateString, Object[] parameters) { + String expandTemplateString = templateString; + List otherTypeList = new ArrayList<>(); + + int spanTypeIndex = 1; + int otherTypeIndex = 1; + for (int i = 1; i <= parameters.length; i++) { + Object param = parameters[i - 1]; + if (param instanceof CharSequence) { + // replaces string type "%1$s" or "%s" to "^1" and so on. + if (expandTemplateString.contains("%" + i + "$s")) { + expandTemplateString = + expandTemplateString.replace(("%" + i + "$s"), ("^" + spanTypeIndex)); + } else if (expandTemplateString.contains("%s")) { + expandTemplateString = expandTemplateString.replaceFirst("%s", ("^" + spanTypeIndex)); + } + spanTypeIndex++; + } else { + // keeps and assigns correct index to other type parameters + expandTemplateString = expandTemplateString.replace(("%" + i), ("%" + otherTypeIndex)); + otherTypeList.add(param); + otherTypeIndex++; + } + } + return String.format(expandTemplateString, otherTypeList.toArray()); + } + + /** + * Creates spannable from text that includes some Spanned. If a template parameter occurs + * multiple times in the final text, this function copies the parameter's spans to the first + * instance. + * + * @param text some text that potentially contains CharSequence parameters. + * @param templateParameters CharSequence arrays that contains spans and need to be copied to + * result Spannable. + * @return Spannable object that contains incoming text and spans from templateParameters. + */ + private static Spannable copySpansFromTemplateParameters( + String text, CharSequence[] templateParameters) { + SpannableString result = new SpannableString(text); + for (CharSequence params : templateParameters) { + if (params instanceof Spanned) { + int index = text.indexOf(params.toString()); + if (index >= 0) { + copySpans(result, (Spanned) params, index); + } + } + } + return result; + } + + /** + * Utility that copies spans from {@code fromSpan} to {@code toSpan}. + * + * @param toSpan Spannable that is supposed to contain fromSpan. + * @param fromSpan Spannable that could contain spans that would be copied to toSpan. + * @param toSpanStartIndex Starting index of occurrence fromSpan in toSpan. + */ + private static void copySpans(Spannable toSpan, Spanned fromSpan, int toSpanStartIndex) { + if (toSpanStartIndex < 0 || toSpanStartIndex >= toSpan.length()) { + LogUtils.e( + TAG, + "startIndex parameter (%d) is out of toSpan length %d", + toSpanStartIndex, + toSpan.length()); + return; + } + + Object[] spans = fromSpan.getSpans(0, fromSpan.length(), Object.class); + if (spans != null && spans.length > 0) { + for (Object span : spans) { + int spanStartIndex = fromSpan.getSpanStart(span); + int spanEndIndex = fromSpan.getSpanEnd(span); + if (spanStartIndex >= spanEndIndex) { + continue; + } + int spanFlags = fromSpan.getSpanFlags(span); + toSpan.setSpan( + span, + (toSpanStartIndex + spanStartIndex), + (toSpanStartIndex + spanEndIndex), + spanFlags); + } + } + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeStringConstantNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeStringConstantNode.java new file mode 100644 index 0000000..a9ae679 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeStringConstantNode.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +class ParseTreeStringConstantNode extends ParseTreeNode { + private final CharSequence mValue; + + ParseTreeStringConstantNode(CharSequence value) { + mValue = value; + } + + @Override + public int getType() { + return ParseTree.VARIABLE_STRING; + } + + @Override + public boolean canCoerceTo(@ParseTree.VariableType int type) { + return type == ParseTree.VARIABLE_BOOL || type == ParseTree.VARIABLE_STRING; + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + return mValue.length() != 0; + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + return mValue; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeSwitchNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeSwitchNode.java new file mode 100644 index 0000000..340cffd --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeSwitchNode.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import androidx.annotation.Nullable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class ParseTreeSwitchNode extends ParseTreeNode { + private final ParseTreeNode mCondition; + private final Map mCases = new HashMap<>(); + private final ParseTreeNode mDefault; + + ParseTreeSwitchNode( + ParseTreeNode condition, + Map cases, + @Nullable ParseTreeNode defaultCase) { + if (cases.isEmpty()) { + throw new IllegalStateException("'switch' requires at least one output condition"); + } + mCondition = condition; + mCases.putAll(cases); + mDefault = + new ParseTreeCommentNode(defaultCase, "switch: Falling back to default value", false); + } + + @Override + public int getType() { + return mCases.entrySet().iterator().next().getValue().getType(); + } + + @Override + public int getEnumType() { + return mCases.entrySet().iterator().next().getValue().getEnumType(); + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + int value = mCondition.resolveToInteger(delegate, logIndent); + ParseTreeNode node = mCases.get(value); + if (node != null) { + return node.resolveToBoolean(delegate, logIndent); + } else { + return mDefault.resolveToBoolean(delegate, logIndent); + } + } + + @Override + public int resolveToInteger(ParseTree.VariableDelegate delegate, String logIndent) { + int value = mCondition.resolveToInteger(delegate, logIndent); + ParseTreeNode node = mCases.get(value); + if (node != null) { + return node.resolveToInteger(delegate, logIndent); + } else { + return mDefault.resolveToInteger(delegate, logIndent); + } + } + + @Override + public double resolveToNumber(ParseTree.VariableDelegate delegate, String logIndent) { + int value = mCondition.resolveToInteger(delegate, logIndent); + ParseTreeNode node = mCases.get(value); + if (node != null) { + return node.resolveToNumber(delegate, logIndent); + } else { + return mDefault.resolveToNumber(delegate, logIndent); + } + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + int value = mCondition.resolveToInteger(delegate, logIndent); + ParseTreeNode node = mCases.get(value); + if (node != null) { + return node.resolveToString(delegate, logIndent); + } else { + return mDefault.resolveToString(delegate, logIndent); + } + } + + @Override + @Nullable + public ParseTree.VariableDelegate resolveToReference( + ParseTree.VariableDelegate delegate, String logIndent) { + int value = mCondition.resolveToInteger(delegate, logIndent); + ParseTreeNode node = mCases.get(value); + if (node != null) { + return node.resolveToReference(delegate, logIndent); + } else { + return mDefault.resolveToReference(delegate, logIndent); + } + } + + @Override + public List resolveToArray(ParseTree.VariableDelegate delegate, String logIndent) { + int value = mCondition.resolveToInteger(delegate, logIndent); + ParseTreeNode node = mCases.get(value); + if (node != null) { + return node.resolveToArray(delegate, logIndent); + } else { + return mDefault.resolveToArray(delegate, logIndent); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeVariableNode.java b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeVariableNode.java new file mode 100644 index 0000000..d17fc8b --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/parsetree/ParseTreeVariableNode.java @@ -0,0 +1,328 @@ +/* + * Copyright (C) 2016 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.google.android.accessibility.utils.parsetree; + +import android.text.TextUtils; +import androidx.annotation.Nullable; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.ArrayList; +import java.util.List; + +class ParseTreeVariableNode extends ParseTreeNode { + + private static final String TAG = "ParseTreeVariableNode"; + + private final String mName; + @ParseTree.VariableType private final int mType; + private final int mId; + private final int mEnumType; + + ParseTreeVariableNode(String name, @ParseTree.VariableType int type, int id) { + if (type == ParseTree.VARIABLE_ENUM) { + throw new IllegalStateException("Enum type required for enums"); + } + mName = name; + mType = type; + mId = id; + mEnumType = -1; + } + + ParseTreeVariableNode(String name, @ParseTree.VariableType int type, int id, int enumType) { + if (type != ParseTree.VARIABLE_ENUM) { + throw new IllegalStateException("Enum type only applicable to enums"); + } + mName = name; + mType = type; + mId = id; + mEnumType = enumType; + } + + @Override + public int getType() { + return mType; + } + + @Override + public int getEnumType() { + return mEnumType; + } + + @Override + public boolean canCoerceTo(@ParseTree.VariableType int type) { + if (type == mType) { + return true; + } + + switch (mType) { + case ParseTree.VARIABLE_STRING: + case ParseTree.VARIABLE_NUMBER: + return type == ParseTree.VARIABLE_BOOL; + + case ParseTree.VARIABLE_INTEGER: + return type == ParseTree.VARIABLE_NUMBER || type == ParseTree.VARIABLE_BOOL; + + case ParseTree.VARIABLE_ENUM: + return type == ParseTree.VARIABLE_INTEGER; + + case ParseTree.VARIABLE_BOOL: + case ParseTree.VARIABLE_REFERENCE: + case ParseTree.VARIABLE_ARRAY: + case ParseTree.VARIABLE_CHILD_ARRAY: + default: + return false; + } + } + + @Override + public boolean resolveToBoolean(ParseTree.VariableDelegate delegate, String logIndent) { + boolean value = false; + switch (mType) { + case ParseTree.VARIABLE_BOOL: + value = delegate.getBoolean(mId); + break; + case ParseTree.VARIABLE_INTEGER: + value = delegate.getInteger(mId) != 0; + break; + case ParseTree.VARIABLE_NUMBER: + value = delegate.getNumber(mId) != 0; + break; + case ParseTree.VARIABLE_STRING: + value = !TextUtils.isEmpty(delegate.getString(mId)); + break; + case ParseTree.VARIABLE_ENUM: + case ParseTree.VARIABLE_REFERENCE: + case ParseTree.VARIABLE_ARRAY: + case ParseTree.VARIABLE_CHILD_ARRAY: + LogUtils.e( + TAG, + "Cannot coerce variable to boolean: %s %s", + ParseTree.variableTypeToString(mType), + mName); + value = false; + break; + default: + // This should never happen. + LogUtils.e(TAG, "Unknown variable type: %d", mType); + return false; + } + + LogUtils.v( + TAG, + "%sParseTreeVariableNode.resolveToBoolean() name=%s value=%s", + logIndent, + mName, + value); + return value; + } + + @Override + public int resolveToInteger(ParseTree.VariableDelegate delegate, String logIndent) { + switch (mType) { + case ParseTree.VARIABLE_INTEGER: + { + int value = delegate.getInteger(mId); + LogUtils.v( + TAG, + "%sParseTreeVariableNode.resolveToInteger() name=%s value=%s", + logIndent, + mName, + value); + return value; + } + case ParseTree.VARIABLE_ENUM: + { + int value = delegate.getEnum(mId); + LogUtils.v( + TAG, + "%sParseTreeVariableNode.resolveToInteger() name=%s value=%s", + logIndent, + mName, + value); + return value; + } + case ParseTree.VARIABLE_NUMBER: + case ParseTree.VARIABLE_BOOL: + case ParseTree.VARIABLE_STRING: + case ParseTree.VARIABLE_REFERENCE: + case ParseTree.VARIABLE_ARRAY: + case ParseTree.VARIABLE_CHILD_ARRAY: + LogUtils.e( + TAG, + "Cannot coerce variable to integer: %s %s", + ParseTree.variableTypeToString(mType), + mName); + return 0; + default: // fall out + } + + // This should never happen. + LogUtils.e(TAG, "Unknown variable type: %d", mType); + return 0; + } + + @Override + public double resolveToNumber(ParseTree.VariableDelegate delegate, String logIndent) { + switch (mType) { + case ParseTree.VARIABLE_INTEGER: + { + int value = delegate.getInteger(mId); + LogUtils.v( + TAG, + "%sParseTreeVariableNode.resolveToNumber() name=%s value=%s", + logIndent, + mName, + value); + return value; + } + case ParseTree.VARIABLE_NUMBER: + { + double value = delegate.getNumber(mId); + LogUtils.v( + TAG, + "%sParseTreeVariableNode.resolveToNumber() name=%s value=%s", + logIndent, + mName, + value); + return value; + } + case ParseTree.VARIABLE_BOOL: + case ParseTree.VARIABLE_STRING: + case ParseTree.VARIABLE_ENUM: + case ParseTree.VARIABLE_REFERENCE: + case ParseTree.VARIABLE_ARRAY: + case ParseTree.VARIABLE_CHILD_ARRAY: + LogUtils.e( + TAG, + "Cannot coerce variable to number: %s %s", + ParseTree.variableTypeToString(mType), + mName); + return 0; + default: // fall out + } + + // This should never happen. + LogUtils.e(TAG, "Unknown variable type: %d", mType); + return 0; + } + + @Override + public CharSequence resolveToString(ParseTree.VariableDelegate delegate, String logIndent) { + switch (mType) { + case ParseTree.VARIABLE_STRING: + { + CharSequence value = delegate.getString(mId); + if (value == null) { + value = ""; + } + LogUtils.v( + TAG, + "%sParseTreeVariableNode.resolveToString() name=%s value=%s", + logIndent, + mName, + value); + return value; + } + case ParseTree.VARIABLE_BOOL: + case ParseTree.VARIABLE_INTEGER: + case ParseTree.VARIABLE_NUMBER: + case ParseTree.VARIABLE_ENUM: + case ParseTree.VARIABLE_REFERENCE: + case ParseTree.VARIABLE_ARRAY: + case ParseTree.VARIABLE_CHILD_ARRAY: + LogUtils.e( + TAG, + "Cannot coerce variable to string: %s %s", + ParseTree.variableTypeToString(mType), + mName); + return ""; + default: // fall out + } + + // This should never happen. + LogUtils.e(TAG, "Unknown variable type: %d", mType); + return ""; + } + + @Override + @Nullable + public ParseTree.VariableDelegate resolveToReference( + ParseTree.VariableDelegate delegate, String logIndent) { + if (mType == ParseTree.VARIABLE_REFERENCE) { + return delegate.getReference(mId); + } + return null; + } + + @Override + public List resolveToArray(ParseTree.VariableDelegate delegate, String logIndent) { + List result = new ArrayList<>(); + if (mType == ParseTree.VARIABLE_ARRAY) { + int length = delegate.getArrayLength(mId); + for (int i = 0; i < length; i++) { + CharSequence value = delegate.getArrayStringElement(mId, i); + if (value == null) { + value = ""; + } + LogUtils.v( + TAG, + "%sParseTreeVariableNode.resolveToArray() name=%s value=%s", + logIndent, + mName, + value); + result.add(value); + } + } else { + LogUtils.e( + TAG, + "Cannot coerce variable to array: %s %s", + ParseTree.variableTypeToString(mType), + mName); + } + return result; + } + + @Override + public List resolveToChildArray( + ParseTree.VariableDelegate delegate, String logIndent) { + List result = new ArrayList<>(); + if (mType == ParseTree.VARIABLE_CHILD_ARRAY) { + int length = delegate.getArrayLength(mId); + for (int i = 0; i < length; i++) { + ParseTree.VariableDelegate value = delegate.getArrayChildElement(mId, i); + if (value != null) { + result.add(value); + } + } + } else { + LogUtils.e( + TAG, + "Cannot coerce variable to child array: %s %s", + ParseTree.variableTypeToString(mType), + mName); + } + return result; + } + + @Override + int getArrayLength(ParseTree.VariableDelegate delegate, String logIndent) { + if (mType == ParseTree.VARIABLE_ARRAY || mType == ParseTree.VARIABLE_CHILD_ARRAY) { + return delegate.getArrayLength(mId); + } else { + return super.getArrayLength(delegate, logIndent); + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/screencapture/ScreenshotCapture.java b/utils/src/main/java/com/google/android/accessibility/utils/screencapture/ScreenshotCapture.java new file mode 100644 index 0000000..a9f244b --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/screencapture/ScreenshotCapture.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2020 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.google.android.accessibility.utils.screencapture; + +import android.accessibilityservice.AccessibilityService; +import android.accessibilityservice.AccessibilityService.ScreenshotResult; +import android.accessibilityservice.AccessibilityService.TakeScreenshotCallback; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.hardware.HardwareBuffer; +import android.view.Display; +import com.google.android.accessibility.utils.FeatureSupport; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.android.libraries.accessibility.utils.screencapture.ScreenCaptureController.CaptureListener; + +/** + * The utility class supports to take screenshot by native {@link + * AccessibilityService#takeScreenshot} API. It's not applicable for the platform before Android R. + */ +public class ScreenshotCapture { + private static final String TAG = "ScreenshotCapture"; + + /** Prevent from instance creation for this utility class. */ + private ScreenshotCapture() { + throw new AssertionError(); + } + + /** + * Method to take screenshot with native support method. + * + * @param service The Accessibility service which has already been granted this feature by + * android:canTakeScreenshot="true" in accessibility-service xml. + * @param listener Call back when got a result; success/failure depends on the screenCapture + * argument. + */ + public static void takeScreenshot(AccessibilityService service, CaptureListener listener) { + if (!FeatureSupport.canTakeScreenShotByAccessibilityService()) { + LogUtils.e(TAG, "Taking screenshot but platform's not support"); + listener.onScreenCaptureFinished(/* screenCapture= */ null, /* isFormatSupported= */ false); + return; + } + service.takeScreenshot( + Display.DEFAULT_DISPLAY, + service.getMainExecutor(), + new TakeScreenshotCallback() { + @Override + public void onFailure(int errorCode) { + LogUtils.e(TAG, "Taking screenshot but failed [error:" + errorCode + "]"); + listener.onScreenCaptureFinished( + /* screenCapture= */ null, /* isFormatSupported= */ false); + } + + @Override + public void onSuccess(ScreenshotResult screenshot) { + Bitmap bitmap; + try (HardwareBuffer hardwareBuffer = screenshot.getHardwareBuffer()) { + bitmap = Bitmap.wrapHardwareBuffer(hardwareBuffer, screenshot.getColorSpace()); + } + + if (bitmap != null) { + Bitmap bitmapCopy = + bitmap.copy(Config.ARGB_8888, /* isMutable= */ bitmap.isMutable()); + bitmap.recycle(); + bitmap = bitmapCopy; + } + + listener.onScreenCaptureFinished(bitmap, /* isFormatSupported= */ bitmap != null); + } + }); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/screenunderstanding/IconAnnotationsDetector.java b/utils/src/main/java/com/google/android/accessibility/utils/screenunderstanding/IconAnnotationsDetector.java new file mode 100644 index 0000000..83753ce --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/screenunderstanding/IconAnnotationsDetector.java @@ -0,0 +1,17 @@ +package com.google.android.accessibility.utils.screenunderstanding; + +import androidx.annotation.Nullable; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import java.util.Locale; + +/** An interface for detecting icon annotations from a specific screenshot. */ +public interface IconAnnotationsDetector extends ScreenAnnotationsDetector { + + /** + * If icons identified by screen understanding matches the specified {@code node}, returns the + * localized label of the matched icons. Returns {@code null} if no detected icon matches the + * specified {@code node}. + */ + @Nullable + CharSequence getIconLabel(Locale locale, AccessibilityNodeInfoCompat node); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/screenunderstanding/ScreenAnnotationsDetector.java b/utils/src/main/java/com/google/android/accessibility/utils/screenunderstanding/ScreenAnnotationsDetector.java new file mode 100644 index 0000000..10f37ac --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/screenunderstanding/ScreenAnnotationsDetector.java @@ -0,0 +1,62 @@ +/* + * Copyright 2021 Google Inc. + * + * 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.google.android.accessibility.utils.screenunderstanding; + +import android.graphics.Bitmap; +import android.graphics.Rect; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import java.util.Locale; + +/** An interface for detecting screen annotations (including icons) from a specific screenshot. */ +public interface ScreenAnnotationsDetector { + + /** Callback interface to be invoked when detecting screen annotations has finished. */ + interface ProcessScreenshotResultListener { + /** + * Invoked when detecting screen annotations from a given screenshot has finished. + * + *

When detecting screen annotations has successfully finished, invoke {@link + * IconAnnotationsDetector#getIconLabel(Locale, AccessibilityNodeInfoCompat)} to get the label + * of the detected icons for a given node. + */ + void onDetectionFinished(boolean success); + } + + /** Starts the screen annotations detector. */ + void start(); + + /** Shuts down the screen annotations detector and releases resources. */ + void shutdown(); + + /** + * Asynchronously processes the provided {@code screenshot} to detect annotations. + * + * @param screenshot The screenshot of the entire screen for which annotations should be detected + * @param listener The {@link ProcessScreenshotResultListener#onDetectionFinished(boolean)} will + * be invoked when detecting annotations from the given {@code screenshot} has finished + */ + void processScreenshotAsync(Bitmap screenshot, ProcessScreenshotResultListener listener); + + /** Invoked when a UI change happens which changes the whole screen content. */ + void clearWholeScreenCache(); + + /** + * Invoked when a UI change happens which only changes the content inside the specific {@code + * rect}. + */ + void clearPartialScreenCache(Rect rect); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalStrategy.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalStrategy.java new file mode 100644 index 0000000..f9ee021 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalStrategy.java @@ -0,0 +1,585 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.traversal; + +import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_ACCESSIBILITY; +import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT; + +import android.graphics.Rect; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.accessibility.utils.BuildVersionUtils; +import com.google.android.accessibility.utils.Filter; +import com.google.android.accessibility.utils.FocusFinder; +import com.google.android.accessibility.utils.WebInterfaceUtils; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.Nullable; + +public class DirectionalTraversalStrategy implements TraversalStrategy { + + /** The root node within which to traverse. */ + private AccessibilityNodeInfoCompat mRoot; + + /** Instance for finding Accessibility/Input focus. */ + private final FocusFinder focusFinder; + + /** The cached on-screen bounds of the root node. */ + private final Rect mRootRect; + + /** The bounds of the root node, padded slightly for intersection checks. */ + private final Rect mRootRectPadded; + + /** A set of all visited nodes in mRoot's hierarchy. */ + private final Set visitedNodes = new HashSet<>(); + + /** A list of only focusable nodes. */ + private final List mFocusables = new ArrayList<>(); + + /** The set of focusable nodes that have focusable descendants. */ + private final Set mContainers = new HashSet<>(); + + /** Cache of nodes that have speech for use by AccessibilityNodeInfoUtils. */ + private final Map mSpeakingNodesCache = new HashMap<>(); + + public DirectionalTraversalStrategy(AccessibilityNodeInfoCompat root, FocusFinder focusFinder) { + mRoot = AccessibilityNodeInfoCompat.obtain(root); + this.focusFinder = focusFinder; + + mRootRect = new Rect(); + mRoot.getBoundsInScreen(mRootRect); + + int fudge = -(mRootRect.width() / 20); // 5% fudge factor to catch objects near edge. + mRootRectPadded = new Rect(mRootRect); + mRootRectPadded.inset(fudge, fudge); + + processNodes(mRoot, false /* forceRefresh */); + + // Before N, sometimes AccessibilityNodeInfo is not properly updated after transitions + // occur. This was fixed in a system framework change for N. REFERTO for context. + // To work-around, manually refresh AccessibilityNodeInfo if it initially + // looks like there's nothing to focus on. + if (mFocusables.isEmpty() && !BuildVersionUtils.isAtLeastN()) { + processNodes(mRoot, true /* forceRefresh */); + } + } + + /** + * Goes through root and its descendant nodes, sorting out the focusable nodes and the container + * nodes for use in finding focus. Does not re-process visitedNodes. + * + * @return whether the root is focusable or has focusable children in its hierarchy + */ + private boolean processNodes(AccessibilityNodeInfoCompat root, boolean forceRefresh) { + if (root == null || visitedNodes.contains(root)) { + return false; + } + + if (forceRefresh) { + root.refresh(); + } + + Rect currentRect = new Rect(); + root.getBoundsInScreen(currentRect); + + // Determine if the node is inside mRootRect (within a fudge factor). If it is outside, we + // will optimize by skipping its entire hierarchy. + if (!Rect.intersects(currentRect, mRootRectPadded)) { + return false; + } + + AccessibilityNodeInfoCompat rootNode = AccessibilityNodeInfoCompat.obtain(root); + visitedNodes.add(rootNode); + + // When we reach a node that supports web navigation, we traverse using the web navigation + // actions, so we should not add any of its descendants to the list of focusable nodes. + if (WebInterfaceUtils.hasNativeWebContent(rootNode)) { + mFocusables.add(rootNode); + return true; + } else { + boolean isFocusable = + AccessibilityNodeInfoUtils.shouldFocusNode(rootNode, mSpeakingNodesCache); + if (isFocusable) { + mFocusables.add(rootNode); + } + + boolean hasFocusableDescendants = false; + int childCount = rootNode.getChildCount(); + for (int i = 0; i < childCount; ++i) { + AccessibilityNodeInfoCompat child = rootNode.getChild(i); + if (child != null) { + hasFocusableDescendants |= processNodes(child, forceRefresh); + } + } + + if (hasFocusableDescendants) { + mContainers.add(rootNode); + } + + return isFocusable || hasFocusableDescendants; + } + } + + @Override + public @Nullable AccessibilityNodeInfoCompat findFocus( + AccessibilityNodeInfoCompat startNode, int direction) { + if (startNode == null) { + return null; + } else if (startNode.equals(mRoot)) { + return getFirstOrderedFocus(); + } + + Rect focusedRect = new Rect(); + getAssumedRectInScreen(startNode, focusedRect); + + return findFocus(startNode, focusedRect, direction); + } + + public @Nullable AccessibilityNodeInfoCompat findFocus( + AccessibilityNodeInfoCompat focused, Rect focusedRect, int direction) { + // Using roughly the same algorithm as + // frameworks/base/core/java/android/view/FocusFinder.java#findNextFocusInAbsoluteDirection + + Rect bestCandidateRect = new Rect(focusedRect); + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_LEFT: + bestCandidateRect.offset(focusedRect.width() + 1, 0); + break; + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + bestCandidateRect.offset(-(focusedRect.width() + 1), 0); + break; + case TraversalStrategy.SEARCH_FOCUS_UP: + bestCandidateRect.offset(0, focusedRect.height() + 1); + break; + case TraversalStrategy.SEARCH_FOCUS_DOWN: + bestCandidateRect.offset(0, -(focusedRect.height() + 1)); + break; + default: // fall out + } + + AccessibilityNodeInfoCompat closest = null; + for (AccessibilityNodeInfoCompat focusable : mFocusables) { + // Skip the currently-focused view. + if (focusable.equals(focused) || focusable.equals(mRoot)) { + continue; + } + + Rect otherRect = new Rect(); + getAssumedRectInScreen(focusable, otherRect); + + if (isBetterCandidate(direction, focusedRect, otherRect, bestCandidateRect)) { + bestCandidateRect.set(otherRect); + closest = focusable; + } + } + + if (closest != null) { + return AccessibilityNodeInfoCompat.obtain(closest); + } + + return null; + } + + /** + * Selects an item to focus when there is no current accessibility focus. + * + *

Uses a two-pronged strategy. First tries to see if there is an input-focused node, and if + * so, returns that node. Otherwise, returns the item that an OrderedTraversalStrategy would first + * focus; this has the advantage of working nicely for both LTR and RTL users. + */ + private @Nullable AccessibilityNodeInfoCompat getFirstOrderedFocus() { + Filter filter = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return node != null && mFocusables.contains(node); + } + }; + + // 1. Attempt to find input-focused node. + AccessibilityNodeInfoCompat inputFocused = focusFinder.findFocusCompat(FOCUS_INPUT); + + AccessibilityNodeInfoCompat target = + AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(inputFocused, filter); + if (target != null) { + return target; + } + + // 2. Just use the OrderedTraversalStrategy. + final OrderedTraversalStrategy orderedStrategy = new OrderedTraversalStrategy(mRoot); + + // Should not need to obtain() here; the inner code should do this for us. + return TraversalStrategyUtils.searchFocus( + orderedStrategy, mRoot, TraversalStrategy.SEARCH_FOCUS_FORWARD, filter); + } + + @Override + public @Nullable AccessibilityNodeInfoCompat focusInitial( + AccessibilityNodeInfoCompat root, int direction) { + if (root == null) { + return null; + } + + Rect rootRect = new Rect(); + root.getBoundsInScreen(rootRect); + + AccessibilityNodeInfoCompat focusedNode = focusFinder.findFocusCompat(FOCUS_ACCESSIBILITY); + + Rect searchRect = new Rect(); + if (focusedNode != null) { + getSearchStartRect(focusedNode, direction, searchRect); + } else if (direction == TraversalStrategy.SEARCH_FOCUS_LEFT) { + searchRect.set(rootRect.right, rootRect.top, rootRect.right + 1, rootRect.bottom); + } else if (direction == TraversalStrategy.SEARCH_FOCUS_RIGHT) { + searchRect.set(rootRect.left - 1, rootRect.top, rootRect.left, rootRect.bottom); + } else if (direction == TraversalStrategy.SEARCH_FOCUS_UP) { + searchRect.set(rootRect.left, rootRect.bottom, rootRect.right, rootRect.bottom + 1); + } else { + searchRect.set(rootRect.left, rootRect.top - 1, rootRect.right, rootRect.top); + } + + return findFocus(focusedNode, searchRect, direction); + } + + @Override + public Map getSpeakingNodesCache() { + return null; + } + + /** + * TODO: Remove once all dependencies have been removed. + * + * @deprecated Accessibility is discontinuing recycling. + */ + @Override + @Deprecated + public void recycle() {} + + /** + * Returns the bounding rect of the given node for directional navigation purposes. Any node that + * is a container of a focusable node will be reduced to a strip at its very top edge. + */ + private void getAssumedRectInScreen(AccessibilityNodeInfoCompat node, Rect assumedRect) { + node.getBoundsInScreen(assumedRect); + if (mContainers.contains(node)) { + assumedRect.set(assumedRect.left, assumedRect.top, assumedRect.right, assumedRect.top + 1); + } + } + + /** + * Given a focus rectangle, returns another rectangle that is placed at the beginning of the row + * or column of the focused object, depending on the direction in which we are navigating. + * + *

Example: + * + *

+   *  +---------+
+   *  |         | node=#
+   * A|      #  | When direction=TraversalStrategy.SEARCH_FOCUS_RIGHT, then a rectangle A with
+   *  |         |   same width and height as node gets returned.
+   *  |         | When direction=TraversalStrategy.SEARCH_FOCUS_UP, then a rectangle B with same
+   *  +---------+   width and height as node gets returned.
+   *         B
+   * 
+ */ + private void getSearchStartRect(AccessibilityNodeInfoCompat node, int direction, Rect rect) { + Rect focusedRect = new Rect(); + node.getBoundsInScreen(focusedRect); + + Rect rootBounds = new Rect(); + mRoot.getBoundsInScreen(rootBounds); + + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_LEFT: // Start from right and move leftwards. + rect.set( + rootBounds.right, + focusedRect.top, + rootBounds.right + focusedRect.width(), + focusedRect.bottom); + break; + case TraversalStrategy.SEARCH_FOCUS_RIGHT: // Start from left and move rightwards. + rect.set( + rootBounds.left - focusedRect.width(), + focusedRect.top, + rootBounds.left, + focusedRect.bottom); + break; + case TraversalStrategy.SEARCH_FOCUS_UP: // Start from bottom and move upwards. + rect.set( + focusedRect.left, + rootBounds.bottom, + focusedRect.right, + rootBounds.bottom + focusedRect.height()); + break; + case TraversalStrategy.SEARCH_FOCUS_DOWN: // Start from top and move downwards. + rect.set( + focusedRect.left, + rootBounds.top - focusedRect.height(), + focusedRect.right, + rootBounds.top); + break; + default: + throw new IllegalArgumentException("direction must be a SearchDirection"); + } + } + + /* + * BEGIN CODE COPIED FROM frameworks/base/core/java/android/view/FocusFinder.java + * These lines were last revised 2009-03-03 in revision 9066cfe9. + * Modifications from original: + * - Uses TraversalStrategy.SEARCH_FOCUS_* constants instead of View.FOCUS_* constants + * - getWeightedDistanceFor() returns MAX_VALUE for very large values to prevent overflow + */ + + /** + * Is rect1 a better candidate than rect2 for a focus search in a particular direction from a + * source rect? This is the core routine that determines the order of focus searching. + * + * @param direction the direction (up, down, left, right) + * @param source The source we are searching from + * @param rect1 The candidate rectangle + * @param rect2 The current best candidate. + * @return Whether the candidate is the new best. + */ + boolean isBetterCandidate(int direction, Rect source, Rect rect1, Rect rect2) { + + // to be a better candidate, need to at least be a candidate in the first + // place :) + if (!isCandidate(source, rect1, direction)) { + return false; + } + + // we know that rect1 is a candidate.. if rect2 is not a candidate, + // rect1 is better + if (!isCandidate(source, rect2, direction)) { + return true; + } + + // if rect1 is better by beam, it wins + if (beamBeats(direction, source, rect1, rect2)) { + return true; + } + + // if rect2 is better, then rect1 cant' be :) + if (beamBeats(direction, source, rect2, rect1)) { + return false; + } + + // otherwise, do fudge-tastic comparison of the major and minor axis + return (getWeightedDistanceFor( + majorAxisDistance(direction, source, rect1), + minorAxisDistance(direction, source, rect1)) + < getWeightedDistanceFor( + majorAxisDistance(direction, source, rect2), + minorAxisDistance(direction, source, rect2))); + } + + /** + * One rectangle may be another candidate than another by virtue of being exclusively in the beam + * of the source rect. + * + * @return Whether rect1 is a better candidate than rect2 by virtue of it being in src's beam + */ + boolean beamBeats(int direction, Rect source, Rect rect1, Rect rect2) { + final boolean rect1InSrcBeam = beamsOverlap(direction, source, rect1); + final boolean rect2InSrcBeam = beamsOverlap(direction, source, rect2); + + // if rect1 isn't exclusively in the src beam, it doesn't win + if (rect2InSrcBeam || !rect1InSrcBeam) { + return false; + } + + // we know rect1 is in the beam, and rect2 is not + + // if rect1 is to the direction of, and rect2 is not, rect1 wins. + // for example, for direction left, if rect1 is to the left of the source + // and rect2 is below, then we always prefer the in beam rect1, since rect2 + // could be reached by going down. + if (!isToDirectionOf(direction, source, rect2)) { + return true; + } + + // for horizontal directions, being exclusively in beam always wins + if ((direction == TraversalStrategy.SEARCH_FOCUS_LEFT + || direction == TraversalStrategy.SEARCH_FOCUS_RIGHT)) { + return true; + } + + // for vertical directions, beams only beat up to a point: + // now, as long as rect2 isn't completely closer, rect1 wins + // e.g for direction down, completely closer means for rect2's top + // edge to be closer to the source's top edge than rect1's bottom edge. + return (majorAxisDistance(direction, source, rect1) + < majorAxisDistanceToFarEdge(direction, source, rect2)); + } + + /** + * Fudge-factor opportunity: how to calculate distance given major and minor axis distances. + * Warning: this fudge factor is finely tuned, be sure to run all focus tests if you dare tweak + * it. + */ + int getWeightedDistanceFor(int majorAxisDistance, int minorAxisDistance) { + if (majorAxisDistance > 10000 || minorAxisDistance > 10000) { + return Integer.MAX_VALUE; + } else { + // Won't overflow; max possible value = 1400000000 < Integer.MAX_VALUE. + return 13 * majorAxisDistance * majorAxisDistance + minorAxisDistance * minorAxisDistance; + } + } + + /** + * Is destRect a candidate for the next focus given the direction? This checks whether the dest is + * at least partially to the direction of (e.g left of) from source. + * + *

Includes an edge case for an empty rect (which is used in some cases when searching from a + * point on the screen). + */ + boolean isCandidate(Rect srcRect, Rect destRect, int direction) { + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_LEFT: + return (srcRect.right > destRect.right || srcRect.left >= destRect.right) + && srcRect.left > destRect.left; + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + return (srcRect.left < destRect.left || srcRect.right <= destRect.left) + && srcRect.right < destRect.right; + case TraversalStrategy.SEARCH_FOCUS_UP: + return (srcRect.bottom > destRect.bottom || srcRect.top >= destRect.bottom) + && srcRect.top > destRect.top; + case TraversalStrategy.SEARCH_FOCUS_DOWN: + return (srcRect.top < destRect.top || srcRect.bottom <= destRect.top) + && srcRect.bottom < destRect.bottom; + default: // fall out + } + throw new IllegalArgumentException("direction must be a SearchDirection"); + } + + /** + * Do the "beams" w.r.t the given direction's axis of rect1 and rect2 overlap? + * + * @param direction the direction (up, down, left, right) + * @param rect1 The first rectangle + * @param rect2 The second rectangle + * @return whether the beams overlap + */ + boolean beamsOverlap(int direction, Rect rect1, Rect rect2) { + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_LEFT: + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + return (rect2.bottom >= rect1.top) && (rect2.top <= rect1.bottom); + case TraversalStrategy.SEARCH_FOCUS_UP: + case TraversalStrategy.SEARCH_FOCUS_DOWN: + return (rect2.right >= rect1.left) && (rect2.left <= rect1.right); + default: // fall out + } + throw new IllegalArgumentException("direction must be a SearchDirection"); + } + + /** e.g for left, is 'to left of' */ + boolean isToDirectionOf(int direction, Rect src, Rect dest) { + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_LEFT: + return src.left >= dest.right; + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + return src.right <= dest.left; + case TraversalStrategy.SEARCH_FOCUS_UP: + return src.top >= dest.bottom; + case TraversalStrategy.SEARCH_FOCUS_DOWN: + return src.bottom <= dest.top; + default: // fall out + } + throw new IllegalArgumentException("direction must be a SearchDirection"); + } + + /** + * @return The distance from the edge furthest in the given direction of source to the edge + * nearest in the given direction of dest. If the dest is not in the direction from source, + * return 0. + */ + static int majorAxisDistance(int direction, Rect source, Rect dest) { + return Math.max(0, majorAxisDistanceRaw(direction, source, dest)); + } + + static int majorAxisDistanceRaw(int direction, Rect source, Rect dest) { + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_LEFT: + return source.left - dest.right; + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + return dest.left - source.right; + case TraversalStrategy.SEARCH_FOCUS_UP: + return source.top - dest.bottom; + case TraversalStrategy.SEARCH_FOCUS_DOWN: + return dest.top - source.bottom; + default: // fall out + } + throw new IllegalArgumentException("direction must be a SearchDirection"); + } + + /** + * @return The distance along the major axis w.r.t the direction from the edge of source to the + * far edge of dest. If the dest is not in the direction from source, return 1 (to break ties + * with {@link #majorAxisDistance}). + */ + static int majorAxisDistanceToFarEdge(int direction, Rect source, Rect dest) { + return Math.max(1, majorAxisDistanceToFarEdgeRaw(direction, source, dest)); + } + + static int majorAxisDistanceToFarEdgeRaw(int direction, Rect source, Rect dest) { + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_LEFT: + return source.left - dest.left; + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + return dest.right - source.right; + case TraversalStrategy.SEARCH_FOCUS_UP: + return source.top - dest.top; + case TraversalStrategy.SEARCH_FOCUS_DOWN: + return dest.bottom - source.bottom; + default: // fall out + } + throw new IllegalArgumentException("direction must be a SearchDirection"); + } + + /** + * Find the distance on the minor axis w.r.t the direction to the nearest edge of the destination + * rectangle. + * + * @param direction the direction (up, down, left, right) + * @param source The source rect. + * @param dest The destination rect. + * @return The distance. + */ + static int minorAxisDistance(int direction, Rect source, Rect dest) { + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_LEFT: + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + // the distance between the center verticals + return Math.abs(((source.top + source.height() / 2) - ((dest.top + dest.height() / 2)))); + case TraversalStrategy.SEARCH_FOCUS_UP: + case TraversalStrategy.SEARCH_FOCUS_DOWN: + // the distance between the center horizontals + return Math.abs(((source.left + source.width() / 2) - ((dest.left + dest.width() / 2)))); + default: // fall out + } + throw new IllegalArgumentException("direction must be a SearchDirection"); + } + + /* END CODE COPIED FROM frameworks/base/core/java/android/view/FocusFinder.java */ + +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeCachedBoundsCalculator.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeCachedBoundsCalculator.java new file mode 100644 index 0000000..fc8f422 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeCachedBoundsCalculator.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.traversal; + +import android.graphics.Rect; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Calculates the utility bounds of the node. If node is not supposed to get accessibility focus the + * utility bounds is calculated on the base of minimum rect that contains all accessibility + * focusable nodes inside node hierarchy rooted by this node. + */ +public class NodeCachedBoundsCalculator { + + private static final String TAG = "NodeCachedBoundsCalculator"; + + private static final Rect EMPTY_RECT = new Rect(); + + private Map mBoundsMap = new HashMap<>(); + private Map mSpeakNodesCache; + private Set mCalculatingNodes = new HashSet<>(); + private Rect mTempRect = new Rect(); + + public void setSpeakNodesCache(Map speakNodeCache) { + mSpeakNodesCache = speakNodeCache; + } + + public @Nullable Rect getBounds(AccessibilityNodeInfoCompat node) { + Rect bounds = getBoundsInternal(node); + if (bounds.equals(EMPTY_RECT)) { + return null; + } + + return bounds; + } + + private Rect getBoundsInternal(AccessibilityNodeInfoCompat node) { + if (node == null) { + return EMPTY_RECT; + } + + if (mCalculatingNodes.contains(node)) { + LogUtils.w(TAG, "node tree loop detected while calculating node bounds"); + return EMPTY_RECT; + } + + Rect bounds = mBoundsMap.get(node); + if (bounds == null) { + mCalculatingNodes.add(node); + bounds = fetchBound(node); + mBoundsMap.put(node, bounds); + mCalculatingNodes.remove(node); + } + + return bounds; + } + + private Rect fetchBound(AccessibilityNodeInfoCompat node) { + if (node == null || !AccessibilityNodeInfoUtils.isVisible(node)) { + return EMPTY_RECT; + } + + if (AccessibilityNodeInfoUtils.shouldFocusNode(node, mSpeakNodesCache)) { + Rect bounds = new Rect(); + node.getBoundsInScreen(bounds); + return bounds; + } + + int childCount = node.getChildCount(); + int minTop = Integer.MAX_VALUE; + int minLeft = Integer.MAX_VALUE; + int maxBottom = Integer.MIN_VALUE; + int maxRight = Integer.MIN_VALUE; + AccessibilityNodeInfoCompat child = null; + boolean hasChildBounds = false; + for (int i = 0; i < childCount; i++) { + try { + child = node.getChild(i); + Rect bounds = getBoundsInternal(child); + if (!bounds.equals(EMPTY_RECT)) { + hasChildBounds = true; + if (bounds.top < minTop) { + minTop = bounds.top; + } + + if (bounds.left < minLeft) { + minLeft = bounds.left; + } + + if (bounds.right > maxRight) { + maxRight = bounds.right; + } + + if (bounds.bottom > maxBottom) { + maxBottom = bounds.bottom; + } + } + } finally { + } + } + + Rect bounds = new Rect(); + node.getBoundsInScreen(bounds); + if (hasChildBounds) { + bounds.top = Math.max(minTop, bounds.top); + bounds.left = Math.max(minLeft, bounds.left); + bounds.right = Math.min(maxRight, bounds.right); + bounds.bottom = Math.min(maxBottom, bounds.bottom); + } + + return bounds; + } + + /** + * If node is not supposed to be accessibility focused by TalkBack NodeBoundsCalculator calculates + * useful bounds of focusable children. The method checks if the node uses its children useful + * bounds or uses its own bounds + */ + public boolean usesChildrenBounds(AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + + Rect bounds = getBounds(node); + if (bounds == null) { + return false; + } + + node.getBoundsInScreen(mTempRect); + return !mTempRect.equals(bounds); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeFocusFinder.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeFocusFinder.java new file mode 100644 index 0000000..55eb77d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeFocusFinder.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * 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.google.android.accessibility.utils.traversal; + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.AccessibilityNodeInfoRef; +import org.checkerframework.checker.nullness.qual.Nullable; + +public class NodeFocusFinder { + public static final int SEARCH_FORWARD = 1; + public static final int SEARCH_BACKWARD = -1; + + /** + * Perform in-order navigation from a given node in a particular direction. + * + * @param node The starting node. + * @param direction The direction to travel. + * @return The next node in the specified direction, or {@code null} if there are no more nodes. + */ + public static @Nullable AccessibilityNodeInfoCompat focusSearch( + AccessibilityNodeInfoCompat node, int direction) { + final AccessibilityNodeInfoRef ref = AccessibilityNodeInfoRef.unOwned(node); + if (ref == null) { + return null; + } + + switch (direction) { + case SEARCH_FORWARD: + { + if (!ref.nextInOrder()) { + return null; + } + return ref.release(); + } + case SEARCH_BACKWARD: + { + if (!ref.previousInOrder()) { + return null; + } + return ref.release(); + } + default: // fall out + } + + return null; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalController.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalController.java new file mode 100644 index 0000000..ed94aa1 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalController.java @@ -0,0 +1,328 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.traversal; + +import android.view.accessibility.AccessibilityNodeInfo; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.TreeDebug; +import com.google.android.accessibility.utils.WebInterfaceUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.LinkedHashMap; +import java.util.Map; +import org.checkerframework.checker.nullness.qual.Nullable; + +public class OrderedTraversalController { + + private static final String TAG = "OrderedTraversalCont"; + + private @Nullable WorkingTree mTree; + private Map mNodeTreeMap; + private Map mSpeakNodesCache; + + public OrderedTraversalController() { + mNodeTreeMap = new LinkedHashMap<>(); + } + + public void setSpeakNodesCache(Map speakNodeCache) { + mSpeakNodesCache = speakNodeCache; + } + + /** + * before start next traversal node search the controller must be initialized. The initialisation + * step includes traversal through all accessibility nodes hierarchy to collect information about + * traversal order of separate subtrees and moving subtries that has custom befor/after traverse + * view order + * + * @param compatRoot - accessibility node that serves as root node for tree hierarchy the + * controller works with + * @param includeChildrenOfNodesWithWebActions whether to calculator order for nodes that support + * web actions. Although TalkBack uses the naviagation order specified by the nodes, Switch + * Access needs to know about all nodes at the time the tree is being created. + */ + public void initOrder( + AccessibilityNodeInfoCompat compatRoot, boolean includeChildrenOfNodesWithWebActions) { + if (compatRoot == null) { + return; + } + + NodeCachedBoundsCalculator boundsCalculator = new NodeCachedBoundsCalculator(); + boundsCalculator.setSpeakNodesCache(mSpeakNodesCache); + mTree = + createWorkingTree( + AccessibilityNodeInfoCompat.obtain(compatRoot), + null, + boundsCalculator, + includeChildrenOfNodesWithWebActions); + reorderTree(); + } + + /** + * Creates tree that reproduces AccessibilityNodeInfoCompat tree hierarchy + * + * @param rootNode root node that is starting point for tree reproduction + * @param parent parent WorkingTree node for subtree that would be returned in this method + * @param includeChildrenOfNodesWithWebActions whether to calculator order for nodes that support + * web actions. Although TalkBack uses the naviagation order specified by the nodes, Switch + * Access needs to know about all nodes at the time the tree is being created. + * @return subtree that reproduces accessibility node hierarchy + */ + private @Nullable WorkingTree createWorkingTree( + AccessibilityNodeInfoCompat rootNode, + @Nullable WorkingTree parent, + NodeCachedBoundsCalculator boundsCalculator, + boolean includeChildrenOfNodesWithWebActions) { + if (mNodeTreeMap.containsKey(rootNode)) { + LogUtils.w(TAG, "creating node tree with looped nodes - break the loop edge"); + return null; + } + + WorkingTree tree = new WorkingTree(rootNode, parent); + mNodeTreeMap.put(rootNode, tree); + + // When we reach a node that supports web navigation, we traverse using the web navigation + // actions, so we should not try to determine the ordering of its descendants. + if (!includeChildrenOfNodesWithWebActions && WebInterfaceUtils.supportsWebActions(rootNode)) { + return tree; + } + + ReorderedChildrenIterator iterator = + ReorderedChildrenIterator.createAscendingIterator(rootNode, boundsCalculator); + while (iterator != null && iterator.hasNext()) { + AccessibilityNodeInfoCompat child = iterator.next(); + WorkingTree childSubTree = + createWorkingTree(child, tree, boundsCalculator, includeChildrenOfNodesWithWebActions); + if (childSubTree != null) { + tree.addChild(childSubTree); + } + } + return tree; + } + + /** + * reorder previously created tree according to after/before view traversal order on separate + * nodes + */ + private void reorderTree() { + for (WorkingTree subtree : mNodeTreeMap.values()) { + AccessibilityNodeInfoCompat node = subtree.getNode(); + AccessibilityNodeInfoCompat beforeNode = node.getTraversalBefore(); + if (beforeNode != null) { + WorkingTree targetTree = mNodeTreeMap.get(beforeNode); + moveNodeBefore(subtree, targetTree); + } else { + AccessibilityNodeInfoCompat afterNode = node.getTraversalAfter(); + if (afterNode != null) { + WorkingTree targetTree = mNodeTreeMap.get(afterNode); + moveNodeAfter(subtree, targetTree); + } + } + } + } + + /** Moves movingTree before targetTree. */ + private void moveNodeBefore(@Nullable WorkingTree movingTree, @Nullable WorkingTree targetTree) { + if (movingTree == null || targetTree == null) { + return; + } + + if (movingTree.hasDescendant(targetTree)) { + // no operation if move child before parent + return; + } + + // Find subtree to move. + WorkingTree movingTreeRoot = getParentsThatAreMovedBeforeOrSameNode(movingTree); + + // Find destination for movingTreeRoot. + WorkingTree parent = targetTree.getParent(); + if (movingTreeRoot.hasDescendant(parent)) { + return; // Moving movingTreeRoot under its own descendant would create a loop. + } + + // Unlink moving subtree from tree. + detachSubtreeFromItsParent(movingTreeRoot); + + //swap target node with moving node on targets node parent children list + if (parent != null) { + parent.swapChild(targetTree, movingTreeRoot); + } + + movingTreeRoot.setParent(parent); + + //add target node as last child of moving node + movingTree.addChild(targetTree); + targetTree.setParent(movingTree); + } + + /** + * This method is called before moving subtree. It checks if parent of that node was moved on its + * place because it has before property to that node. In that case parent node should be moved + * with movingTree node. + * + * @return top node that should be moved with movingTree node. + */ + private WorkingTree getParentsThatAreMovedBeforeOrSameNode(WorkingTree movingTree) { + WorkingTree parent = movingTree.getParent(); + if (parent == null) { + return movingTree; + } + + AccessibilityNodeInfoCompat parentNode = parent.getNode(); + AccessibilityNodeInfoCompat parentNodeBefore = parentNode.getTraversalBefore(); + if (parentNodeBefore == null) { + return movingTree; + } + + if (parentNodeBefore.equals(movingTree.getNode())) { + return getParentsThatAreMovedBeforeOrSameNode(parent); + } + + return movingTree; + } + + private void detachSubtreeFromItsParent(WorkingTree subtree) { + WorkingTree movingTreeParent = subtree.getParent(); + if (movingTreeParent != null) { + movingTreeParent.removeChild(subtree); + } + subtree.setParent(null); + } + + private void moveNodeAfter(@Nullable WorkingTree movingTree, @Nullable WorkingTree targetTree) { + if (movingTree == null || targetTree == null) { + return; + } + + if (movingTree.hasDescendant(targetTree)) { + return; // Moving movingTree under its own descendant would create a loop. + } + movingTree = getParentsThatAreMovedBeforeOrSameNode(movingTree); + if (movingTree.hasDescendant(targetTree)) { + return; // Moving movingTree under its own descendant would create a loop. + } + detachSubtreeFromItsParent(movingTree); + targetTree.addChild(movingTree); + movingTree.setParent(targetTree); + } + + public @Nullable AccessibilityNodeInfoCompat findNext(AccessibilityNodeInfoCompat node) { + WorkingTree tree = mNodeTreeMap.get(node); + if (tree == null) { + LogUtils.w(TAG, "findNext(), can't find WorkingTree for AccessibilityNodeInfo"); + return null; + } + + WorkingTree nextTree = tree.getNext(); + if (nextTree != null) { + return AccessibilityNodeInfoCompat.obtain(nextTree.getNode()); + } + + return null; + } + + public @Nullable AccessibilityNodeInfoCompat findPrevious(AccessibilityNodeInfoCompat node) { + WorkingTree tree = mNodeTreeMap.get(node); + if (tree == null) { + LogUtils.w(TAG, "findPrevious(), can't find WorkingTree for AccessibilityNodeInfo"); + return null; + } + + WorkingTree prevTree = tree.getPrevious(); + if (prevTree != null) { + return AccessibilityNodeInfoCompat.obtain(prevTree.getNode()); + } + + return null; + } + + /** Searches first node to be focused */ + public @Nullable AccessibilityNodeInfoCompat findFirst() { + if (mTree == null) { + return null; + } + + return AccessibilityNodeInfoCompat.obtain(mTree.getRoot().getNode()); + } + + public @Nullable AccessibilityNodeInfoCompat findFirst(AccessibilityNodeInfoCompat rootNode) { + if (rootNode == null) { + return null; + } + + WorkingTree tree = mNodeTreeMap.get(rootNode); + if (tree == null) { + return null; + } + + return AccessibilityNodeInfoCompat.obtain(tree.getNode()); + } + + /** Searches last node to be focused */ + public @Nullable AccessibilityNodeInfoCompat findLast() { + if (mTree == null) { + return null; + } + + return AccessibilityNodeInfoCompat.obtain(mTree.getRoot().getLastNode().getNode()); + } + + public @Nullable AccessibilityNodeInfoCompat findLast(AccessibilityNodeInfoCompat rootNode) { + if (rootNode == null) { + return null; + } + + WorkingTree tree = mNodeTreeMap.get(rootNode); + if (tree == null) { + return null; + } + + return AccessibilityNodeInfoCompat.obtain(tree.getLastNode().getNode()); + } + + /** Dumps the traversal order tree. */ + protected void dumpTree() { + AccessibilityNodeInfoCompat node = findFirst(); + while (node != null) { + LogUtils.v( + TreeDebug.TAG, + " (%d)%s%s", + node.hashCode(), + TreeDebug.nodeDebugDescription(node), + getCustomizedTraversalNodeString(node)); + node = findNext(node); + } + } + + /** + * Returns the string contains the attribute {@link AccessibilityNodeInfo#getTraversalAfter()} and + * {@link AccessibilityNodeInfo#getTraversalAfter()} of the target node. + */ + private static String getCustomizedTraversalNodeString(AccessibilityNodeInfoCompat node) { + StringBuilder builder = new StringBuilder(); + AccessibilityNodeInfoCompat beforeNode = node.getTraversalBefore(); + AccessibilityNodeInfoCompat afterNode = node.getTraversalAfter(); + if (beforeNode != null) { + builder.append(" before:"); + builder.append(beforeNode.hashCode()); + } + if (afterNode != null) { + builder.append(" after:"); + builder.append(afterNode.hashCode()); + } + return builder.toString(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategy.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategy.java new file mode 100644 index 0000000..9855d13 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategy.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.traversal; + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import java.util.HashMap; +import java.util.Map; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Window could have its content views hierarchy. Views in that hierarchy could be traversed one + * after another. Every view inside that hierarchy could change its natural traverse order by + * setting traversal before/after view. See {@link android.view.View.getTraversalBefore()}, {@link + * android.view.View.getTraversalAfter()}. + * + *

This strategy considers changes in the traverse order according to after/before view movements + */ +@SuppressWarnings("JavadocReference") +public class OrderedTraversalStrategy implements TraversalStrategy { + + private @Nullable AccessibilityNodeInfoCompat mRootNode; + private final OrderedTraversalController mController; + private final Map mSpeakingNodesCache; + + public OrderedTraversalStrategy(AccessibilityNodeInfoCompat rootNode) { + if (rootNode != null) { + mRootNode = AccessibilityNodeInfoCompat.obtain(rootNode); + } + + mSpeakingNodesCache = new HashMap<>(); + mController = new OrderedTraversalController(); + mController.setSpeakNodesCache(mSpeakingNodesCache); + mController.initOrder(mRootNode, true); + } + + /** @deprecated Accessibility is discontinuing recycling. */ + @Override + @Deprecated + public void recycle() {} + + @Override + public Map getSpeakingNodesCache() { + return mSpeakingNodesCache; + } + + @Override + public @Nullable AccessibilityNodeInfoCompat findFocus( + AccessibilityNodeInfoCompat startNode, @SearchDirection int direction) { + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_FORWARD: + return focusNext(startNode); + case TraversalStrategy.SEARCH_FOCUS_BACKWARD: + return focusPrevious(startNode); + default: // fall out + } + + return null; + } + + private @Nullable AccessibilityNodeInfoCompat focusNext(AccessibilityNodeInfoCompat node) { + return mController.findNext(node); + } + + private @Nullable AccessibilityNodeInfoCompat focusPrevious(AccessibilityNodeInfoCompat node) { + return mController.findPrevious(node); + } + + @Override + public @Nullable AccessibilityNodeInfoCompat focusInitial( + AccessibilityNodeInfoCompat root, @SearchDirection int direction) { + if (direction == SEARCH_FOCUS_FORWARD) { + return mController.findFirst(root); + } else if (direction == SEARCH_FOCUS_BACKWARD) { + return mController.findLast(root); + } else { + return null; + } + } + + /** Dumps the traversal order tree. */ + public void dumpTree() { + mController.dumpTree(); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/ReorderedChildrenIterator.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/ReorderedChildrenIterator.java new file mode 100644 index 0000000..b7f34d8 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/ReorderedChildrenIterator.java @@ -0,0 +1,274 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.traversal; + +import android.graphics.Rect; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.WebInterfaceUtils; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Children nodes iterator that iterates its children according the order of AccessibilityNodeInfo + * hierarchy. But for nodes that are not considered to be focused according to + * AccessibilityNodeInfoUtils.shouldFocusNode() rules we calculate new bounds that is minimum + * rectangle that contains all focusable children nodes. If that rectangle differs from real node + * bounds that node is reordered according needSwapNodeOrder() logic and could be traversed later. + * + *

This class obtains new instances of AccessibilityNodeCompat. + */ +public class ReorderedChildrenIterator implements Iterator { + + public static ReorderedChildrenIterator createAscendingIterator( + AccessibilityNodeInfoCompat parent) { + return createAscendingIterator(parent, null); + } + + public static ReorderedChildrenIterator createDescendingIterator( + AccessibilityNodeInfoCompat parent) { + return createDescendingIterator(parent, null); + } + + public static @Nullable ReorderedChildrenIterator createAscendingIterator( + AccessibilityNodeInfoCompat parent, @Nullable NodeCachedBoundsCalculator boundsCalculator) { + if (parent == null) { + return null; + } + + return new ReorderedChildrenIterator(parent, true, boundsCalculator); + } + + public static @Nullable ReorderedChildrenIterator createDescendingIterator( + AccessibilityNodeInfoCompat parent, @Nullable NodeCachedBoundsCalculator boundsCalculator) { + if (parent == null) { + return null; + } + + return new ReorderedChildrenIterator(parent, false, boundsCalculator); + } + + private final AccessibilityNodeInfoCompat mParent; + private int mCurrentIndex; + private final List mNodes; + private final boolean mIsAscending; + private final boolean mRightToLeft = false; // TODO: Refactor to get RTL state. + private final NodeCachedBoundsCalculator mBoundsCalculator; + + // Avoid constantly creating and discarding Rects. + private final Rect mTempLeftBounds = new Rect(); + private final Rect mTempRightBounds = new Rect(); + + private ReorderedChildrenIterator( + AccessibilityNodeInfoCompat parent, + boolean isAscending, + @Nullable NodeCachedBoundsCalculator boundsCalculator) { + mParent = parent; + mIsAscending = isAscending; + mBoundsCalculator = + (boundsCalculator == null) ? new NodeCachedBoundsCalculator() : boundsCalculator; + + mNodes = new ArrayList<>(mParent.getChildCount()); + init(mParent); + mCurrentIndex = mIsAscending ? 0 : mNodes.size() - 1; + } + + private void init(AccessibilityNodeInfoCompat node) { + fillNodesFromParent(); + if (!WebInterfaceUtils.isWebContainer(node) && needReordering(mNodes)) { + reorder(mNodes); + } + } + + private boolean needReordering(List nodes) { + if (nodes == null || nodes.size() == 1) { + return false; + } + + for (AccessibilityNodeInfoCompat node : nodes) { + if (mBoundsCalculator.usesChildrenBounds(node)) { + return true; + } + } + + return false; + } + + private void reorder(List nodes) { + if (nodes == null || nodes.size() == 1) { + return; + } + + int size = nodes.size(); + AccessibilityNodeInfoCompat[] nodeArray = new AccessibilityNodeInfoCompat[size]; + nodes.toArray(nodeArray); + + int currentIndex = size - 2; + while (currentIndex >= 0) { + AccessibilityNodeInfoCompat currentNode = nodeArray[currentIndex]; + if (mBoundsCalculator.usesChildrenBounds(currentNode)) { + moveNodeIfNecessary(nodeArray, currentIndex); + } + + currentIndex--; + } + + nodes.clear(); + nodes.addAll(Arrays.asList(nodeArray)); + } + + private void moveNodeIfNecessary(AccessibilityNodeInfoCompat[] nodeArray, int index) { + int size = nodeArray.length; + int nextIndex = index + 1; + AccessibilityNodeInfoCompat currentNode = nodeArray[index]; + while (nextIndex < size && needSwapNodeOrder(currentNode, nodeArray[nextIndex])) { + nodeArray[nextIndex - 1] = nodeArray[nextIndex]; + nodeArray[nextIndex] = currentNode; + nextIndex++; + } + } + + private boolean needSwapNodeOrder( + AccessibilityNodeInfoCompat leftNode, AccessibilityNodeInfoCompat rightNode) { + if (leftNode == null || rightNode == null) { + return false; + } + + Rect leftBounds = mBoundsCalculator.getBounds(leftNode); + Rect rightBounds = mBoundsCalculator.getBounds(rightNode); + + // Sometimes the bounds compare() is overzealous, so swap the items only if the adjusted + // (mBoundsCalculator) leftBounds > rightBounds but the original leftBounds < rightBounds, + // i.e. the compare() method returns the existing ordering for the original bounds but + // wants a swap for the adjusted bounds. + // Simply, if compare() says that the original system ordering is wrong, then we cannot + // trust its judgment in the adjusted bounds case. + // + // Example: + // (1) Page scrolled to top (2) Page scrolled to bottom. + // +----------+ +----------+ + // | App bar | | App bar | + // +----------+ +----------+ + // | Item 1 | | Item 2 | + // | Item 2 | | Item 3 | + // | Item 3 | | (spacer) | + // +----------+ +----------+ + // Note: App bar overlays the top part of the list; the top, left, and right edges of the + // list line up with the app bar. Assume that the spacer is not important for accessibility. + // In this example, the traversal order for (1) is Item 1 -> Item 2 -> Item 3 -> App bar + // but the traversal order for (2) gets reordered to App bar -> Item 2 -> Item 3. + // So during auto-scrolling the app bar is actually excluded from the traversal order until + // after the wrap-around. + if (compare(leftBounds, rightBounds) > 0) { + leftNode.getBoundsInScreen(mTempLeftBounds); + rightNode.getBoundsInScreen(mTempRightBounds); + return compare(mTempLeftBounds, mTempRightBounds) < 0; + } + + return false; + } + + /** + * Returns a negative value if the inputs are ordered {@code {leftBounds, rightBounds}} and a + * positive value if the inputs are ordered {@code {rightBounds, leftBounds}}. Guaranteed to not + * return 0. + * + *

The ordering is determined via an algorithm similar to the {@link + * android.view.ViewGroup.ViewLocationHolder#COMPARISON_STRATEGY_STRIPE} strategy used by the + * framework to sort children of ViewGroups. This is essentially copied from {@link + * android.view.ViewGroup.ViewLocationHolder#compareTo} with minor modifications. + */ + private int compare(@Nullable Rect leftBounds, @Nullable Rect rightBounds) { + if (leftBounds == null || rightBounds == null) { + return -1; + } + + // First is above second. + if (leftBounds.bottom - rightBounds.top <= 0) { + return -1; + } + // First is below second. + if (leftBounds.top - rightBounds.bottom >= 0) { + return 1; + } + + // We are ordering left-to-right, top-to-bottom. + if (mRightToLeft) { + final int rightDifference = leftBounds.right - rightBounds.right; + if (rightDifference != 0) { + return -rightDifference; + } + } else { // LTR + final int leftDifference = leftBounds.left - rightBounds.left; + if (leftDifference != 0) { + return leftDifference; + } + } + // We are ordering left-to-right, top-to-bottom. + final int topDifference = leftBounds.top - rightBounds.top; + if (topDifference != 0) { + return topDifference; + } + // Break tie by height. + final int heightDifference = leftBounds.height() - rightBounds.height(); + if (heightDifference != 0) { + return -heightDifference; + } + // Break tie by width. + final int widthDifference = leftBounds.width() - rightBounds.width(); + if (widthDifference != 0) { + return -widthDifference; + } + // Break tie somehow. + return -1; + } + + private void fillNodesFromParent() { + int count = mParent.getChildCount(); + for (int i = 0; i < count; i++) { + AccessibilityNodeInfoCompat node = mParent.getChild(i); + if (node != null) { + mNodes.add(node); + } + } + } + + @Override + public boolean hasNext() { + return mIsAscending ? mCurrentIndex < mNodes.size() : mCurrentIndex >= 0; + } + + @Override + public @Nullable AccessibilityNodeInfoCompat next() { + AccessibilityNodeInfoCompat nextNode = mNodes.get(mCurrentIndex); + if (mIsAscending) { + mCurrentIndex++; + } else { + mCurrentIndex--; + } + + return nextNode != null ? AccessibilityNodeInfoCompat.obtain(nextNode) : null; + } + + @Override + public void remove() { + throw new UnsupportedOperationException( + "ReorderedChildrenIterator does not support remove operation"); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/SimpleTraversalStrategy.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/SimpleTraversalStrategy.java new file mode 100644 index 0000000..b41501b --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/SimpleTraversalStrategy.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.traversal; + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.AccessibilityNodeInfoRef; +import java.util.Map; +import org.checkerframework.checker.nullness.qual.Nullable; + +public class SimpleTraversalStrategy implements TraversalStrategy { + + @Override + public @Nullable AccessibilityNodeInfoCompat findFocus( + AccessibilityNodeInfoCompat startNode, @SearchDirection int direction) { + if (startNode == null) { + return null; + } + + AccessibilityNodeInfoRef ref = AccessibilityNodeInfoRef.obtain(startNode); + boolean focusFound = + direction == TraversalStrategy.SEARCH_FOCUS_FORWARD + ? ref.nextInOrder() + : ref.previousInOrder(); + if (focusFound) { + return ref.get(); + } + + return null; + } + + @Override + public @Nullable AccessibilityNodeInfoCompat focusInitial( + AccessibilityNodeInfoCompat root, @SearchDirection int direction) { + if (root == null) { + return null; + } + + if (direction == SEARCH_FOCUS_FORWARD) { + return AccessibilityNodeInfoCompat.obtain(root); + } else if (direction == SEARCH_FOCUS_BACKWARD) { + AccessibilityNodeInfoRef ref = AccessibilityNodeInfoRef.obtain(root); + if (ref.lastDescendant()) { + return ref.get(); + } else { + return null; + } + } + + return null; + } + + @Override + public Map getSpeakingNodesCache() { + return null; + } + + @Override + public void recycle() {} +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/SpannableTraversalUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/SpannableTraversalUtils.java new file mode 100644 index 0000000..ced581e --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/SpannableTraversalUtils.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2017 Google Inc. + * + * 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.google.android.accessibility.utils.traversal; + +import android.text.SpannableString; +import android.text.TextUtils; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.accessibility.utils.SpannableUtils; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** Utility methods for traversing a tree with spannable objects. */ +public class SpannableTraversalUtils { + + /** Return whether the tree description of node contains target spans. */ + public static boolean hasTargetSpanInNodeTreeDescription( + AccessibilityNodeInfoCompat node, Class targetSpanClass) { + if (node == null) { + return false; + } + Set visitedNode = new HashSet<>(); + try { + return searchSpannableStringsInNodeTree( + AccessibilityNodeInfoUtils.obtain(node), // Root node. + visitedNode, // Visited nodes. + null, // Result list. No need to collect result here. + targetSpanClass // Target span class + ); + } finally { + } + } + + /** + * Collects SpannableStrings with target span within the node tree description. Caller should + * recycle {@code node}. + */ + public static void collectSpannableStringsWithTargetSpanInNodeDescriptionTree( + AccessibilityNodeInfoCompat node, + Class targetSpanClass, + @NonNull List result) { + if (node == null) { + return; + } + Set visitedNodes = new HashSet<>(); + searchSpannableStringsInNodeTree( + AccessibilityNodeInfoCompat.obtain(node), // Root node. + visitedNodes, // Visited nodes. + result, // List of SpannableStrings collected. + targetSpanClass // Target span class + ); + } + + /** + * Search for SpannableStrings under node description tree of {@code root}. + * Note: {@code root} will be added to {@code visitedNodes} if it's not null. + * + * @param root Root of node tree. + * @param visitedNodes Set of {@link AccessibilityNodeInfoCompat} to record visited nodes, used to + * avoid loops. + * @param result List of SpannableStrings collected. + * @param targetSpanClass Class of target span. + * @return true if any SpannableString is found in the description tree. + */ + private static boolean searchSpannableStringsInNodeTree( + AccessibilityNodeInfoCompat root, + @NonNull Set visitedNodes, + @Nullable List result, + Class targetSpanClass) { + if (root == null) { + return false; + } + if (!visitedNodes.add(root)) { + // Root already visited. Stop searching. + return false; + } + SpannableString string = SpannableUtils.getStringWithTargetSpan(root, targetSpanClass); + boolean hasSpannableString = !TextUtils.isEmpty(string); + if (hasSpannableString) { + if (result == null) { + // If we don't need to collect result and we found a Spannable String, return true. + return true; + } else { + result.add(string); + } + } + + // TODO: Check if we should search descendents of web content node. + if (!TextUtils.isEmpty(root.getContentDescription())) { + // If root has content description, do not search the children nodes. + return hasSpannableString; + } + ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createAscendingIterator(root); + boolean containsSpannableDescendents = false; + while (iterator.hasNext()) { + AccessibilityNodeInfoCompat child = iterator.next(); + if (AccessibilityNodeInfoUtils.FILTER_NON_FOCUSABLE_VISIBLE_NODE.accept(child)) { + containsSpannableDescendents |= + searchSpannableStringsInNodeTree(child, visitedNodes, result, targetSpanClass); + } else { + } + if (containsSpannableDescendents && result == null) { + return true; + } + } + return hasSpannableString || containsSpannableDescendents; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategy.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategy.java new file mode 100644 index 0000000..5550d6b --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategy.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.traversal; + +import androidx.annotation.IntDef; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Map; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Strategy the is defined an order of traversing through the nodes of AccessibilityNodeInfo + * hierarchy + */ +public interface TraversalStrategy { + + public static final int SEARCH_FOCUS_UNKNOWN = 0; + public static final int SEARCH_FOCUS_FORWARD = 1; + public static final int SEARCH_FOCUS_BACKWARD = 2; + public static final int SEARCH_FOCUS_LEFT = 3; + public static final int SEARCH_FOCUS_RIGHT = 4; + public static final int SEARCH_FOCUS_UP = 5; + public static final int SEARCH_FOCUS_DOWN = 6; + + /** Direction to search for an item to focus. */ + @IntDef({ + SEARCH_FOCUS_FORWARD, + SEARCH_FOCUS_BACKWARD, + SEARCH_FOCUS_LEFT, + SEARCH_FOCUS_RIGHT, + SEARCH_FOCUS_UP, + SEARCH_FOCUS_DOWN + }) + @Retention(RetentionPolicy.SOURCE) + public @interface SearchDirection {} + + /** Direction to search for an item to focus, or unknown. */ + @IntDef({ + SEARCH_FOCUS_UNKNOWN, + SEARCH_FOCUS_FORWARD, + SEARCH_FOCUS_BACKWARD, + SEARCH_FOCUS_LEFT, + SEARCH_FOCUS_RIGHT, + SEARCH_FOCUS_UP, + SEARCH_FOCUS_DOWN + }) + @Retention(RetentionPolicy.SOURCE) + public @interface SearchDirectionOrUnknown {} + + /** + * The method searches next node to be focused + * + * @param startNode - pivot node the search is start from + * @param direction - direction to find focus + * @return {@link androidx.core.view.accessibility.AccessibilityNodeInfoCompat} node that has next + * focus + */ + public @Nullable AccessibilityNodeInfoCompat findFocus( + AccessibilityNodeInfoCompat startNode, @SearchDirection int direction); + + /** + * Finds the initial focusable accessibility node in hierarchy started from root node when + * searching in the given direction. + * + *

For example, if {@code direction} is {@link #SEARCH_FOCUS_FORWARD}, then the method should + * return the first node in the traversal order. If {@code direction} is {@link + * #SEARCH_FOCUS_BACKWARD} then the method should return the last node in the traversal order. + * + * @param root - root node + * @param direction - the direction to search from + * @return returns the first node that could be focused + */ + public @Nullable AccessibilityNodeInfoCompat focusInitial( + AccessibilityNodeInfoCompat root, @SearchDirection int direction); + + /** + * Calculating if node is speaking node according to AccessibilityNodeInfoUtils.isSpeakingNode() + * method is time consuming. Traversal strategy may use cache for already calculated values. If + * traversal strategy does not need in such cache use it could return null. + * + * @return speaking node cache map. Could be null if cache is not used by traversal strategy + */ + public Map getSpeakingNodesCache(); + + /** + * When there is no need in traversal strategy object it must be recycled before garbage collected + * + *

TODO: Remove once all dependencies have been removed. + * + * @deprecated Accessibility is discontinuing recycling. + */ + @Deprecated + public void recycle(); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtils.java new file mode 100644 index 0000000..e959609 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtils.java @@ -0,0 +1,491 @@ +package com.google.android.accessibility.utils.traversal; + +import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.SEARCH_FOCUS_FAIL; + +import android.graphics.Rect; +import android.view.View; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.accessibility.utils.DiagnosticOverlayUtils; +import com.google.android.accessibility.utils.Filter; +import com.google.android.accessibility.utils.FocusFinder; +import com.google.android.accessibility.utils.NodeActionFilter; +import com.google.android.accessibility.utils.Role; +import com.google.android.accessibility.utils.WebInterfaceUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.HashSet; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.Nullable; + +public class TraversalStrategyUtils { + + private static final String TAG = "TraversalStrategyUtils"; + + private TraversalStrategyUtils() { + // Prevent utility class from being instantiated. + } + + /** + * Recycles the given traversal strategy. + * + * @deprecated Accessibility is discontinuing recycling. + */ + @Deprecated + public static void recycle(@Nullable TraversalStrategy traversalStrategy) {} + + /** + * Depending on whether the direction is spatial or logical, returns the appropriate traversal + * strategy to handle the case. + */ + public static TraversalStrategy getTraversalStrategy( + AccessibilityNodeInfoCompat root, + FocusFinder focusFinder, + @TraversalStrategy.SearchDirection int direction) { + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_BACKWARD: + case TraversalStrategy.SEARCH_FOCUS_FORWARD: + return new OrderedTraversalStrategy(root); + case TraversalStrategy.SEARCH_FOCUS_LEFT: + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + case TraversalStrategy.SEARCH_FOCUS_UP: + case TraversalStrategy.SEARCH_FOCUS_DOWN: + return new DirectionalTraversalStrategy(root, focusFinder); + default: // fall out + } + + throw new IllegalArgumentException("direction must be a SearchDirection"); + } + + /** Converts {@link TraversalStrategy.SearchDirection} to view focus direction. */ + public static int nodeSearchDirectionToViewSearchDirection( + @TraversalStrategy.SearchDirection int direction) { + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_FORWARD: + return View.FOCUS_FORWARD; + case TraversalStrategy.SEARCH_FOCUS_BACKWARD: + return View.FOCUS_BACKWARD; + case TraversalStrategy.SEARCH_FOCUS_LEFT: + return View.FOCUS_LEFT; + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + return View.FOCUS_RIGHT; + case TraversalStrategy.SEARCH_FOCUS_UP: + return View.FOCUS_UP; + case TraversalStrategy.SEARCH_FOCUS_DOWN: + return View.FOCUS_DOWN; + default: + throw new IllegalArgumentException("Direction must be a SearchDirection"); + } + } + + /** + * Determines whether the given search direction corresponds to an actual spatial direction as + * opposed to a logical direction. + */ + public static boolean isSpatialDirection(@TraversalStrategy.SearchDirection int direction) { + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_FORWARD: + case TraversalStrategy.SEARCH_FOCUS_BACKWARD: + return false; + case TraversalStrategy.SEARCH_FOCUS_UP: + case TraversalStrategy.SEARCH_FOCUS_DOWN: + case TraversalStrategy.SEARCH_FOCUS_LEFT: + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + return true; + default: // fall out + } + + throw new IllegalArgumentException("direction must be a SearchDirection"); + } + + /** + * Converts a spatial direction to a logical direction based on whether the user is LTR or RTL. If + * the direction is already a logical direction, it is returned. + */ + @TraversalStrategy.SearchDirection + public static int getLogicalDirection( + @TraversalStrategy.SearchDirection int direction, boolean isRtl) { + @TraversalStrategy.SearchDirection int left; + @TraversalStrategy.SearchDirection int right; + if (isRtl) { + left = TraversalStrategy.SEARCH_FOCUS_FORWARD; + right = TraversalStrategy.SEARCH_FOCUS_BACKWARD; + } else { + left = TraversalStrategy.SEARCH_FOCUS_BACKWARD; + right = TraversalStrategy.SEARCH_FOCUS_FORWARD; + } + + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_LEFT: + return left; + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + return right; + case TraversalStrategy.SEARCH_FOCUS_UP: + case TraversalStrategy.SEARCH_FOCUS_BACKWARD: + return TraversalStrategy.SEARCH_FOCUS_BACKWARD; + case TraversalStrategy.SEARCH_FOCUS_DOWN: + case TraversalStrategy.SEARCH_FOCUS_FORWARD: + return TraversalStrategy.SEARCH_FOCUS_FORWARD; + default: // fall out + } + + throw new IllegalArgumentException("direction must be a SearchDirection"); + } + + /** + * Returns the scroll action for the given {@link TraversalStrategy.SearchDirection} if the scroll + * action is available on the current SDK version. Otherwise, returns 0. + */ + public static int convertSearchDirectionToScrollAction( + @TraversalStrategy.SearchDirection int direction) { + if (direction == TraversalStrategy.SEARCH_FOCUS_FORWARD) { + return AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD; + } else if (direction == TraversalStrategy.SEARCH_FOCUS_BACKWARD) { + return AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD; + } else { + if (direction == TraversalStrategy.SEARCH_FOCUS_LEFT) { + return AccessibilityAction.ACTION_SCROLL_LEFT.getId(); + } else if (direction == TraversalStrategy.SEARCH_FOCUS_RIGHT) { + return AccessibilityAction.ACTION_SCROLL_RIGHT.getId(); + } else if (direction == TraversalStrategy.SEARCH_FOCUS_UP) { + return AccessibilityAction.ACTION_SCROLL_UP.getId(); + } else if (direction == TraversalStrategy.SEARCH_FOCUS_DOWN) { + return AccessibilityAction.ACTION_SCROLL_DOWN.getId(); + } + } + + return 0; + } + + /** + * Returns the {@link TraversalStrategy.SearchDirectionOrUnknown} for the given scroll action; + * {@link TraversalStrategy#SEARCH_FOCUS_UNKNOWN} is returned for a scroll action that can't be + * handled (e.g. because the current API level doesn't support it). + */ + @TraversalStrategy.SearchDirectionOrUnknown + public static int convertScrollActionToSearchDirection(int scrollAction) { + if (scrollAction == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) { + return TraversalStrategy.SEARCH_FOCUS_FORWARD; + } else if (scrollAction == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { + return TraversalStrategy.SEARCH_FOCUS_BACKWARD; + } else { + if (scrollAction == AccessibilityAction.ACTION_SCROLL_LEFT.getId()) { + return TraversalStrategy.SEARCH_FOCUS_LEFT; + } else if (scrollAction == AccessibilityAction.ACTION_SCROLL_RIGHT.getId()) { + return TraversalStrategy.SEARCH_FOCUS_RIGHT; + } else if (scrollAction == AccessibilityAction.ACTION_SCROLL_UP.getId()) { + return TraversalStrategy.SEARCH_FOCUS_UP; + } else if (scrollAction == AccessibilityAction.ACTION_SCROLL_DOWN.getId()) { + return TraversalStrategy.SEARCH_FOCUS_DOWN; + } + } + + return TraversalStrategy.SEARCH_FOCUS_UNKNOWN; + } + + /** + * Determines if the current item is at the logical edge of a list by checking the scrollable + * predecessors of the items going forwards and backwards. + * + * @param node The node to check. + * @param traversalStrategy - traversal strategy that is used to define order of node + * @return true if the current item is at the edge of a list. + */ + public static boolean isEdgeListItem( + AccessibilityNodeInfoCompat node, TraversalStrategy traversalStrategy) { + return isEdgeListItem( + node, + /* ignoreDescendantsOfPivot= */ false, + TraversalStrategy.SEARCH_FOCUS_BACKWARD, + null, + traversalStrategy) + || isEdgeListItem( + node, + /* ignoreDescendantsOfPivot= */ false, + TraversalStrategy.SEARCH_FOCUS_FORWARD, + null, + traversalStrategy); + } + + /** + * Determines if the current item is at the edge of a list by checking the scrollable predecessors + * of the items in a relative or absolute direction. + * + * @param pivot The node to check. + * @param ignoreDescendantsOfPivot Whether to ignore descendants of pivot when searching down the + * node tree. + * @param direction The direction in which to check. + * @param filter (Optional) Filter used to validate list-type ancestors. + * @param traversalStrategy - traversal strategy that is used to define order of node + * @return true if the current item is at the edge of a list. + */ + private static boolean isEdgeListItem( + AccessibilityNodeInfoCompat pivot, + boolean ignoreDescendantsOfPivot, + @TraversalStrategy.SearchDirection int direction, + @Nullable Filter filter, + TraversalStrategy traversalStrategy) { + if (pivot == null) { + return false; + } + + int scrollAction = TraversalStrategyUtils.convertSearchDirectionToScrollAction(direction); + if (scrollAction != 0) { + NodeActionFilter scrollableFilter = new NodeActionFilter(scrollAction); + Filter comboFilter = scrollableFilter.and(filter); + + return isMatchingEdgeListItem( + pivot, + AccessibilityNodeInfoUtils.getMatchingAncestor(pivot, comboFilter), + ignoreDescendantsOfPivot, + direction, + comboFilter, + traversalStrategy); + } + + return false; + } + + /** + * Convenience method determining if the current item is at the edge of a scrollable view and + * suitable autoscroll. Calls {@code isEdgeListItem} with {@code FILTER_AUTO_SCROLL}. + * + * @param pivot The node to check. + * @param scrollableNode The scrollable container that for checking the pivot is at the edge or + * not. Will find from the ancestor of the pivot if it's null. + * @param ignoreDescendantsOfPivot Whether to ignore descendants of pivot when search down the + * node tree. + * @param direction The direction in which to check, one of: + *

    + *
  • {@code -1} to check backward + *
  • {@code 0} to check both backward and forward + *
  • {@code 1} to check forward + *
+ * + * @param traversalStrategy - traversal strategy that is used to define order of node + * @return true if the current item is at the edge of a list. + */ + public static boolean isAutoScrollEdgeListItem( + AccessibilityNodeInfoCompat pivot, + @Nullable AccessibilityNodeInfoCompat scrollableNode, + boolean ignoreDescendantsOfPivot, + int direction, + TraversalStrategy traversalStrategy) { + if (scrollableNode == null) { + return isEdgeListItem( + pivot, + ignoreDescendantsOfPivot, + direction, + AccessibilityNodeInfoUtils.FILTER_AUTO_SCROLL, + traversalStrategy); + } + + return isMatchingEdgeListItem( + pivot, + scrollableNode, + ignoreDescendantsOfPivot, + direction, + AccessibilityNodeInfoUtils.FILTER_AUTO_SCROLL, + traversalStrategy); + } + + /** + * Utility method for determining if a searching past a particular node will fall off the edge of + * a scrollable container. + * + * @param cursor Node to check. + * @param scrollableNode The scrollable container that for checking the cursor is at the edge or + * not. Caller is responsible to recycle it. + * @param ignoreDescendantsOfCursor Whether to ignore descendants of cursor when search down the + * node tree. + * @param direction The direction in which to move from the cursor. + * @param filter Filter used to validate list-type ancestors. + * @param traversalStrategy - traversal strategy that is used to define order of node + * @return {@code true} if focusing search in the specified direction will fall off the edge of + * the container. + */ + private static boolean isMatchingEdgeListItem( + final AccessibilityNodeInfoCompat cursor, + final AccessibilityNodeInfoCompat scrollableNode, + boolean ignoreDescendantsOfCursor, + @TraversalStrategy.SearchDirection int direction, + Filter filter, + TraversalStrategy traversalStrategy) { + AccessibilityNodeInfoCompat webViewNode = null; + + boolean cursorNodeNotContainedInScrollableList = + scrollableNode == null + || !scrollableNode.isScrollable() + || !(AccessibilityNodeInfoUtils.hasAncestor(cursor, scrollableNode) + || scrollableNode.equals(cursor)); + + if (cursorNodeNotContainedInScrollableList) { + return false; + } + Filter focusNodeFilter = + AccessibilityNodeInfoUtils.FILTER_SHOULD_FOCUS; + if (ignoreDescendantsOfCursor) { + focusNodeFilter = + focusNodeFilter.and( + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat obj) { + return !AccessibilityNodeInfoUtils.hasAncestor(obj, cursor); + } + }); + } + AccessibilityNodeInfoCompat nextFocusNode = + searchFocus(traversalStrategy, cursor, direction, focusNodeFilter); + if ((nextFocusNode == null) || nextFocusNode.equals(scrollableNode)) { + // Can't move from this position. + return true; + } + + // if nextFocusNode is in WebView and not visible to user we still could set + // accessibility focus on it and WebView scrolls itself to show newly focused item + // on the screen. But there could be situation that node is inside WebView bounds but + // WebView is [partially] outside the screen bounds. In that case we don't ask WebView + // to set accessibility focus but try to scroll scrollable parent to get the WebView + // with nextFocusNode inside it to the screen bounds. + if (!nextFocusNode.isVisibleToUser() && WebInterfaceUtils.hasNativeWebContent(nextFocusNode)) { + webViewNode = + AccessibilityNodeInfoUtils.getMatchingAncestor( + nextFocusNode, + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return Role.getRole(node) == Role.ROLE_WEB_VIEW; + } + }); + + if (webViewNode != null + && (!webViewNode.isVisibleToUser() + || isNodeInBoundsOfOther(webViewNode, nextFocusNode))) { + return true; + } + } + + AccessibilityNodeInfoCompat searchedAncestor = + AccessibilityNodeInfoUtils.getMatchingAncestor(nextFocusNode, filter); + while (searchedAncestor != null) { + if (scrollableNode.equals(searchedAncestor)) { + return false; + } + searchedAncestor = AccessibilityNodeInfoUtils.getMatchingAncestor(searchedAncestor, filter); + } + // Moves outside of the scrollable container. + return true; + + } + + /** + * Search focus that satisfied specified node filter from currentFocus to specified direction + * according to OrderTraversal strategy + * + * @param traversal - order traversal strategy + * @param currentFocus - node that is starting point of focus search + * @param direction - direction the target focus is searching to + * @param filter - filters focused node candidate + * @return node that could be focused next + */ + public static @Nullable AccessibilityNodeInfoCompat searchFocus( + TraversalStrategy traversal, + AccessibilityNodeInfoCompat currentFocus, + @TraversalStrategy.SearchDirection int direction, + Filter filter) { + if (traversal == null || currentFocus == null) { + return null; + } + + if (filter == null) { + filter = DEFAULT_FILTER; + } + + AccessibilityNodeInfoCompat targetNode = AccessibilityNodeInfoCompat.obtain(currentFocus); + Set seenNodes = new HashSet<>(); + + do { + seenNodes.add(targetNode); + targetNode = traversal.findFocus(targetNode, direction); + DiagnosticOverlayUtils.appendLog(SEARCH_FOCUS_FAIL, targetNode); + + if (seenNodes.contains(targetNode)) { + LogUtils.e(TAG, "Found duplicate during traversal: %s", targetNode); + return null; + } + } while (targetNode != null && !filter.accept(targetNode)); + + + return targetNode; + } + + public static @Nullable AccessibilityNodeInfoCompat findInitialFocusInNodeTree( + TraversalStrategy traversalStrategy, + AccessibilityNodeInfoCompat root, + @TraversalStrategy.SearchDirection int direction, + Filter nodeFilter) { + if (root == null) { + return null; + } + AccessibilityNodeInfoCompat initialNode = traversalStrategy.focusInitial(root, direction); + + if (nodeFilter.accept(initialNode)) { + return AccessibilityNodeInfoUtils.obtain(initialNode); + } + return TraversalStrategyUtils.searchFocus( + traversalStrategy, initialNode, direction, nodeFilter); + } + + private static boolean isNodeInBoundsOfOther( + AccessibilityNodeInfoCompat outerNode, AccessibilityNodeInfoCompat innerNode) { + if (outerNode == null || innerNode == null) { + return false; + } + + Rect outerRect = new Rect(); + Rect innerRect = new Rect(); + outerNode.getBoundsInScreen(outerRect); + innerNode.getBoundsInScreen(innerRect); + + if (outerRect.top > innerRect.bottom || outerRect.bottom < innerRect.top) { + return false; + } + + //noinspection RedundantIfStatement + if (outerRect.left > innerRect.right || outerRect.right < innerRect.left) { + return false; + } + + return true; + } + + private static final Filter DEFAULT_FILTER = + new Filter() { + @Override + public boolean accept(AccessibilityNodeInfoCompat node) { + return node != null; + } + }; + + public static String directionToString(@TraversalStrategy.SearchDirection int direction) { + switch (direction) { + case TraversalStrategy.SEARCH_FOCUS_FORWARD: + return "SEARCH_FOCUS_FORWARD"; + case TraversalStrategy.SEARCH_FOCUS_BACKWARD: + return "SEARCH_FOCUS_BACKWARD"; + case TraversalStrategy.SEARCH_FOCUS_LEFT: + return "SEARCH_FOCUS_LEFT"; + case TraversalStrategy.SEARCH_FOCUS_RIGHT: + return "SEARCH_FOCUS_RIGHT"; + case TraversalStrategy.SEARCH_FOCUS_UP: + return "SEARCH_FOCUS_UP"; + case TraversalStrategy.SEARCH_FOCUS_DOWN: + return "SEARCH_FOCUS_DOWN"; + case TraversalStrategy.SEARCH_FOCUS_UNKNOWN: + return "SEARCH_FOCUS_UNKNOWN"; + default: + return "(unhandled)"; + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/WorkingTree.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/WorkingTree.java new file mode 100644 index 0000000..2327f2b --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/WorkingTree.java @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2015 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.google.android.accessibility.utils.traversal; + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Tree that represents Accessibility node hierarchy. It lets reorder the structure of the tree. */ +public class WorkingTree { + + private static final String TAG = "WorkingTree"; + + private AccessibilityNodeInfoCompat mNode; + private @Nullable WorkingTree mParent; + private final List mChildren = new ArrayList<>(); + + public WorkingTree(AccessibilityNodeInfoCompat node, @Nullable WorkingTree parent) { + mNode = node; + mParent = parent; + } + + public AccessibilityNodeInfoCompat getNode() { + return mNode; + } + + public @Nullable WorkingTree getParent() { + return mParent; + } + + public void setParent(@Nullable WorkingTree parent) { + mParent = parent; + } + + public void addChild(WorkingTree node) { + mChildren.add(node); + } + + public boolean removeChild(WorkingTree child) { + return mChildren.remove(child); + } + + /** Checks whether subTree is a descendant of this WorkingTree node. */ + public boolean hasDescendant(@Nullable WorkingTree tree) { + + if (ancestorsHaveLoop()) { + LogUtils.w(TAG, "Looped ancestors line"); + return false; + } + + // For each ancestor of target descendant node... + WorkingTree subTree = tree; + while (subTree != null) { + AccessibilityNodeInfoCompat node = subTree.getNode(); + + // If ancestor is this working tree node... target is descendant of this node. + if (mNode.equals(node)) { + return true; + } + + subTree = subTree.getParent(); + } + + return false; + } + + /** Checks whether subTree is a descendant of this WorkingTree node. */ + public boolean ancestorsHaveLoop() { + Set visitedNodes = new HashSet<>(); + + // For each ancestor node... + for (WorkingTree workNode = this; workNode != null; workNode = workNode.getParent()) { + AccessibilityNodeInfoCompat accessNode = workNode.getNode(); + if (visitedNodes.contains(accessNode)) { + return true; + } + visitedNodes.add(accessNode); + } + return false; + } + + public void swapChild(WorkingTree swappedChild, WorkingTree newChild) { + int position = mChildren.indexOf(swappedChild); + if (position < 0) { + LogUtils.e(TAG, "WorkingTree IllegalStateException: swap child not found"); + return; + } + + mChildren.set(position, newChild); + } + + public @Nullable WorkingTree getNext() { + if (!mChildren.isEmpty()) { + return mChildren.get(0); + } + + WorkingTree startNode = this; + while (startNode != null) { + WorkingTree nextSibling = startNode.getNextSibling(); + if (nextSibling != null) { + return nextSibling; + } + + startNode = startNode.getParent(); + } + + return null; + } + + public @Nullable WorkingTree getNextSibling() { + WorkingTree parent = getParent(); + if (parent == null) { + return null; + } + + int currentIndex = parent.mChildren.indexOf(this); + if (currentIndex < 0) { + LogUtils.e(TAG, "WorkingTree IllegalStateException: swap child not found"); + return null; + } + + currentIndex++; + + if (currentIndex >= parent.mChildren.size()) { + // it was last child + return null; + } + + return parent.mChildren.get(currentIndex); + } + + public @Nullable WorkingTree getPrevious() { + WorkingTree previousSibling = getPreviousSibling(); + if (previousSibling != null) { + return previousSibling.getLastNode(); + } + + return getParent(); + } + + public @Nullable WorkingTree getPreviousSibling() { + WorkingTree parent = getParent(); + if (parent == null) { + return null; + } + + int currentIndex = parent.mChildren.indexOf(this); + if (currentIndex < 0) { + LogUtils.e(TAG, "WorkingTree IllegalStateException: swap child not found"); + return null; + } + + currentIndex--; + + if (currentIndex < 0) { + // it was first child + return null; + } + + return parent.mChildren.get(currentIndex); + } + + public WorkingTree getLastNode() { + WorkingTree node = this; + while (!node.mChildren.isEmpty()) { + node = node.mChildren.get(node.mChildren.size() - 1); + } + + return node; + } + + public WorkingTree getRoot() { + WorkingTree root = this; + WorkingTree parent; + while ((parent = root.getParent()) != null) { + root = parent; + } + + return root; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/DoublePlayPauseButtonShortPressPatternMatcher.java b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/DoublePlayPauseButtonShortPressPatternMatcher.java new file mode 100644 index 0000000..84f2613 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/DoublePlayPauseButtonShortPressPatternMatcher.java @@ -0,0 +1,110 @@ +/* + * Copyright 2018 Google Inc. + * + * 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.google.android.accessibility.utils.volumebutton; + +import android.view.KeyEvent; +import android.view.ViewConfiguration; +import androidx.annotation.Nullable; +import java.util.concurrent.TimeUnit; + +/** Matches the double short press of the play/pause button on wired headset * */ +public class DoublePlayPauseButtonShortPressPatternMatcher extends VolumeButtonPatternMatcher { + + private static final long MULTIPLE_TAP_TIMEOUT = TimeUnit.MINUTES.toMillis(1); + private static final long LONG_PRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout(); + + private int buttonPresses; + @Nullable private VolumeButtonAction lastAction; + private long lastButtonPressEndTimestamp; + + public DoublePlayPauseButtonShortPressPatternMatcher() { + super( + VolumeButtonPatternDetector.SHORT_DOUBLE_PRESS_PARTTERN, + VolumeButtonPatternDetector.PLAY_PAUSE); + } + + @Override + public void onKeyEvent(KeyEvent keyEvent) { + if (keyEvent.getKeyCode() != getButtonCombination()) { + return; + } + + if (interruptActionSequence(keyEvent)) { + clear(); + } + + if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { + lastAction = createAction(keyEvent); + } else { + handleActionUpEvent(keyEvent); + } + + updateCounters(); + } + + private void handleActionUpEvent(KeyEvent event) { + if (lastAction != null) { + lastAction.pressed = false; + lastAction.endTimestamp = event.getEventTime(); + } + } + + private boolean interruptActionSequence(KeyEvent keyEvent) { + if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { + return isDownEventInterruptsActionSequence(keyEvent); + } else { + return isUpEventInterruptsActionSequence(keyEvent); + } + } + + private boolean isDownEventInterruptsActionSequence(KeyEvent keyEvent) { + // Too long timeout between previous buttons pushes. + return buttonPresses != 0 + && keyEvent.getEventTime() - lastButtonPressEndTimestamp > MULTIPLE_TAP_TIMEOUT; + } + + private boolean isUpEventInterruptsActionSequence(KeyEvent keyEvent) { + VolumeButtonAction lastAction = this.lastAction; + if (lastAction == null) { + // This is key up on event on non-pressed button. + return true; + } + + // Check whether button was pressed too long. + return keyEvent.getEventTime() - lastAction.startTimestamp > LONG_PRESS_TIMEOUT; + } + + private void updateCounters() { + if (lastAction != null && !lastAction.pressed) { + buttonPresses++; + lastButtonPressEndTimestamp = lastAction.endTimestamp; + lastAction = null; + } + } + + @Override + public boolean checkMatch() { + return buttonPresses >= 2; + } + + @Override + public void clear() { + buttonPresses = 0; + lastAction = null; + lastButtonPressEndTimestamp = 0; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/DoubleVolumeButtonLongPressPatternMatcher.java b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/DoubleVolumeButtonLongPressPatternMatcher.java new file mode 100644 index 0000000..a799f28 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/DoubleVolumeButtonLongPressPatternMatcher.java @@ -0,0 +1,96 @@ +/* + * Copyright 2015 Google Inc. + * + * 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.google.android.accessibility.utils.volumebutton; + +import android.os.SystemClock; +import android.view.KeyEvent; +import android.view.ViewConfiguration; +import androidx.annotation.Nullable; + +/** A {@link VolumeButtonPatternMatcher} to detect long pressing on both volume up and down keys. */ +public class DoubleVolumeButtonLongPressPatternMatcher extends VolumeButtonPatternMatcher { + + private static final int LONG_PRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout(); + + @Nullable private VolumeButtonAction mVolumeUpAction; + @Nullable private VolumeButtonAction mVolumeDownAction; + + public DoubleVolumeButtonLongPressPatternMatcher() { + super( + VolumeButtonPatternDetector.TWO_BUTTONS_LONG_PRESS_PATTERN, + VolumeButtonPatternDetector.TWO_BUTTONS); + } + + @Override + public void onKeyEvent(KeyEvent keyEvent) { + if (keyEvent.getKeyCode() != KeyEvent.KEYCODE_VOLUME_DOWN + && keyEvent.getKeyCode() != KeyEvent.KEYCODE_VOLUME_UP) { + return; + } + + if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { + handleActionDownEvent(keyEvent); + } else { + handleActionUpEvent(keyEvent); + } + } + + private void handleActionDownEvent(KeyEvent event) { + VolumeButtonAction action = createAction(event); + if (event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP) { + mVolumeUpAction = action; + } else { + mVolumeDownAction = action; + } + } + + private void handleActionUpEvent(KeyEvent event) { + VolumeButtonAction action; + if (event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP) { + action = mVolumeUpAction; + } else { + action = mVolumeDownAction; + } + + if (action != null) { + action.pressed = false; + action.endTimestamp = event.getEventTime(); + } + } + + @Override + public boolean checkMatch() { + long uptime = SystemClock.uptimeMillis(); + if (mVolumeUpAction == null || mVolumeDownAction == null) { + return false; + } + + long doubleButtonStartTimestamp = + Math.max(mVolumeUpAction.startTimestamp, mVolumeDownAction.startTimestamp); + long upButtonEndTimestamp = mVolumeUpAction.pressed ? uptime : mVolumeUpAction.endTimestamp; + long downButtonEndTimestamp = + mVolumeDownAction.pressed ? uptime : mVolumeDownAction.endTimestamp; + long doubleButtonEndTimestamp = Math.min(upButtonEndTimestamp, downButtonEndTimestamp); + return doubleButtonEndTimestamp - doubleButtonStartTimestamp > LONG_PRESS_TIMEOUT; + } + + @Override + public void clear() { + mVolumeUpAction = null; + mVolumeDownAction = null; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/DoubleVolumeButtonThreeShortPressPatternMatcher.java b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/DoubleVolumeButtonThreeShortPressPatternMatcher.java new file mode 100644 index 0000000..faa2251 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/DoubleVolumeButtonThreeShortPressPatternMatcher.java @@ -0,0 +1,174 @@ +/* + * Copyright 2015 Google Inc. + * + * 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.google.android.accessibility.utils.volumebutton; + +import android.view.KeyEvent; +import android.view.ViewConfiguration; +import androidx.annotation.Nullable; +import java.util.concurrent.TimeUnit; + +/** Matches 3 short presses on both volume buttons. */ +public class DoubleVolumeButtonThreeShortPressPatternMatcher extends VolumeButtonPatternMatcher { + + private static final long MULTIPLE_TAP_TIMEOUT = TimeUnit.MINUTES.toMillis(1); + private static final long LONG_PRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout(); + + private int mDoubleButtonPresses; + private long mLastDoubleButtonPressEndTimestamp; + @Nullable private VolumeButtonAction mLastVolumeUpAction; + @Nullable private VolumeButtonAction mLastVolumeDownAction; + + public DoubleVolumeButtonThreeShortPressPatternMatcher() { + super( + VolumeButtonPatternDetector.TWO_BUTTONS_THREE_PRESS_PATTERN, + VolumeButtonPatternDetector.TWO_BUTTONS); + } + + @Override + public void onKeyEvent(KeyEvent keyEvent) { + if (keyEvent.getKeyCode() != KeyEvent.KEYCODE_VOLUME_DOWN + && keyEvent.getKeyCode() != KeyEvent.KEYCODE_VOLUME_UP) { + return; + } + + if (interruptActionSequence(keyEvent)) { + clear(); + } + + if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { + handleActionDownEvent(keyEvent); + } else { + handleActionUpEvent(keyEvent); + } + + updateCounters(); + } + + private void handleActionDownEvent(KeyEvent event) { + VolumeButtonAction action = createAction(event); + if (event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP) { + mLastVolumeUpAction = action; + } else { + mLastVolumeDownAction = action; + } + } + + private void handleActionUpEvent(KeyEvent event) { + VolumeButtonAction action; + if (event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP) { + action = mLastVolumeUpAction; + } else { + action = mLastVolumeDownAction; + } + + if (action != null) { + action.pressed = false; + action.endTimestamp = event.getEventTime(); + } + } + + private boolean interruptActionSequence(KeyEvent keyEvent) { + if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { + return isDownEventInterruptsActionSequence(keyEvent); + } else { + return isUpEventInterruptsActionSequence(keyEvent); + } + } + + private boolean isDownEventInterruptsActionSequence(KeyEvent keyEvent) { + if (mDoubleButtonPresses != 0 + && keyEvent.getEventTime() - mLastDoubleButtonPressEndTimestamp > MULTIPLE_TAP_TIMEOUT) { + // too long timeout between previous double volume buttons pushes + return true; + } + + VolumeButtonAction otherAction = getOtherAction(keyEvent); + //noinspection RedundantIfStatement + if (otherAction != null && !otherAction.pressed) { + // other button was released before current action pressed + return true; + } + + return false; + } + + private boolean isUpEventInterruptsActionSequence(KeyEvent keyEvent) { + VolumeButtonAction currentAction = getCurrentAction(keyEvent); + VolumeButtonAction otherAction = getOtherAction(keyEvent); + if (currentAction == null) { + // up on non-pressed button + return true; + } + + if (otherAction == null) { + // click detected while other button was not pressed + return true; + } + + //noinspection RedundantIfStatement + if (keyEvent.getEventTime() - currentAction.startTimestamp > LONG_PRESS_TIMEOUT) { + // button was pressed too long + return true; + } + + return false; + } + + @Nullable + private VolumeButtonAction getCurrentAction(KeyEvent keyEvent) { + if (keyEvent.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP) { + return mLastVolumeUpAction; + } else { + return mLastVolumeDownAction; + } + } + + @Nullable + private VolumeButtonAction getOtherAction(KeyEvent keyEvent) { + if (keyEvent.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP) { + return mLastVolumeDownAction; + } else { + return mLastVolumeUpAction; + } + } + + private void updateCounters() { + if (mLastVolumeUpAction != null + && !mLastVolumeUpAction.pressed + && mLastVolumeDownAction != null + && !mLastVolumeDownAction.pressed) { + mDoubleButtonPresses++; + mLastDoubleButtonPressEndTimestamp = + Math.max(mLastVolumeUpAction.endTimestamp, mLastVolumeDownAction.endTimestamp); + mLastVolumeUpAction = null; + mLastVolumeDownAction = null; + } + } + + @Override + public boolean checkMatch() { + return mDoubleButtonPresses >= 3; + } + + @Override + public void clear() { + mDoubleButtonPresses = 0; + mLastDoubleButtonPressEndTimestamp = 0; + mLastVolumeUpAction = null; + mLastVolumeDownAction = null; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/SingleVolumeButtonPressPatternMatcher.java b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/SingleVolumeButtonPressPatternMatcher.java new file mode 100644 index 0000000..f925926 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/SingleVolumeButtonPressPatternMatcher.java @@ -0,0 +1,88 @@ +/* + * Copyright 2015 Google Inc. + * + * 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.google.android.accessibility.utils.volumebutton; + +import android.os.SystemClock; +import android.view.KeyEvent; +import android.view.ViewConfiguration; +import androidx.annotation.Nullable; +import com.google.android.accessibility.utils.volumebutton.VolumeButtonPatternDetector.ButtonSequence; + +/** A {@link VolumeButtonPatternMatcher} to detect short pressing or long pressing on a key. */ +public class SingleVolumeButtonPressPatternMatcher extends VolumeButtonPatternMatcher { + + private static final int LONG_PRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout(); + + @Nullable private VolumeButtonAction mAction; + @ButtonSequence private final int patternCode; + + public SingleVolumeButtonPressPatternMatcher(@ButtonSequence int patternCode, int keyCode) { + super(patternCode, keyCode); + if (patternCode != VolumeButtonPatternDetector.SHORT_PRESS_PATTERN + && patternCode != VolumeButtonPatternDetector.LONG_PRESS_PATTERN) { + throw new IllegalArgumentException( + "patternCode must be either SHORT_PRESS_PATTERN or LONG_PRESS_PATTERN"); + } + this.patternCode = patternCode; + } + + @Override + public void onKeyEvent(KeyEvent keyEvent) { + if (keyEvent.getKeyCode() != getButtonCombination()) { + return; + } + + if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { + handleActionDownEvent(keyEvent); + } else { + handleActionUpEvent(keyEvent); + } + } + + private void handleActionDownEvent(KeyEvent event) { + mAction = createAction(event); + } + + private void handleActionUpEvent(KeyEvent event) { + if (mAction != null) { + mAction.pressed = false; + mAction.endTimestamp = event.getEventTime(); + } + } + + @Override + public boolean checkMatch() { + VolumeButtonAction action = mAction; + if (action == null) { + return false; + } + + if (patternCode == VolumeButtonPatternDetector.SHORT_PRESS_PATTERN) { + return !action.pressed && action.endTimestamp - action.startTimestamp < LONG_PRESS_TIMEOUT; + } else if (patternCode == VolumeButtonPatternDetector.LONG_PRESS_PATTERN) { + long buttonEndTimestamp = action.pressed ? SystemClock.uptimeMillis() : action.endTimestamp; + return buttonEndTimestamp - action.startTimestamp >= LONG_PRESS_TIMEOUT; + } + + return false; + } + + @Override + public void clear() { + mAction = null; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/VolumeButtonAction.java b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/VolumeButtonAction.java new file mode 100644 index 0000000..c077740 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/VolumeButtonAction.java @@ -0,0 +1,25 @@ +/* + * Copyright 2015 Google Inc. + * + * 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.google.android.accessibility.utils.volumebutton; + +/** Defines attributes for a volume button action. */ +public class VolumeButtonAction { + public int button; + public long startTimestamp; + public long endTimestamp; + public boolean pressed; +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/VolumeButtonPatternDetector.java b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/VolumeButtonPatternDetector.java new file mode 100644 index 0000000..116deb5 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/VolumeButtonPatternDetector.java @@ -0,0 +1,154 @@ +/* + * Copyright 2015 Google Inc. + * + * 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.google.android.accessibility.utils.volumebutton; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.view.KeyEvent; +import android.view.ViewConfiguration; +import androidx.annotation.IntDef; +import com.google.android.accessibility.utils.Performance; +import com.google.android.accessibility.utils.Performance.EventId; +import java.util.ArrayList; +import java.util.List; + +/** + * This class listens to key events, and when it detects certain key combinations, it creates + * key-combination events. This class contains various VolumeButtonPatternMatcher subclasses. + */ +// TODO : Simplify VolumeButtonPatternMatcher subclasses. +public class VolumeButtonPatternDetector { + + /** Constants denoting different sequences of pushing the volume buttons. */ + @IntDef({ + SHORT_PRESS_PATTERN, + LONG_PRESS_PATTERN, + SHORT_DOUBLE_PRESS_PARTTERN, + TWO_BUTTONS_LONG_PRESS_PATTERN, + TWO_BUTTONS_THREE_PRESS_PATTERN + }) + public @interface ButtonSequence {} + + public static final int SHORT_PRESS_PATTERN = 1; + public static final int LONG_PRESS_PATTERN = 2; + public static final int TWO_BUTTONS_LONG_PRESS_PATTERN = 3; + public static final int TWO_BUTTONS_THREE_PRESS_PATTERN = 4; + public static final int SHORT_DOUBLE_PRESS_PARTTERN = 5; + + /** Constants denoting different combinations of the volume buttons. */ + @IntDef({VOLUME_UP, VOLUME_DOWN, TWO_BUTTONS, PLAY_PAUSE}) + public @interface ButtonsUsed {} + + public static final int VOLUME_UP = KeyEvent.KEYCODE_VOLUME_UP; + public static final int VOLUME_DOWN = KeyEvent.KEYCODE_VOLUME_DOWN; + public static final int PLAY_PAUSE = KeyEvent.KEYCODE_HEADSETHOOK; + public static final int TWO_BUTTONS = 1; + + private static final long LONG_PRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout(); + private static final int CHECK_MATCHERS_MESSAGE = 1; + + private OnPatternMatchListener mListener; + private final List patternMatchers; + + private final Handler mHandler = + new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message message) { + if (message.what == CHECK_MATCHERS_MESSAGE) { + checkMatchers(); + } + } + }; + + public VolumeButtonPatternDetector(Context context) { + patternMatchers = new ArrayList<>(); + patternMatchers.add(new SingleVolumeButtonPressPatternMatcher(SHORT_PRESS_PATTERN, VOLUME_UP)); + patternMatchers.add( + new SingleVolumeButtonPressPatternMatcher(SHORT_PRESS_PATTERN, VOLUME_DOWN)); + patternMatchers.add(new SingleVolumeButtonPressPatternMatcher(SHORT_PRESS_PATTERN, PLAY_PAUSE)); + patternMatchers.add(new SingleVolumeButtonPressPatternMatcher(LONG_PRESS_PATTERN, VOLUME_UP)); + patternMatchers.add(new SingleVolumeButtonPressPatternMatcher(LONG_PRESS_PATTERN, VOLUME_DOWN)); + patternMatchers.add(new SingleVolumeButtonPressPatternMatcher(LONG_PRESS_PATTERN, PLAY_PAUSE)); + patternMatchers.add(new DoubleVolumeButtonLongPressPatternMatcher()); + patternMatchers.add(new DoubleVolumeButtonThreeShortPressPatternMatcher()); + } + + public boolean onKeyEvent(KeyEvent keyEvent) { + if (!isFromVolumeKey(keyEvent.getKeyCode())) { + return false; + } + + processKeyEvent(keyEvent); + checkMatchers(); + + mHandler.removeMessages(CHECK_MATCHERS_MESSAGE); + mHandler.sendEmptyMessageDelayed(CHECK_MATCHERS_MESSAGE, LONG_PRESS_TIMEOUT); + return true; + } + + private static boolean isFromVolumeKey(int keyCode) { + switch (keyCode) { + case KeyEvent.KEYCODE_VOLUME_DOWN: + case KeyEvent.KEYCODE_VOLUME_UP: + case KeyEvent.KEYCODE_HEADSETHOOK: + return true; + default: + return false; + } + } + + private void processKeyEvent(KeyEvent event) { + for (VolumeButtonPatternMatcher matcher : patternMatchers) { + matcher.onKeyEvent(event); + } + } + + private void checkMatchers() { + for (VolumeButtonPatternMatcher matcher : patternMatchers) { + if (matcher.checkMatch()) { + EventId eventId = + Performance.getInstance().onVolumeKeyComboEventReceived(matcher.getPatternCode()); + notifyPatternMatched(matcher.getPatternCode(), matcher.getButtonCombination(), eventId); + matcher.clear(); + } + } + } + + public void clearState() { + for (VolumeButtonPatternMatcher matcher : patternMatchers) { + matcher.clear(); + } + } + + public void setOnPatternMatchListener(OnPatternMatchListener listener) { + mListener = listener; + } + + private void notifyPatternMatched( + @ButtonSequence int patternCode, @ButtonsUsed int buttonCombination, EventId eventId) { + if (mListener != null) { + mListener.onPatternMatched(patternCode, buttonCombination, eventId); + } + } + + public interface OnPatternMatchListener { + public void onPatternMatched( + @ButtonSequence int patternCode, @ButtonsUsed int buttonCombination, EventId eventId); + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/VolumeButtonPatternMatcher.java b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/VolumeButtonPatternMatcher.java new file mode 100644 index 0000000..002c2f7 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/volumebutton/VolumeButtonPatternMatcher.java @@ -0,0 +1,65 @@ +/* + * Copyright 2015 Google Inc. + * + * 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.google.android.accessibility.utils.volumebutton; + +import android.view.KeyEvent; +import com.google.android.accessibility.utils.volumebutton.VolumeButtonPatternDetector.ButtonSequence; +import com.google.android.accessibility.utils.volumebutton.VolumeButtonPatternDetector.ButtonsUsed; + +/** Matches patterned action on volume buttons. */ +// TODO: Simplify VolumeButtonPatternMatcher subclasses. +public abstract class VolumeButtonPatternMatcher { + + @ButtonSequence private final int mPatternCode; + @ButtonsUsed private final int mButtonCombination; + + public VolumeButtonPatternMatcher( + @ButtonSequence int patternCode, @ButtonsUsed int buttonCombination) { + mPatternCode = patternCode; + mButtonCombination = buttonCombination; + } + + @ButtonSequence + public int getPatternCode() { + return mPatternCode; + } + + @ButtonsUsed + public int getButtonCombination() { + return mButtonCombination; + } + + protected VolumeButtonAction createAction(KeyEvent downEvent) { + if (downEvent == null || downEvent.getAction() != KeyEvent.ACTION_DOWN) { + throw new IllegalArgumentException(); + } + + VolumeButtonAction action = new VolumeButtonAction(); + action.button = downEvent.getKeyCode(); + action.startTimestamp = downEvent.getEventTime(); + action.endTimestamp = downEvent.getEventTime(); + action.pressed = true; + + return action; + } + + public abstract void onKeyEvent(KeyEvent keyEvent); + + public abstract boolean checkMatch(); + + public abstract void clear(); +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/widget/DialogUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/widget/DialogUtils.java new file mode 100644 index 0000000..660ca61 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/widget/DialogUtils.java @@ -0,0 +1,16 @@ +package com.google.android.accessibility.utils.widget; + +import android.view.Window; +import android.view.WindowManager; + +/** Helper functions related dialog settings. */ +public class DialogUtils { + + public static void setWindowTypeToDialog(Window window) { + window.setType(getDialogType()); + } + + public static int getDialogType() { + return WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY; + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/widget/SimpleOverlay.java b/utils/src/main/java/com/google/android/accessibility/utils/widget/SimpleOverlay.java new file mode 100644 index 0000000..5d1c202 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/widget/SimpleOverlay.java @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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.google.android.accessibility.utils.widget; + +import android.content.Context; +import android.graphics.PixelFormat; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnKeyListener; +import android.view.View.OnTouchListener; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.WindowManager.LayoutParams; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.FrameLayout; +import androidx.annotation.Nullable; + +/** Provides a simple full-screen overlay. Behaves like a {@link android.app.Dialog} but simpler. */ +public class SimpleOverlay { + private final Context context; + private final WindowManager windowManager; + private final ViewGroup contentView; + private final LayoutParams params; + private final int id; + + private SimpleOverlayListener listener; + private OnTouchListener touchListener; + private OnKeyListener keyListener; + private boolean isVisible; + @Nullable private CharSequence rootViewClassName = null; + + /** + * Creates a new simple overlay that does not send {@link AccessibilityEvent}s. + * + * @param context The parent context. + */ + public SimpleOverlay(Context context) { + this(context, 0); + } + + /** + * Creates a new simple overlay that does not send {@link AccessibilityEvent}s. + * + * @param context The parent context. + * @param id An optional identifier for the overlay. + */ + public SimpleOverlay(Context context, int id) { + this(context, id, false); + } + + /** + * Creates a new simple overlay. + * + * @param context The parent context. + * @param id An optional identifier for the overlay. + * @param sendsAccessibilityEvents Whether this window should dispatch {@link + * AccessibilityEvent}s. + */ + public SimpleOverlay(Context context, int id, final boolean sendsAccessibilityEvents) { + this.context = context; + windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + contentView = + new FrameLayout(context) { + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if ((keyListener != null) && keyListener.onKey(this, event.getKeyCode(), event)) { + return true; + } + + return super.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + // TODO: Check if we should adjust position after notifying touch listener. + event.offsetLocation(-getTranslationX(), -getTranslationY()); + if ((touchListener != null) && touchListener.onTouch(this, event)) { + return true; + } + + return super.dispatchTouchEvent(event); + } + + @Override + public boolean requestSendAccessibilityEvent(View view, AccessibilityEvent event) { + if (sendsAccessibilityEvents) { + return super.requestSendAccessibilityEvent(view, event); + } else { + // Never send accessibility events if sendAccessibilityEvents == false. + return false; + } + } + + @Override + public void sendAccessibilityEventUnchecked(AccessibilityEvent event) { + if (sendsAccessibilityEvents) { + super.sendAccessibilityEventUnchecked(event); + } else { + // Never send accessibility events if sendAccessibilityEvents == false. + return; + } + } + + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + if (rootViewClassName != null) { + info.setClassName(rootViewClassName); + } + } + }; + + params = new WindowManager.LayoutParams(); + params.type = LayoutParams.TYPE_ACCESSIBILITY_OVERLAY; + params.format = PixelFormat.TRANSLUCENT; + params.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; + + this.id = id; + + isVisible = false; + } + + /** @return The overlay context. */ + public Context getContext() { + return context; + } + + /** @return The overlay identifier, or {@code 0} if no identifier was provided at construction. */ + public int getId() { + return id; + } + + /** + * Sets class name in {@link AccessibilityNodeInfo} of the root view. This is a work around for + * Android L where we cannot override the class name of FrameLayout by setting + * AccessibilityDelegate. + */ + public void setRootViewClassName(CharSequence className) { + rootViewClassName = className; + } + + /** + * Sets the key listener. + * + * @param keyListener + */ + public void setOnKeyListener(OnKeyListener keyListener) { + this.keyListener = keyListener; + } + + /** + * Sets the touch listener. + * + * @param touchListener + */ + public void setOnTouchListener(OnTouchListener touchListener) { + this.touchListener = touchListener; + } + + /** + * Sets the listener for overlay visibility callbacks. + * + * @param listener + */ + public void setListener(SimpleOverlayListener listener) { + this.listener = listener; + } + + /** + * Shows the overlay. Calls the listener's {@link SimpleOverlayListener#onShow(SimpleOverlay)} if + * available. + */ + public void show() { + if (isVisible) { + return; + } + + windowManager.addView(contentView, params); + isVisible = true; + + if (listener != null) { + listener.onShow(this); + } + + onShow(); + } + + /** + * Hides the overlay. Calls the listener's {@link SimpleOverlayListener#onHide(SimpleOverlay)} if + * available. + */ + public void hide() { + if (!isVisible) { + return; + } + + windowManager.removeViewImmediate(contentView); + isVisible = false; + + if (listener != null) { + listener.onHide(this); + } + + onHide(); + } + + /** Called after {@link #show()}. */ + protected void onShow() { + // Do nothing. + } + + /** Called after {@link #hide()}. */ + protected void onHide() { + // Do nothing. + } + + /** @return A copy of the current layout parameters. */ + public LayoutParams getParams() { + final LayoutParams copy = new LayoutParams(); + copy.copyFrom(params); + return copy; + } + + /** + * Sets the current layout parameters and applies them immediately. + * + * @param params The layout parameters to use. + */ + public void setParams(LayoutParams params) { + this.params.copyFrom(params); + updateViewLayout(); + } + + /** Updates the current layout if this overlay is visible. */ + public void updateViewLayout() { + if (isVisible) { + windowManager.updateViewLayout(contentView, this.params); + } + } + + /** @return {@code true} if this overlay is visible. */ + public boolean isVisible() { + return isVisible; + } + + /** + * Inflates the specified resource ID and sets it as the content view. + * + * @param layoutResId The layout ID of the view to set as the content view. + */ + public void setContentView(int layoutResId) { + contentView.removeAllViews(); + final LayoutInflater inflater = LayoutInflater.from(context); + inflater.inflate(layoutResId, contentView); + } + + /** + * Sets the specified view as the content view. + * + * @param content The view to set as the content view. + */ + public void setContentView(View content) { + contentView.removeAllViews(); + contentView.addView(content); + } + + /** + * Returns the root {@link View} for this overlay. This is not the content view. + */ + public View getRootView() { + return contentView; + } + + /** + * Finds and returns the view within the overlay content. + * + * @param id The ID of the view to return. + * @return The view with the specified ID, or {@code null} if not found. + */ + public View findViewById(int id) { + return contentView.findViewById(id); + } + + /** Handles overlay visibility change callbacks. */ + public interface SimpleOverlayListener { + /** + * Called after the overlay is displayed. + * + * @param overlay The overlay that was displayed. + */ + public void onShow(SimpleOverlay overlay); + + /** + * Called after the overlay is hidden. + * + * @param overlay The overlay that was hidden. + */ + public void onHide(SimpleOverlay overlay); + } +} diff --git a/utils/src/main/java/com/google/android/libraries/accessibility/utils/bitmap/BitmapUtils.java b/utils/src/main/java/com/google/android/libraries/accessibility/utils/bitmap/BitmapUtils.java new file mode 100644 index 0000000..47b04cd --- /dev/null +++ b/utils/src/main/java/com/google/android/libraries/accessibility/utils/bitmap/BitmapUtils.java @@ -0,0 +1,63 @@ +package com.google.android.libraries.accessibility.utils.bitmap; + +import android.graphics.Bitmap; +import android.graphics.Rect; +import androidx.annotation.Nullable; +import com.google.android.libraries.accessibility.utils.log.LogUtils; + +/** Utility methods for handling common operations on {@link Bitmap}s */ +public final class BitmapUtils { + + private static final String TAG = "BitmapUtils"; + + private BitmapUtils() {} + + /** + * Return a cropped {@link Bitmap} given a certain {@link Rect} cropping region. + * + * @param bitmap The {@link Bitmap} to be cropped to the size of the {@code cropRegion}. + * @param cropRegion The {@link Rect} representing the desired crop area with respect to the + * {@code bitmap}. Note that if the cropRegion extends outside the bitmap's area, the crop + * will happen at the overlap of the crop area and the bitmap's area (assuming they overlap). + * @return A new {@link Bitmap} containing the cropped image, or {@code null} if the crop region + * does not intersect the bitmap's region (e.g. the cropRegion is of size 0, or it is + * completely outside of the Bitmap). + */ + @Nullable + public static Bitmap cropBitmap(Bitmap bitmap, Rect cropRegion) { + Rect bitmapRegion = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + + // Just in case cropRegion extends past the bitmap, fit bitmapRegion down to cropRegion size. + // Additionally if they don't intersect, return null + if (cropRegion.isEmpty() || (!bitmapRegion.intersect(cropRegion))) { + return null; + } + + return Bitmap.createBitmap( + bitmap, bitmapRegion.left, bitmapRegion.top, bitmapRegion.width(), bitmapRegion.height()); + } + + /** + * Returns a cropped {@link Bitmap} for the specified, rectangular region of pixels from the + * source bitmap. + * + *

The source bitmap is unaffected by this operation. + * + * @param bitmap The source bitmap to crop. + * @param left The leftmost coordinate to include in the cropped image + * @param top The toptmost coordinate to include in the cropped image + * @param width The width of the cropped image + * @param height The height of the cropped image + * @return A new bitmap of the cropped area, or {@code null} if the crop parameters were out of + * bounds. + */ + @Nullable + public static Bitmap cropBitmap(Bitmap bitmap, int left, int top, int width, int height) { + try { + return Bitmap.createBitmap(bitmap, left, top, width, height); + } catch (IllegalArgumentException ex) { + LogUtils.e(TAG, ex, "Cropping arguments out of bounds"); + return null; + } + } +} diff --git a/utils/src/main/java/com/google/android/libraries/accessibility/utils/log/LogUtils.java b/utils/src/main/java/com/google/android/libraries/accessibility/utils/log/LogUtils.java new file mode 100644 index 0000000..bc8e443 --- /dev/null +++ b/utils/src/main/java/com/google/android/libraries/accessibility/utils/log/LogUtils.java @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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.google.android.libraries.accessibility.utils.log; + +import android.util.Log; +import com.google.common.base.Strings; +import java.util.IllegalFormatException; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Handles logging formatted strings. */ +public class LogUtils { + + private LogUtils() {} // Not instantiable. + + /** Plug-in for custom printing complex objects, especially accessibility event & node. */ + public interface ParameterCustomizer { + /** Converts an object for display. */ + @Nullable + Object customize(@Nullable Object object); + } + + /** Customizer for log parameters. By default, changes nothing. */ + private static @Nullable ParameterCustomizer parameterCustomizer = null; + + private static final String TAG = "LogUtils"; + + /** + * The minimum log level that will be printed to the console. Set this to {@link Log#ERROR} for + * release or {@link Log#VERBOSE} for debugging. + */ + private static int minLogLevel = Log.ERROR; + + private static String logTagPrefix = ""; + + /** + * Set the prefix that will be prepended to all logging tags. This is useful for filtering logs + * specific to a particular application. + */ + public static void setTagPrefix(String prefix) { + logTagPrefix = prefix; + } + + /** + * Logs a formatted string to the console. + * + * @param tag The tag that should be associated with the event + * @param priority The log entry priority, see {@link Log#println(int, String, String)} + * @param index The index of the log entry in the current log sequence + * @param limit The maximum number of log entries allowed in the current sequence + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void logWithLimit( + String tag, int priority, int index, int limit, String format, @Nullable Object... args) { + String formatWithIndex; + if (index > limit) { + return; + } else if (index == limit) { + formatWithIndex = String.format("%s (%d); further messages suppressed", format, index); + } else { + formatWithIndex = String.format("%s (%d)", format, index); + } + + log(tag, priority, formatWithIndex, args); + } + + /** + * Logs a string to the console at the VERBOSE log level. + * + * @param tag The tag that should be associated with the event + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void v(String tag, @Nullable String format, @Nullable Object... args) { + log(tag, Log.VERBOSE, format, args); + } + + /** + * Logs a string to the console. + * + * @param tag The tag that should be associated with the event + * @param throwable A {@link Throwable} containing system state information to log + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void v(String tag, Throwable throwable, String format, @Nullable Object... args) { + log(tag, Log.VERBOSE, throwable, format, args); + } + + /** + * Logs a string to the console at the DEBUG log level. + * + * @param tag The tag that should be associated with the event + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void d(String tag, String format, @Nullable Object... args) { + log(tag, Log.DEBUG, format, args); + } + + /** + * Logs a string to the console at the DEBUG log level. + * + * @param tag The tag that should be associated with the event + * @param throwable A {@link Throwable} containing system state information to log + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void d(String tag, Throwable throwable, String format, @Nullable Object... args) { + log(tag, Log.DEBUG, throwable, format, args); + } + + /** + * Logs a string to the console at the INFO log level. + * + * @param tag The tag that should be associated with the event + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void i(String tag, String format, @Nullable Object... args) { + log(tag, Log.INFO, format, args); + } + + /** + * Logs a string to the console at the INFO log level. + * + * @param tag The tag that should be associated with the event + * @param throwable A {@link Throwable} containing system state information to log + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void i(String tag, Throwable throwable, String format, @Nullable Object... args) { + log(tag, Log.INFO, throwable, format, args); + } + + /** + * Logs a string to the console at the WARN log level. + * + * @param tag The tag that should be associated with the event + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void w(String tag, String format, @Nullable Object... args) { + log(tag, Log.WARN, format, args); + } + + /** + * Logs a string to the console at the WARN log level. + * + * @param tag The tag that should be associated with the event + * @param throwable A {@link Throwable} containing system state information to log + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void w(String tag, Throwable throwable, String format, @Nullable Object... args) { + log(tag, Log.WARN, throwable, format, args); + } + + /** + * Logs a string to the console at the ERROR log level. + * + * @param tag The tag that should be associated with the event + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void e(String tag, @Nullable String format, @Nullable Object... args) { + log(tag, Log.ERROR, format, args); + } + + /** + * Logs a string to the console at the ERROR log level. + * + * @param tag The tag that should be associated with the event + * @param throwable A {@link Throwable} containing system state information to log + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void e(String tag, Throwable throwable, String format, @Nullable Object... args) { + log(tag, Log.ERROR, throwable, format, args); + } + + /** + * Logs a string to the console at the ASSERT log level. + * + * @param tag The tag that should be associated with the event + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void wtf(String tag, String format, @Nullable Object... args) { + log(tag, Log.ASSERT, format, args); + } + + /** + * Logs a string to the console at the ASSERT log level. + * + * @param tag The tag that should be associated with the event + * @param throwable A {@link Throwable} containing system state information to log + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void wtf(String tag, Throwable throwable, String format, @Nullable Object... args) { + log(tag, Log.ASSERT, throwable, format, args); + } + + /** + * Logs a formatted string to the console. + * + *

Example usage:
+ * + * LogUtils.log("LogUtils", Log.ERROR, myException, "Invalid value: %d", value); + * + * + * @param tag The tag that should be associated with the event + * @param priority The log entry priority, see {@link Log#println(int, String, String)} + * @param throwable A {@link Throwable} containing system state information to log + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void log( + String tag, + int priority, + @Nullable Throwable throwable, + @Nullable String format, + @Nullable Object... args) { + if (priority < minLogLevel) { + return; + } + + String prefixedTag = logTagPrefix + tag; + + // For each argument... replace with custom text. + if (parameterCustomizer != null) { + for (int a = 0; a < args.length; ++a) { + args[a] = parameterCustomizer.customize(args[a]); + } + } + + try { + String message = String.format(Strings.nullToEmpty(format), args); + if (throwable == null) { + Log.println(priority, prefixedTag, message); + } else { + Log.println( + priority, + prefixedTag, + String.format("%s\n%s", message, Log.getStackTraceString(throwable))); + } + } catch (IllegalFormatException e) { + Log.e(TAG, "Bad formatting string: \"" + format + "\"", e); + } + } + + /** + * Logs a formatted string to the console. + * + *

Example usage:
+ * + * LogUtils.log("LogUtils", Log.ERROR, "Invalid value: %d", value); + * + * + * @param tag The tag that should be associated with the event + * @param priority The log entry priority, see {@link Log#println(int, String, String)} + * @param format A format string, see {@link String#format(String, Object...)} + * @param args String formatter arguments + */ + public static void log( + String tag, int priority, @Nullable String format, @Nullable Object... args) { + log(tag, priority, null, format, args); + } + + /** Sets customizer for log parameters. */ + public static void setParameterCustomizer(@Nullable ParameterCustomizer parameterCustomizerArg) { + parameterCustomizer = parameterCustomizerArg; + } + + /** + * Sets the log display level. + * + * @param logLevel The minimum log level that will be printed to the console. + */ + public static void setLogLevel(int logLevel) { + minLogLevel = logLevel; + } + + /** Gets the log display level. */ + public static int getLogLevel() { + return minLogLevel; + } + + /** + * Tells if the current settings are at or above the specified level of verboseness. This + * indicates whether or not a log statement at the provided level will actually result in logging. + * + * @param logLevel The log level to compare the current setting to. + */ + public static boolean shouldLog(int logLevel) { + // Higher levels of verbosity correspond with lower int values. + // https://developer.android.com/reference/android/util/Log.html#ASSERT + return minLogLevel <= logLevel; + } +} diff --git a/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/AndroidManifest.xml b/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/AndroidManifest.xml new file mode 100644 index 0000000..f318544 --- /dev/null +++ b/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/README.md b/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/README.md new file mode 100644 index 0000000..66c3b90 --- /dev/null +++ b/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/README.md @@ -0,0 +1,41 @@ +# `ScreenCaptureController` + +The `ScreenCaptureController` API allows client applications to request +permission for and take screenshots of any Android screen. There are two main +methods used to accomplish this: + + * `authorizeCaptureAsync(AuthorizationListener)` requests permission of the + user to capture their screen. + * `requestScreenCaptureAsync(CaptureListener)` takes a screenshot. + +## API +`authorizeCaptureAsync()` will request the necessary screen capture permissions +that are used to fetch an image of the screen. This method should ideally only +need to be called once, as long as you use the same instance of +`ScreenCaptureController` to call `requestScreenCaptureAsync()` as you use to +call `authorizeCaptureAsync()`. Calling `deauthorizeCapture()` will +end the underlying `MediaProjection` session. Note that users have the ability +to manually end a seesion at any time. While aurhotized, a notification icon +will appear within the device's status bar, informing them of the application's +ability to capture screenshots. + +`requestScreenCaptureAsync()` will take a screenshot, and then will call back +`CaptureListener.onScreenCaptureFinished()`. If the instance of +`ScreenCaptureController` has not been authorized, authorization will be +requested and retained for just this single screenshot, after which the instance +of `ScreenCaptureController` with self-deauthorize. + +`shutdown()` disposes of underlying resources, and should be invoked when an +instance of `ScreenCaptureController` is no longer needed. + +## Compatibility + +The minimum supported API version for this library is 22. Note that docs for +`MediaProjection` APIs say 21, but are incorrect. If you attempt to use public +methods exposed by this library on devices with API versions earlier than 22, +they will no-op, and provided callbacks will not be invoked. + +An implementing application may choose to define an `android:taskAffinity` +attribute in its local `AndroidManifest.xml` declaration of +`ScreenshotAuthProxyActivity` to ensure it is not added to the same task group +as the application's other activities. diff --git a/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/ScreenCaptureController.java b/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/ScreenCaptureController.java new file mode 100644 index 0000000..7526eb8 --- /dev/null +++ b/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/ScreenCaptureController.java @@ -0,0 +1,465 @@ +package com.google.android.libraries.accessibility.utils.screencapture; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Bitmap; +import android.graphics.PixelFormat; +import android.hardware.display.DisplayManager; +import android.hardware.display.VirtualDisplay; +import android.media.Image; +import android.media.Image.Plane; +import android.media.ImageReader; +import android.media.projection.MediaProjection; +import android.media.projection.MediaProjectionManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.DisplayMetrics; +import android.view.WindowManager; +import androidx.annotation.Nullable; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import com.google.android.libraries.accessibility.utils.bitmap.BitmapUtils; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import java.nio.ByteBuffer; + +/** + * Manages the capture of images from the device's frame buffer as {@link Bitmap}s via + * {@link MediaProjection} APIs. + */ +@TargetApi(Build.VERSION_CODES.LOLLIPOP_MR1) +public class ScreenCaptureController { + + private static final String TAG = "ScreenCaptureController"; + + private static final String VIRTUAL_DISPLAY_NAME = + "com.google.android.libraries.accessibility.utils.screencapture.VIRTUAL_DISPLAY_SCREEN_CAPTURE"; + + /** Context used for authorizing screen capture */ + private final Context context; + + /** Handler used to post callbacks */ + private Handler handler; + + /** System service instance for obtaining a screen capture authorization token */ + private MediaProjectionManager projectionManager; + + /** Used to register for local broadcasts in changes to screen capture authorization state */ + private LocalBroadcastManager broadcastManager; + + /** Used to keep track of the virtual display so it can be released */ + private VirtualDisplay virtualDisplay; + + /** Used to track our ImageReader and ensure it isn't garbage collected */ + private ImageReader imageReader; + + /** Callback used to deauthorize capture if projection is stopped by the system */ + private final MediaProjection.Callback projectionCallback = + new MediaProjection.Callback() { + @Override + public void onStop() { + deauthorizeCapture(); + } + }; + + /** + * Screen capture authorization token. When {@code null}, the user has not authorized us to + * capture frames. + */ + private MediaProjection activeProjection; + + public ScreenCaptureController(Context context) { + this(context, new Handler(Looper.getMainLooper())); + } + + public ScreenCaptureController(Context context, Handler handler) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { + this.context = null; + return; + } + + this.context = context; + this.handler = handler; + this.projectionManager = + (MediaProjectionManager) context.getSystemService(Context.MEDIA_PROJECTION_SERVICE); + this.broadcastManager = LocalBroadcastManager.getInstance(context); + } + + /** + * @return {@code True} if this instance has been authorized by the user to request screen capture + * data, {@code false} otherwise. + */ + public boolean canRequestScreenCapture() { + return activeProjection != null; + } + + /** + * Begins the asynchronous process by which the user authorizes this instance to request screen + * capture data. + * + * @param listener An {@link AuthorizationListener} which can be used to determine when the user + * authorizes this instance to request screen capture data or when the user declines the + * authorization request. + */ + public void authorizeCaptureAsync(@Nullable final AuthorizationListener listener) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { + return; + } + + if (canRequestScreenCapture()) { + LogUtils.w(TAG, "Authorization requested for previously authorized instance."); + // Instance already authorized + handler.post( + () -> { + if (listener != null) { + listener.onAuthorizationFinished(true); + } + }); + return; + } + + // Begin authorization + handler.post( + () -> { + if (listener != null) { + listener.onAuthorizationStarted(); + } + }); + + ScreenshotAuthorizationReceiver receiver = new ScreenshotAuthorizationReceiver(listener); + broadcastManager.registerReceiver(receiver, receiver.getFilter()); + + Intent intent = new Intent(context, ScreenshotAuthProxyActivity.class); + intent.putExtra(ScreenshotAuthProxyActivity.INTENT_EXTRA_SCREEN_CAPTURE_INTENT, + projectionManager.createScreenCaptureIntent()); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + context.startActivity(intent); + } + + /** + * Deauthorizes this instance from being able to request screen capture data. Once called, + * {@link #authorizeCaptureAsync(AuthorizationListener)} may be invoked to request a new + * authorization, and calls to {@link #requestScreenCaptureAsync(CaptureListener)} will implicitly + * attempt to authorize this instance. + */ + public void deauthorizeCapture() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { + return; + } + + LogUtils.i(TAG, "Deauthorizing."); + if (activeProjection != null) { + activeProjection.unregisterCallback(projectionCallback); + activeProjection.stop(); + activeProjection = null; + } + if (virtualDisplay != null) { + virtualDisplay.release(); + virtualDisplay = null; + } + if (imageReader != null) { + imageReader.close(); + imageReader = null; + } + } + + /** + * Deauthorizes capture and shuts down all resources managed by this instance. + */ + public void shutdown() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { + return; + } + + deauthorizeCapture(); + } + + /** + * Captures a single frame from the device's frame buffer. The frame is captured asynchronously + * and passed to the supplied {@code listener}'s + * {@link CaptureListener#onScreenCaptureFinished(Bitmap, boolean)} + *

+ * NOTE: If this instance has not been authorized and {@link #canRequestScreenCapture()} returns + * {@code false}, calling this method will attempt to automatically authorize and deauthorize this + * instance. This process is asynchronous and may require the user to interact with a consent + * dialog displayed by the Android OS prior to performing screen capture. As such, screen capture + * may not occur at the exact point in time that it was requested. For more precise control over + * when screen capture occurs, use {@link #authorizeCaptureAsync(AuthorizationListener)} and wait + * for a successful callback before calling this method. + * + * @param listener A {@link CaptureListener} to be notified when screen capture has completed. + */ + public void requestScreenCaptureAsync(final CaptureListener listener) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { + return; + } + + if (!canRequestScreenCapture()) { + requestManagedScreenCaptureAsync(listener); + return; + } + + DisplayMetrics metrics = new DisplayMetrics(); + WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + windowManager.getDefaultDisplay().getRealMetrics(metrics); + imageReader = ImageReader.newInstance(metrics.widthPixels, metrics.heightPixels, + PixelFormat.RGBA_8888, 1); + imageReader.setOnImageAvailableListener(new ScreenCaptureImageProcessor(listener), handler); + + // Create a virtual display to hold captured frames and implicitly start projection + if (virtualDisplay != null) { + virtualDisplay.release(); + } + + try { + virtualDisplay = activeProjection.createVirtualDisplay(VIRTUAL_DISPLAY_NAME, + metrics.widthPixels, + metrics.heightPixels, + metrics.densityDpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + imageReader.getSurface(), + null, + null); + } catch (SecurityException se) { + LogUtils.e(TAG, "Unexpected invalid MediaProjection token"); + deauthorizeCapture(); + handler.post( + () -> { + if (listener != null) { + listener.onScreenCaptureFinished(null, true); + } + }); + } + } + + private void requestManagedScreenCaptureAsync(final CaptureListener clientListener) { + // Define our own CaptureListener which will invoke the provided listener before automatically + // deauthorizing this instance. + CaptureListener managedCaptureListener = + new CaptureListener() { + + @Override + public void onScreenCaptureFinished( + @Nullable Bitmap screenCapture, boolean isFormatSupported) { + clientListener.onScreenCaptureFinished(screenCapture, isFormatSupported); + deauthorizeCapture(); + } + }; + + // Define our own AuthorizationListener that will request screen capture using our managed + // CaptureListener following a successful authorization. + AuthorizationListener managedAuthListener = + new AuthorizationListener() { + + @Override + public void onAuthorizationStarted() { + /* no implementation needed */ + } + + @Override + public void onAuthorizationFinished(boolean success) { + if (success) { + requestScreenCaptureAsync(managedCaptureListener); + } else { + // Authorization was not granted so pass the user a null Bitmap and truthy format + // boolean. + managedCaptureListener.onScreenCaptureFinished(null, true); + } + } + }; + + // Begin the authorization and capture process + authorizeCaptureAsync(managedAuthListener); + } + + /** + * BroadcastReceiver used to handle broadcasts from ScreenshotAuthProxyActivity, which notifies + * this class of changes in the user consent state for screen capture. + */ + class ScreenshotAuthorizationReceiver extends BroadcastReceiver { + + public static final String ACTION_SCREEN_CAPTURE_AUTHORIZED = + "com.google.android.libraries.accessibility.utils.screencapture.ACTION_SCREEN_CAPTURE_AUTHORIZED"; + + public static final String ACTION_SCREEN_CAPTURE_NOT_AUTHORIZED = + "com.google.android.libraries.accessibility.utils.screencapture.ACTION_SCREEN_CAPTURE_NOT_AUTHORIZED"; + + public static final String INTENT_EXTRA_SCREEN_CAPTURE_AUTH_INTENT = + "com.google.android.libraries.accessibility.utils.screencapture.EXTRA_SCREEN_CAPTURE_AUTH_INTENT"; + + /** Callback to fire when the user authorizes or fails to authorize screen capture. */ + private final AuthorizationListener listener; + + public ScreenshotAuthorizationReceiver(AuthorizationListener listener) { + this.listener = listener; + } + + @Override + public void onReceive(Context context, Intent intent) { + if (ACTION_SCREEN_CAPTURE_AUTHORIZED.equals(intent.getAction())) { + LogUtils.i(TAG, "Screen capture was authorized."); + Intent systemIntent = + (Intent) intent.getParcelableExtra(INTENT_EXTRA_SCREEN_CAPTURE_AUTH_INTENT); + if (systemIntent != null) { + MediaProjection projection = null; + try { + projection = projectionManager.getMediaProjection(Activity.RESULT_OK, systemIntent); + } catch (IllegalStateException ise) { + LogUtils.e(TAG, "MediaProjectionManager indicated projection has already started."); + } + if (projection != null) { + LogUtils.i(TAG, "Obtained MediaProjection from system."); + activeProjection = projection; + activeProjection.registerCallback(projectionCallback, null); + deliverResult(true); + } else { + LogUtils.e(TAG, "Unable to obtain MediaProjection from system."); + deliverResult(false); + } + } else { + LogUtils.e(TAG, "Screen capture token was not valid."); + deliverResult(false); + } + } else if (ACTION_SCREEN_CAPTURE_NOT_AUTHORIZED.equals(intent.getAction())) { + LogUtils.w(TAG, "Screen capture was not authorized."); + deliverResult(false); + } + + broadcastManager.unregisterReceiver(this); + } + + public IntentFilter getFilter() { + IntentFilter filter = new IntentFilter(); + filter.addAction(ACTION_SCREEN_CAPTURE_AUTHORIZED); + filter.addAction(ACTION_SCREEN_CAPTURE_NOT_AUTHORIZED); + return filter; + } + + private void deliverResult(final boolean success) { + if (listener != null) { + handler.post(() -> listener.onAuthorizationFinished(success)); + } + } + } + + /** + * Manages the conversion of screen capture data from an {@link ImageReader} backed by the frame + * buffer to a {@link Bitmap}. + */ + private class ScreenCaptureImageProcessor implements ImageReader.OnImageAvailableListener { + + private final CaptureListener listener; + + public ScreenCaptureImageProcessor(CaptureListener listener) { + this.listener = listener; + } + + @Override + public void onImageAvailable(final ImageReader reader) { + // Unregister ourselves as soon as the first ImageReader frame is available. + reader.setOnImageAvailableListener(null, null); + + // Copying the frame buffer from ImageReader to a Bitmap is expensive, so we push that work to + // another thread, and post the callback on the main thread when this operation finishes. + new Thread( + () -> { + boolean isFormatSupported = true; + Bitmap result = null; + try { + result = getBitmapFromImageReader(reader); + } catch (UnsupportedOperationException e) { + isFormatSupported = false; + } + + deliverResult(result, isFormatSupported); + }) + .start(); + } + + /** + * @param reader An {@link ImageReader} of format {@link PixelFormat#RGBA_8888} + * @return a {@link Bitmap} of format {@link android.graphics.Bitmap.Config#ARGB_8888} + * containing screen capture data processed from the ImageReader's frame buffer. + */ + @Nullable + private Bitmap getBitmapFromImageReader(ImageReader reader) { + Image frame = reader.acquireLatestImage(); + if (frame == null) { + return null; + } + + Image.Plane[] planes = frame.getPlanes(); + if ((planes == null) || (planes.length < 1)) { + return null; + } + + // We only capture data from the first Plane of the Image, as the MediaProjection Surface uses + // only a single plane. + Plane imagePlane = planes[0]; + + // Create a bitmap with a format matching that expected from the ImageReader and copy the + // capture data. + Bitmap bitmap = Bitmap.createBitmap(imagePlane.getRowStride() / imagePlane.getPixelStride(), + frame.getHeight(), Bitmap.Config.ARGB_8888); + ByteBuffer buffer = planes[0].getBuffer(); + bitmap.copyPixelsFromBuffer(buffer); + Bitmap croppedBitmap = BitmapUtils.cropBitmap(bitmap, frame.getCropRect()); + bitmap.recycle(); + frame.close(); + + return croppedBitmap; + } + + private void deliverResult( + @Nullable final Bitmap screenCapture, final boolean isFormatSupported) { + virtualDisplay.release(); + virtualDisplay = null; + imageReader.close(); + imageReader = null; + if (listener != null) { + handler.post(() -> listener.onScreenCaptureFinished(screenCapture, isFormatSupported)); + } + } + } + + /** + * Listener callback interface for authorizing an application to obtain a screen capture data. + * + * @see ScreenCaptureController#authorizeCaptureAsync(AuthorizationListener) + */ + public interface AuthorizationListener { + + /** Invoked before the application requests authorization for screen capture from the user. */ + void onAuthorizationStarted(); + + /** + * Invoked when the user completes authorization for screen capture. + * + * @param success {@code True} if the user authorized the application to use screen capture + * functionality, {@code false} otherwise. + */ + void onAuthorizationFinished(boolean success); + } + + /** + * Listener callback interface for obtain screen capture data. + * + * @see ScreenCaptureController#requestScreenCaptureAsync(CaptureListener) + */ + public interface CaptureListener { + + /** + * Invoked when screen capture completes. + * + * @param screenCapture A {@link Bitmap} containing screen capture data, or {@code null} if + * screen capture data could not be obtained. + * @param isFormatSupported {@code true} if the screen capture's format is supported {@code + * false} otherwise + */ + void onScreenCaptureFinished(@Nullable Bitmap screenCapture, boolean isFormatSupported); + } +} diff --git a/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/ScreenshotAuthProxyActivity.java b/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/ScreenshotAuthProxyActivity.java new file mode 100644 index 0000000..7852fbe --- /dev/null +++ b/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/ScreenshotAuthProxyActivity.java @@ -0,0 +1,75 @@ +package com.google.android.libraries.accessibility.utils.screencapture; + +import android.app.Activity; +import android.content.Intent; +import android.media.projection.MediaProjectionManager; +import android.os.Bundle; +import android.os.Parcelable; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import com.google.android.libraries.accessibility.utils.log.LogUtils; +import com.google.android.libraries.accessibility.utils.screencapture.ScreenCaptureController.ScreenshotAuthorizationReceiver; + +/** + * Short-lived proxy activity without UI for launching {@link MediaProjectionManager}'s user consent + * flow for obtaining a screen capture token. + *

+ * {@link ScreenCaptureController} starts this {@link Activity} via an {@link Intent} containing a + * {@link Parcelable} {@code extra} with the key defined by + * {@link #INTENT_EXTRA_SCREEN_CAPTURE_INTENT}. The extra is another {@code Intent} generated by + * {@link MediaProjectionManager#createScreenCaptureIntent()} and is used to start a consent UI for + * obtaining screen capture data. This activity starts the system-generated {@code Intent} and waits + * for a result. When the user authorizes or cancels the request, the result is passed to + * {@link #onActivityResult(int, int, Intent)}. This class then uses {@link LocalBroadcastManager} + * to pass the result back to a listener held by {@code ScreenCaptureController}. + */ +public class ScreenshotAuthProxyActivity extends Activity { + + public static final String INTENT_EXTRA_SCREEN_CAPTURE_INTENT = + "com.google.android.libraries.accessibility.utils.screencapture.EXTRA_SCREEN_CAPTURE_INTENT"; + + private static final String TAG = "ScreenshotAuthProxyActivity"; + + private static final int INTENT_REQUEST_CODE_SCREEN_CAPTURE_AUTH = 1000; + + private LocalBroadcastManager broadcastManager; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + broadcastManager = LocalBroadcastManager.getInstance(this); + Intent launchIntent = getIntent(); + Intent systemIntent = + (Intent) launchIntent.getParcelableExtra(INTENT_EXTRA_SCREEN_CAPTURE_INTENT); + if (systemIntent == null) { + // A system-generated screen capture authorization Intent must be passed to this activity as + // an Intent extra with the key INTENT_EXTRA_SCREEN_CAPTURE_INTENT. This Intent is obtained + // from MediaProjectionManager and should be added as a parcelable to the Intent that launches + // this activity. + LogUtils.e(TAG, "Could not start authorization as no MediaProjection intent was provided."); + finish(); + } + startActivityForResult(systemIntent, INTENT_REQUEST_CODE_SCREEN_CAPTURE_AUTH); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode != INTENT_REQUEST_CODE_SCREEN_CAPTURE_AUTH) { + LogUtils.e(TAG, "Incorrect request code for activity result"); + return; + } + + if (resultCode == Activity.RESULT_OK) { + Intent wrapperIntent = + new Intent(ScreenshotAuthorizationReceiver.ACTION_SCREEN_CAPTURE_AUTHORIZED); + wrapperIntent.putExtra( + ScreenshotAuthorizationReceiver.INTENT_EXTRA_SCREEN_CAPTURE_AUTH_INTENT, data); + broadcastManager.sendBroadcast(wrapperIntent); + } else { + broadcastManager.sendBroadcast( + new Intent(ScreenshotAuthorizationReceiver.ACTION_SCREEN_CAPTURE_NOT_AUTHORIZED)); + } + + finish(); + } +} diff --git a/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/res/values/styles.xml b/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/res/values/styles.xml new file mode 100644 index 0000000..34432ba --- /dev/null +++ b/utils/src/main/java/com/google/android/libraries/accessibility/utils/screencapture/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + diff --git a/utils/src/main/java/com/google/android/libraries/accessibility/utils/url/SpannableUrl.java b/utils/src/main/java/com/google/android/libraries/accessibility/utils/url/SpannableUrl.java new file mode 100644 index 0000000..64f8a5c --- /dev/null +++ b/utils/src/main/java/com/google/android/libraries/accessibility/utils/url/SpannableUrl.java @@ -0,0 +1,63 @@ +package com.google.android.libraries.accessibility.utils.url; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.text.style.URLSpan; +import android.view.View; +import com.google.auto.value.AutoValue; +import com.google.common.base.Strings; + +/** + * Represents a URL from a {@link android.text.SpannableString} with a URL path and its associated + * text from the {@link android.text.SpannableString}. + */ +@AutoValue +public abstract class SpannableUrl { + + public static SpannableUrl create(String string, URLSpan urlSpan) { + return new AutoValue_SpannableUrl(urlSpan, string); + } + + /** + * A {@link URLSpan} associated with this SpannableUrl. Access to the original URLSpan is needed, + * as subclasses may use {@link URLSpan#onClick(View)} instead of {@link URLSpan#getURL()}. + */ + public abstract URLSpan urlSpan(); + + /** + * The text from the {@link android.text.SpannableString} associated with this SpannableUrl. For + * example, if a spannable string "Click here" has a {@link android.text.style.URLSpan} on the + * text "here," the text value would be "here." It is possible for this to be the same value as + * the URL path. + */ + public abstract String text(); + + /** A URL path from a {@link android.text.style.URLSpan}. */ + public String path() { + return urlSpan().getURL(); + } + + /** Returns {@code true} if the URL text and path are the same. */ + public boolean isTextAndPathEquivalent() { + return text().equals(path()); + } + + // URLSpan.onClick is fine with a null parameter when called from an AccessibilityService. + @SuppressWarnings("nullness:argument") + public void onClick(Context context) { + if (Strings.isNullOrEmpty(path())) { + // If the path is null or empty, use the onClick listener. + urlSpan().onClick(null); + } else { + try { + UrlUtils.openUrlWithIntent(context, path()); + } catch (ActivityNotFoundException e) { + // Sometimes a malformed link can cause an ActivityNotFound exception when a link is + // clicked. In this case, it is better to fallback to using the onClick listener. The + // onClick listener may result in nothing happening, but this is preferable to crashing + // and turning off the service. + urlSpan().onClick(null); + } + } + } +} diff --git a/utils/src/main/java/com/google/android/libraries/accessibility/utils/url/UrlDialogAdapter.java b/utils/src/main/java/com/google/android/libraries/accessibility/utils/url/UrlDialogAdapter.java new file mode 100644 index 0000000..07ea14e --- /dev/null +++ b/utils/src/main/java/com/google/android/libraries/accessibility/utils/url/UrlDialogAdapter.java @@ -0,0 +1,55 @@ +package com.google.android.libraries.accessibility.utils.url; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; +import com.google.android.accessibility.utils.R; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Custom {@link ArrayAdapter} that allows {@link android.app.AlertDialog}s to display a URL path + * and its associated text via a {@link SpannableUrl} in a single row. + */ +public final class UrlDialogAdapter extends ArrayAdapter { + + private int urlMinHeightPx = 0; + + /** + * @param context The context associated with this adapter. + * @param spannableUrls A list of {@link SpannableUrl} to be displayed in a single row via the + * adapter. + */ + public UrlDialogAdapter(Context context, List spannableUrls) { + super(context, R.layout.url_dialog_row, R.id.dialog_url_view, spannableUrls); + } + + @Override + public View getView(int position, @Nullable View currentView, ViewGroup parent) { + View view = super.getView(position, currentView, parent); + SpannableUrl spannableUrl = getItem(position); + if (spannableUrl == null) { + return view; + } + view.setPadding(0, 0, 0, 0); + view.setMinimumHeight(urlMinHeightPx); + TextView urlTextView = view.findViewById(R.id.dialog_url_view); + if (spannableUrl.isTextAndPathEquivalent()) { + urlTextView.setText(spannableUrl.path()); + } else { + String urlPathAndTextString = + getContext() + .getString(R.string.url_dialog_table, spannableUrl.text(), spannableUrl.path()); + urlTextView.setText(urlPathAndTextString); + } + + return view; + } + + /** Sets the minimum height for each URL item in the dialog list. */ + public void setUrlMinHeightPx(int minHeightPx) { + urlMinHeightPx = minHeightPx; + } +} diff --git a/utils/src/main/java/com/google/android/libraries/accessibility/utils/url/UrlUtils.java b/utils/src/main/java/com/google/android/libraries/accessibility/utils/url/UrlUtils.java new file mode 100644 index 0000000..5be4b3f --- /dev/null +++ b/utils/src/main/java/com/google/android/libraries/accessibility/utils/url/UrlUtils.java @@ -0,0 +1,25 @@ +package com.google.android.libraries.accessibility.utils.url; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +/** Utilities to help with URLs */ +public final class UrlUtils { + + private UrlUtils() {} + + /** + * Opens the provided URL in a new task. + * + * @param context The context from which to start the activity + * @param url The URL to open + */ + public static void openUrlWithIntent(Context context, CharSequence url) { + Intent intent = new Intent(Intent.ACTION_VIEW); + Uri destination = Uri.parse(url.toString()); + intent.setData(destination); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } +} diff --git a/utils/src/main/java/com/google/android/libraries/accessibility/widgets/simple/SimpleOverlay.java b/utils/src/main/java/com/google/android/libraries/accessibility/widgets/simple/SimpleOverlay.java new file mode 100644 index 0000000..6dcb03f --- /dev/null +++ b/utils/src/main/java/com/google/android/libraries/accessibility/widgets/simple/SimpleOverlay.java @@ -0,0 +1,305 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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.google.android.libraries.accessibility.widgets.simple; + +import android.content.Context; +import android.graphics.PixelFormat; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnKeyListener; +import android.view.View.OnTouchListener; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.WindowManager.LayoutParams; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.FrameLayout; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Provides a simple full-screen overlay. Behaves like a {@link android.app.Dialog} but simpler. */ +public class SimpleOverlay { + private final Context context; + private final WindowManager windowManager; + private final ViewGroup contentView; + private final LayoutParams params; + private final int id; + + private SimpleOverlayListener listener; + private @Nullable OnTouchListener touchListener; + private OnKeyListener keyListener; + private boolean isVisible; + private @Nullable CharSequence rootViewClassName = null; + + /** + * Creates a new simple overlay that does not send {@link AccessibilityEvent}s. + * + * @param context The parent context. + */ + public SimpleOverlay(Context context) { + this(context, 0); + } + + /** + * Creates a new simple overlay that does not send {@link AccessibilityEvent}s. + * + * @param context The parent context. + * @param id An optional identifier for the overlay. + */ + public SimpleOverlay(Context context, int id) { + this(context, id, false); + } + + /** + * Creates a new simple overlay. + * + * @param context The parent context. + * @param id An optional identifier for the overlay. + * @param sendsAccessibilityEvents Whether this window should dispatch {@link + * AccessibilityEvent}s. + */ + // WindowManager is guaranteed to be not null but getSystemService() can return null in general. + @SuppressWarnings("nullness:assignment") + public SimpleOverlay(Context context, int id, final boolean sendsAccessibilityEvents) { + this.context = context; + windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + contentView = + new FrameLayout(context) { + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if ((keyListener != null) && keyListener.onKey(this, event.getKeyCode(), event)) { + return true; + } + + return super.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + // TODO: Check if we should adjust position after notifying touch listener. + event.offsetLocation(-getTranslationX(), -getTranslationY()); + if ((touchListener != null) && touchListener.onTouch(this, event)) { + return true; + } + + return super.dispatchTouchEvent(event); + } + + @Override + public boolean requestSendAccessibilityEvent(View view, AccessibilityEvent event) { + if (sendsAccessibilityEvents) { + return super.requestSendAccessibilityEvent(view, event); + } else { + // Never send accessibility events if sendAccessibilityEvents == false. + return false; + } + } + + @Override + public void sendAccessibilityEventUnchecked(AccessibilityEvent event) { + // Never send accessibility events if sendAccessibilityEvents == false. + if (sendsAccessibilityEvents) { + super.sendAccessibilityEventUnchecked(event); + } + } + + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + if (rootViewClassName != null) { + info.setClassName(rootViewClassName); + } + } + }; + + params = new LayoutParams(); + params.type = LayoutParams.TYPE_SYSTEM_ALERT; + params.format = PixelFormat.TRANSLUCENT; + params.flags |= LayoutParams.FLAG_NOT_FOCUSABLE; + + this.id = id; + + isVisible = false; + } + + /** Returns the overlay context. */ + public Context getContext() { + return context; + } + + /** Returns the overlay identifier, or {@code 0} if no identifier was provided at construction. */ + public int getId() { + return id; + } + + /** + * Sets class name in {@link AccessibilityNodeInfo} of the root view. This is a work around for + * Android L where we cannot override the class name of FrameLayout by setting + * AccessibilityDelegate. + */ + public void setRootViewClassName(CharSequence className) { + rootViewClassName = className; + } + + /** Sets the key listener. */ + public void setOnKeyListener(OnKeyListener keyListener) { + this.keyListener = keyListener; + } + + /** Sets the touch listener. */ + public void setOnTouchListener(@Nullable OnTouchListener touchListener) { + this.touchListener = touchListener; + } + + /** Sets the listener for overlay visibility callbacks. */ + public void setListener(SimpleOverlayListener listener) { + this.listener = listener; + } + + /** + * Shows the overlay. Calls the listener's {@link SimpleOverlayListener#onShow(SimpleOverlay)} if + * available. + */ + public void show() { + if (isVisible) { + return; + } + + windowManager.addView(contentView, params); + isVisible = true; + + if (listener != null) { + listener.onShow(this); + } + + onShow(); + } + + /** + * Hides the overlay. Calls the listener's {@link SimpleOverlayListener#onHide(SimpleOverlay)} if + * available. + */ + public void hide() { + if (!isVisible) { + return; + } + + windowManager.removeViewImmediate(contentView); + isVisible = false; + + if (listener != null) { + listener.onHide(this); + } + + onHide(); + } + + /** Called after {@link #show()}. */ + protected void onShow() { + // Do nothing. + } + + /** Called after {@link #hide()}. */ + protected void onHide() { + // Do nothing. + } + + /** Returns a copy of the current layout parameters. */ + public LayoutParams getParams() { + LayoutParams copy = new LayoutParams(); + copy.copyFrom(params); + return copy; + } + + /** + * Sets the current layout parameters and applies them immediately. + * + * @param params The layout parameters to use. + */ + public void setParams(LayoutParams params) { + this.params.copyFrom(params); + updateViewLayout(); + } + + /** Updates the current layout if this overlay is visible. */ + public void updateViewLayout() { + if (isVisible) { + windowManager.updateViewLayout(contentView, this.params); + } + } + + /** Returns {@code true} if this overlay is visible. */ + public boolean isVisible() { + return isVisible; + } + + /** + * Inflates the specified resource ID and sets it as the content view. + * + * @param layoutResId The layout ID of the view to set as the content view. + */ + public void setContentView(int layoutResId) { + contentView.removeAllViews(); + final LayoutInflater inflater = LayoutInflater.from(context); + inflater.inflate(layoutResId, contentView); + } + + /** + * Sets the specified view as the content view. + * + * @param content The view to set as the content view. + */ + public void setContentView(View content) { + contentView.removeAllViews(); + contentView.addView(content); + } + + /** + * Returns the root {@link View} for this overlay. This is not the content view. + */ + public View getRootView() { + return contentView; + } + + /** + * Finds and returns the view within the overlay content. + * + * @param id The ID of the view to return. + * @return The view with the specified ID, or {@code null} if not found. + */ + public View findViewById(int id) { + return contentView.findViewById(id); + } + + /** Handles overlay visibility change callbacks. */ + public interface SimpleOverlayListener { + /** + * Called after the overlay is displayed. + * + * @param overlay The overlay that was displayed. + */ + public void onShow(SimpleOverlay overlay); + + /** + * Called after the overlay is hidden. + * + * @param overlay The overlay that was hidden. + */ + public void onHide(SimpleOverlay overlay); + } +} diff --git a/utils/src/main/res/color-v31/a11y_outlined_button_stroke_color.xml b/utils/src/main/res/color-v31/a11y_outlined_button_stroke_color.xml new file mode 100644 index 0000000..3b9d93e --- /dev/null +++ b/utils/src/main/res/color-v31/a11y_outlined_button_stroke_color.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/utils/src/main/res/color/a11y_filled_button_background_color.xml b/utils/src/main/res/color/a11y_filled_button_background_color.xml new file mode 100644 index 0000000..32d5f15 --- /dev/null +++ b/utils/src/main/res/color/a11y_filled_button_background_color.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/utils/src/main/res/color/a11y_filled_button_text_color.xml b/utils/src/main/res/color/a11y_filled_button_text_color.xml new file mode 100644 index 0000000..6f385cc --- /dev/null +++ b/utils/src/main/res/color/a11y_filled_button_text_color.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/utils/src/main/res/layout/preference_with_survey.xml b/utils/src/main/res/layout/preference_with_survey.xml new file mode 100644 index 0000000..fecdf02 --- /dev/null +++ b/utils/src/main/res/layout/preference_with_survey.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/utils/src/main/res/layout/url_dialog_row.xml b/utils/src/main/res/layout/url_dialog_row.xml new file mode 100644 index 0000000..5fc5db5 --- /dev/null +++ b/utils/src/main/res/layout/url_dialog_row.xml @@ -0,0 +1,17 @@ + + + + diff --git a/utils/src/main/res/layout/web_activity.xml b/utils/src/main/res/layout/web_activity.xml new file mode 100644 index 0000000..799e7a8 --- /dev/null +++ b/utils/src/main/res/layout/web_activity.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/utils/src/main/res/raw/hyperlink.ogg b/utils/src/main/res/raw/hyperlink.ogg new file mode 100644 index 0000000..41b93ae Binary files /dev/null and b/utils/src/main/res/raw/hyperlink.ogg differ diff --git a/utils/src/main/res/raw/window_state.ogg b/utils/src/main/res/raw/window_state.ogg new file mode 100644 index 0000000..0be777d Binary files /dev/null and b/utils/src/main/res/raw/window_state.ogg differ diff --git a/utils/src/main/res/values-af/strings.xml b/utils/src/main/res/values-af/strings.xml new file mode 100644 index 0000000..dcfc4b1 --- /dev/null +++ b/utils/src/main/res/values-af/strings.xml @@ -0,0 +1,50 @@ + + + Karakters %1$d tot %2$d + Karakter %1$d + titelloos + het gekopieer, %1$s + hoofletter %1$s + %1$d %2$s + Deur gebruik van %1$s + Druk sleutelkombinasie om \'n nuwe kortpad te stel. Dit moet minstens ALT- of Control-sleutel bevat. + Druk sleutelkombinasie saam met die %1$s-wysigersleutel om \'n nuwe kortpad te stel. + Nie toegewys nie + Shift + Alt + Ctrl + Soek + Pyltjie regs + Pyltjie links + Pyltjie op + Pyltjie af + Verstek + Karakters + Woorde + Reëls + Paragrawe + Vensters + Landmerke + Opskrifte + Lyste + Skakels + Kontroles + Spesiale inhoud + Opskrifte + Kontroles + Skakels + %1$s-beeld-in-beeld + %1$s aan bokant, %2$s aan onderkant + %1$s aan linkerkant, %2$s aan regterkant + %1$s aan regterkant, %2$s aan linkerkant + Wys tans items %1$d tot %2$d van %3$d. + Wys tans item %1$d van %2$d. + Bladsy %1$d van %2$d + %1$d van %2$d + %1$s (%2$s) + Verlaat + Wys tans %1$s + sleutelbord is versteek + Gesproke terugvoer is aan + Gesproke terugvoer is af + diff --git a/utils/src/main/res/values-af/strings_symbols.xml b/utils/src/main/res/values-af/strings_symbols.xml new file mode 100644 index 0000000..29e907f --- /dev/null +++ b/utils/src/main/res/values-af/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Afkappingsteken + Ampersand + Kleiner-as-teken + Groter-as-teken + Asterisk + By + Agtertoe-skuinsstreep + Kolpunt + Kappie + Sentteken + Dubbelpunt + Komma + Kopiereg + Begin-krulhakie + Eind-krulhakie + Grade-teken + Delingsteken + Dollar-teken + Ellips + Em-strepie + En-strepie + Euro + Uitroepteken + Gravis + Strepie + Onderste dubbel-aanhalingsteken + Vermenigvuldigingsteken + Nuwe reël + Paragraafteken + Beginhakie + Eindhakie + Persent + Punt + Pi + Pond + Pond-geldeenheidteken + Vraagteken + Aanhalingsteken + Geregistreerde handelsmerk + Kommapunt + Vorentoe-skuinsstreep + Spasie + Begin-blokhakie + Eind-blokhakie + Vierkantswortel + Handelsmerk + Onderstreep + Vertikale streep + Jen + Nie-teken + Gebroke balk + Mikroteken + Amper gelyk aan + Nie gelyk aan + Teken van geldeenheid + Seksie-teken + Opwaartse pyltjie + Pyltjie na links + Roepee + Swart hart + Tilde + Gelyk-aan-teken + Won-geldeenheidteken + Verwysingmerk + Wit ster + Swart ster + Wit hart + Wit kring + Swart kring + Sonsimbool + Kol + Wit klawer + Wit skoppens + Wit wysvinger wat links wys + Wit wysvinger wat regs wys + Kring met linkerhelfte swart + Kring met regterhelfte swart + Wit blokkie + Swart blokkie + Wit driehoek wat boontoe wys + Wit driehoek wat ondertoe wys + Wit driehoek wat links wys + Wit driehoek wat regs wys + Wit diamant + Kwartnoot + Agstenoot + Verbinde sestiende note + Vroulike simbool + Manlike simbool + Swart linkerlenshakie + Swart regterlenshakie + Linkerhoekhakie + Regterhoekhakie + Pyl na regs + Afwaartse pyl + Plus-minus-teken + Liter + Celsiusgraad + Fahrenheitgraad + Naastenby gelyk aan + Integraal + Wiskundige linkerhoekhakie + Wiskundige regterhoekhakie + Posmerk + Swart driehoek wat op wys + Swart driehoek wat af wys + Swart diamant + Katakana-halfbreedtemiddelkol + Klein swart vierkant + Dubbele beginhoekhakie + Dubbele eindhoekhakie + Onderstebo uitroepteken + Onderstebo vraagteken + Won-geldeenheidteken + Volbreedtekomma + Volbreedte-uitroepteken + Ideografiese punt + Volbreedtevraagteken + Middelkol + Dubbele eindaanhalingsteken + Ideografiese komma + Volbreedtedubbelpunt + Volbreedtekommapunt + Volbreedteampersand + Volbreedtekappie + Volbreedtetilde + Dubbele beginaanhalingsteken + Volbreedtebeginhakie + Volbreedte-eindhakie + Volbreedteasterisk + Volbreedteonderstreep + Eindenkelaanhalingsteken + Volbreedtebeginkrulhakie + Volbreedte-eindkrulhakie + Volbreedte-kleiner-as-teken + Volbreedte-groter-as-teken + Beginenkelaanhalingsteken + diff --git a/utils/src/main/res/values-am/strings.xml b/utils/src/main/res/values-am/strings.xml new file mode 100644 index 0000000..a761ff4 --- /dev/null +++ b/utils/src/main/res/values-am/strings.xml @@ -0,0 +1,50 @@ + + + ከ%1$d እስከ %2$d ያሉ ቁምፊዎች + ቁምፊ %1$d + ርዕስ አልባ + ተቀድቷል፣ %1$s + አቢይ ሆሄ %1$s + %1$d%2$s + %1$sን መጠቀም + አዲስ አቋራጭ ለማዘጋጀት የቁልፍ ጥምረትን ይጫኑ። ቢያንስ ALT ወይም Control keyን የያዘ መሆን ይኖርበታል። + አዲስ አቋራጭ ለማዘጋጀት የቁልፍ ጥምር ከ%1$s ቀያሪ ቁልፍ ጋር ይጫኑ። + አልተመደበም + Shift + Alt + Ctrl + ፈልግ + ቀኝ ቀስት + ግራ ቀስት + ላይ ቀስት + ታች ቀስት + ነባሪ + ገጸባህሪያት + ቃላት + መስመሮች + አንቀጾች + መስኮቶች + ታሪካዊ ምልክቶች + አርዕስቶች + ዝርዝሮች + አገናኞች + መቆጣጠሪያዎች + ልዩ ይዘት + አርዕስቶች + መቆጣጠሪያዎች + አገናኞች + %1$s ሥዕል በሥዕል ውስጥ + %1$s ከላይ፣ %2$s ከታች + %1$s በስተግራ ላይ፣ %2$s በስተቀኝ ላይ፣ + %1$s በስተቀኝ ላይ፣ %2$s በስተግራ ላይ፣ + ከ%3$d ንጥሎች ውስጥ ከ%1$d እስከ %2$d ያሉትን በማሳየት ላይ። + %1$d ከ%2$d ንጥል በማሳየት ላይ። + ገጽ %1$d ከ%2$d + %1$d ከ%2$d + %1$s (%2$s) + ውጣ + %1$sን በማሳየት ላይ + የቁልፍ ሰሌዳ ተደብቋል + የቃል ግብረመልስ በርቷል + የቃል ግብረመልስ ጠፍቷል + diff --git a/utils/src/main/res/values-am/strings_symbols.xml b/utils/src/main/res/values-am/strings_symbols.xml new file mode 100644 index 0000000..489562f --- /dev/null +++ b/utils/src/main/res/values-am/strings_symbols.xml @@ -0,0 +1,140 @@ + + + ትእምርተ ጭረት + እና + የያንሳል ምልክት + የይበልጣል ምልክት + ኮከቢት + + ሕዝባር + ነጥበ ምልክት + ድፋት + የሳንቲም ምልክት + ሁለት ነጥብ + ኮማ + የቅጂ መብት + የግራ ጥምዝ ቅንፍ + የቀኝ ጥምዝ ቅንፍ + የዲግሪ ምልክት + የማካፈል ምልክት + የዶላር ምልክት + አስጨምሬ + አብይ ሰረዝ + ንዑስ ሰረዝ + ዩሮ + ቃለ አጋኖ + የጭረት ምልክት + ሰረዝ + ዝቅ ያለ ድርብ ትምህርተ ጥቅስ + የማባዛት ምልክት + አዲስ መስመር + የአንቀጽ ምልክት + ግራ ቅንፍ + የቀኝ ቅንፍ + በመቶ + ክፍለ ጊዜ + ፓይ + ፓውንድ + የፓውንድ ምንዛሬ ምልክት + የጥያቄ ምልክት + ጥቅስ + የተመዘገበ የንግድ ምልክት + ሴሚ ኮሎን + ህዝባር + ክፍተት + የግራ መረባ ቅንፍ + የቀኝ መረባ ቅንፍ + ዳግም ዘር + የንግድ ምልክት + ሰረዘዘብጥ + አቀባዊ መስመር + የን + የአይደለም ምልክት + የተሰበረ አሞሌ + የማይክሮ ምልክት + እኩል ለመሆን ይጠጋል ከ + እኩል አይደለም ከ + የምንዛሬ ምልክት + የክፍል ምልክት + የላይ ቀስት + የግራ ቀስት + ሩፒ + ጥቁር ልብ + ድፋትቅናት + የእኩል ነው ምልክት + የዎን የምንዛሬ ምልክት + የዋቢ ምልክት + ነጭ ኮከብ + ጥቁር ኮከብ + ነጭ ልብ + ነጭ ክብ + ጥቁር ክብ + የጸሐይ ምልክት + ዒላማ + የነጭ አበባ ስብስብ + ነጭ የጦር ስብስብ + ወደ ግራ የሚያመለክት ነጭ አመልካች ጣት + ወደ ቀኝ የሚያመለክት ነጭ አመልካች ጣት + የግራ ግማሹ ጥቁር የሆነ ክብ + የቀኝ ግማሹ ጥቁር የሆነ ክብ + ነጭ ካሬ + ጥቁር ካሬ + ወደ ላይ የሚያመለክት ሶስት ማዕዘን + ወደ ታች የሚያመለክት ነጭ ሶስት ማዕዘን + ወደ ግራ የሚያመለክት ነጭ ሶስት ማዕዘን + ወደ ቀኝ የሚያመለክት ነጭ ሶስት ማዕዘን + ነጭ አልማዝ + ሩብ ኖታ + የስምንተኛ ኖታ + የተያያዙ አስራስድስተኛ ኖታዎች + የእንስት ምልክት + የተባዕት ምልክት + የግራ ጥቁር ምስሬ ቅንፍ + የቀኝ ጥቁር ምስሬ ቅንፍ + የግራ ማዕዘን ቅንፍ + የቀኝ ማዕዘን ቅንፍ + የቀኝ ቀስት + የታች ቀስት + የመደመር መቀነስ ምልክት + ላይተር + ሴልሺየስ ዲግሪ + ፋራንሃይት ዲግሪ + ይጠጋል ለ + እጉር + የሒሳብ የግራ አንግል ቅንፍ + የሒሳብ የቀኝ አንግል ቅንፍ + የፖስታ ምልክት + ጥቁር ሶስት ማዕዘን ወደላይ ሲያመለክት + ጥቁር ሶስት ማዕዘን ወደላይ ሲያመለክት + የአልማዝ ጥቁር ሱፍ + ግማሽ ወርድ ካታካና መካከለኛ ነጥብ + ትንሽ ጥቁር ካሬ + የግራ ድርብ አንግል ቅንፍ + የቀኝ ድርብ አንግል ቅንፍ + የተገለበጠ ቃለ አጋኖ + የተገለበጠ የጥያቄ ምልክት + የዎን የምንዛሬ ምልክት + ባለሙሉ ወርድ ኮማ + ባለሙሉ ወርድ ቃለ አጋኖ + ፅንሰ-ሐሳባ አራት ነጥብ + ባለሙሉ ወርድ የጥያቄ ምልክት + የመሃል ነጥብ + የቀኝ ድርብ ትዕምርተ ጥቅስ + ፅንሰ-ሐሳባ ኮማ + ባለሙሉ ወርድ ኮሎን + ባለሙሉ ወርድ ሰሚኮሎን + ባለሙሉ ወርድ አምፕርሳንድ + ባለሙሉ ወርድ ስርኬምፍሌክስ + ባለሙሉ ወርድ ድፋትቅናት + የግራ ድርብ ትዕምርተ ጥቅስ + ባለሙሉ ወርድ የግራ ቅንፍ + ባለሙሉ ወርድ የቀኝ ቅንፍ + ባለሙሉ ወርድ ኮከብ ምልክት + ባለሙሉ ወርድ የሥር መስመር + የቀኝ ነጠላ ትዕምርተ ጥቅስ + ባለሙሉ ወርድ ጥምዝ ቅንፍ + ባለሙሉ ወርድ የቀኝ ጥምዝ ቅንፍ + ከምልክት ያነሰ ባለሙሉ ወርድ + ከምልክት የበለጠ ባለሙሉ ወርድ + የግራ ነጠላ ትዕምርተ ጥቅስ + diff --git a/utils/src/main/res/values-ar/strings.xml b/utils/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..c0cbadd --- /dev/null +++ b/utils/src/main/res/values-ar/strings.xml @@ -0,0 +1,50 @@ + + + الأحرف %1$d إلى %2$d + الرمز %1$d + بلا عنوان + تم نسخ %1$s + حرف %1$s كبير + %1$d %2$s + استخدام %1$s + ‏اضغط على مجموعة المفاتيح لضبط اختصار جديد والتي يجب أن تحتوي على مفتاح ALT أو Control على الأقل. + اضغط على مجموعة مفاتيح مع مفتاح تعديل %1$s لضبط اختصار جديد. + غير محدد + Shift + Alt + Ctrl + بحث + سهم لليمين + سهم لليسار + سهم متّجه للأعلى + سهم متّجه للأسفل + تلقائي + الأحرف + الكلمات + الأسطر + الفقرات + النوافذ + معالم + العناوين + القوائم + الروابط + عناصر التحكم + محتوى خاص + العناوين + عناصر التحكم + الروابط + صورة %1$s داخل صورة + %1$s في الجزء العلوي و%2$s في الجزء السفلي + %1$s على اليسار و%2$s على اليمين + %1$s على اليمين، و%2$s على اليسار + عرض العناصر من %1$d إلى %2$d من إجمالي %3$d. + عرض العنصر %1$d من إجمالي %2$d. + صفحة %1$d من %2$d + %1$d من %2$d + %1$s (%2$s) + خروج + يتم الآن عرض %1$s. + لوحة المفاتيح مخفية + ميزة التعليقات والملاحظات المنطوقة مفعَّلة. + ميزة التعليقات والملاحظات المنطوقة غير مفعَّلة. + diff --git a/utils/src/main/res/values-ar/strings_symbols.xml b/utils/src/main/res/values-ar/strings_symbols.xml new file mode 100644 index 0000000..20f4608 --- /dev/null +++ b/utils/src/main/res/values-ar/strings_symbols.xml @@ -0,0 +1,140 @@ + + + الفاصلة العليا + علامة العطف + علامة أقل من + علامة أكبر من + العلامة النجمية + ‏علامة At + شرطة مائلة للخلف + تعداد نقطي + علامة الإقحام + علامة السنت + نقطتان + فاصلة + حقوق الطبع والنشر + قوس متعرج أيسر + قوس متعرج أيمن + علامة الدرجة + علامة القسمة + علامة الدولار + علامة حذف + شرطة طويلة + شرطة قصيرة + يورو + علامة التعجب + العلامة النطقية + شرطة + علامة الاقتباس المزدوجة السفلية + علامة الضرب + خط جديد + علامة الفقرة + قوس أيسر + قوس أيمن + نسبة مئوية + نقطة + باي + علامة المربّع + علامة عملة الجنيه + علامة استفهام + علامة اقتباس + علامة تجارية مسجلة + فاصلة منقوطة + شرطة مائلة + مسافة + قوس مربع أيسر + قوس مربع أيمن + جذر تربيعي + علامة تجارية + شَرْطَة سُفْلِيَّة + خط عمودي + الين + علامة النفي + شريط عمودي مقطوع + علامة ميكرو + يساوي تقريبًا + غير مساوٍ لـ + علامة العملة + علامة المادة + سهم متّجه للأعلى + سهم لليسار + روبية + قلب أسود + علامة المد (تلدة) + علامة يساوي + علامة عملة الوون + علامة مرجعية + نجمة بيضاء + نجمة سوداء + قلب أبيض + دائرة بيضاء + دائرة سوداء + رمز شمسي + مركز الهدف + رمز على شكل نبات نفل أبيض + بستوني أبيض + رمز أبيض للتوجيه إلى اليسار + رمز أبيض للتوجيه إلى اليمين + دائرة نصفها الأيسر بلون أسود + دائرة نصفها الأيمن بلون أسود + مربع أبيض + مربع أسود + مثلث أبيض رأسه متجهة لأعلى + مثلث أبيض رأسه متجهة لأسفل + مثلث أبيض رأسه متجهة لليسار + مثلث أبيض رأسه متجهة لليمين + معين أبيض + علامة موسيقية وزنها جزء من أربعة + علامة موسيقية وزنها جزء من ثمانية + علامات موسيقية وزنها جزء من ستة عشرة ومرتبطة برابط + رمز أنثى + رمز ذكر + قوس عدسي أيسر أسود + قوس عدسي أيمن أسود + قوس أيسر بزاوية + قوس أيمن بزاوية + سهم لليمين + سهم متّجه للأسفل + علامة زائد ناقص + لتر + درجة مئوية + درجة فهرنهايت + يساوي تقريبًا + تكامل + ذراع الزاوية الرياضية اليسرى + ذراع الزاوية الرياضية اليمنى + علامة بريدية + مثلث أسود يشير للأعلى + مثلث أسود يشير للأسفل + رموز من المعينات السوداء + نقطة متوسطة كاتاكانا بنصف عرضها + مربع أسود صغير + قوس زاوية مزدوجة يسرى + قوس زاوية مزدوجة يمنى + علامة تعجب مقلوبة + علامة استفهام مقلوبة + علامة عملة الوون + فاصلة بالعرض الكامل + علامة تعجب بالعرض الكامل + نقطة في الكتابة المصوّرة + علامة استفهام بالعرض الكامل + نقطة متوسطة + علامة اقتباس مزدوجة يمنى + فاصلة في الكتابة المصوّرة + علامة نقطتين بالعرض الكامل + فاصلة منقوطة بالعرض الكامل + علامة العطف بالعرض الكامل + مدة معقوفة بالعرض الكامل + علامة المد بالعرض الكامل + علامة اقتباس مزدوجة يسرى + قوس أيسر بالعرض الكامل + قوس أيمن بالعرض الكامل + علامة نجمية بالعرض الكامل + شرطة سفلية بالعرض الكامل + علامة اقتباس مفردة يمنى + قوس مزهّر أيسر بالعرض الكامل + قوس مزهّر أيمن بالعرض الكامل + علامة أقل من بالعرض الكامل + علامة أكبر من بالعرض الكامل + علامة اقتباس مفردة يسرى + diff --git a/utils/src/main/res/values-as/strings.xml b/utils/src/main/res/values-as/strings.xml new file mode 100644 index 0000000..81389e7 --- /dev/null +++ b/utils/src/main/res/values-as/strings.xml @@ -0,0 +1,50 @@ + + + %1$d ৰ পৰা %2$dলৈ বৰ্ণসমূহ + বৰ্ণ %1$d + শিৰোনামবিহীন + প্ৰতিলিপি কৰা হ’ল, %1$s + বৰফলা %1$s + %1$d %2$s + %1$s ব্যৱহাৰ কৰি থকা হৈছে + নতুন শ্বর্টকার্ট ছেট কৰিবলৈ কী যুটিত হেঁচক। ইয়াত ALT বা Control কী থাকিবই লাগিব। + নতুন শ্বৰ্টকাট ছেট কৰিবলৈ কীৰ যুটি %1$s সংশোধক কীৰ সৈতে টিপক। + আৱণ্টন কৰা হোৱা নাই + শ্বিফ্ট + Alt + Ctrl + সন্ধান কৰক + সোঁফালে নিৰ্দেশ কৰা কাঁড় চিহ্ন + বাওঁফালৰ নিৰ্দেশ কৰা কাঁড় চিহ্ন + ওপৰলৈ নিৰ্দেশ কৰা কাঁড় চিহ্ন + তললৈ নিৰ্দেশ কৰা কাঁড় চিহ্ন + ডিফ\'ল্ট + বর্ণসমূহ + শব্দসমূহ + শাৰীসমূহ + পেৰাগ্ৰাফসমূহ + ৱিণ্ড\' + লেণ্ডমার্ক + শিৰোনাম + সূচীসমূহ + লিংকসমূহ + নিয়ন্ত্ৰণসমূহ + বিশেষ সমল + শিৰোনামবোৰ + নিয়ন্ত্ৰণবোৰ + লিংকসমূহ + %1$s চিত্ৰৰ মাজত থকা চিত্ৰ + %1$s ওপৰত, %2$s তলত + %1$s বাওঁফালে, %2$s সোঁফালে + %1$sসোঁফালে, %2$s বাওঁফালে + %3$dটা বস্তুৰ %1$dৰপৰা %2$dলৈ দেখাই থকা হৈছে। + %2$dটা বস্তুৰ %1$d পৰা দেখাই থকা হৈছে। + %2$dৰ %1$d পৃষ্ঠা + %2$dৰ %1$d + %1$s (%2$s) + বাহিৰ হওক + %1$s দেখুৱাই থকা হৈছে + কীব’ৰ্ড লুকুওৱা হৈছে + কথিত ফীডবেক অন আছে + কথিত ফীডবেক অফ আছে + diff --git a/utils/src/main/res/values-as/strings_symbols.xml b/utils/src/main/res/values-as/strings_symbols.xml new file mode 100644 index 0000000..0c0b894 --- /dev/null +++ b/utils/src/main/res/values-as/strings_symbols.xml @@ -0,0 +1,140 @@ + + + উর্ধকমা + এম্পাৰচেণ্ড + ইয়াতকৈ কম সূচোৱা চিহ্ন + ইয়াতকৈ ডাঙৰ সূচোৱা চিহ্ন + তৰাচিহ্ন + এট + বেকশ্লেশ্ব + বুলেট + কাৰেট + চেণ্টৰ চিহ্ন + ক\'লন + ক\'মা + স্ৱত্ৱাধিকাৰ + বাওঁ কুটিল বন্ধনী + সোঁ কুটিল বন্ধনী + ডিগ্ৰীৰ চিহ্ন + হৰণ চিন + ডলাৰ চিন + এলিপ্সিছ + এম ডেশ্ব + এন ডেশ্ব + ইউৰো + ভাৱবোধক চিহ্ন + গ্ৰেভ একচেণ্ট + ডেশ্ব + নিম্ন দ্বৈত উদ্ধৃতি + পুৰণ চিন + নতুন ৰেখা + পেৰাগ্ৰাফ চিহ্ন + বাওঁফালৰ চন্দ্ৰ বন্ধনী + সোঁফালৰ চন্দ্ৰ বন্ধনী + শতাংশ + দাৰি চিহ্ন + পাই + পাউণ্ড + পাউণ্ড মুদ্ৰাৰ চিহ্ন + প্ৰশ্নবোধক চিহ্ন + উদ্ধৃতি + পঞ্জীকৃত ট্ৰেডমার্ক + ছেমিকল\'ন + শ্লেশ্ব + স্পেচ + বাওঁ বৰবন্ধনী + সোঁ বৰবন্ধনী + বর্গমূল + ট্ৰেডমার্ক + আণ্ডাৰস্ক\'ৰ + উলম্ব ৰেখা + য়েন চিহ্ন + নহয় বুজোৱা চিহ্ন + ভগ্ন দণ্ড + ক্ষুদ্ৰ চিহ্ন + ইয়াৰ প্ৰায় সমান + ইয়াৰ সমান নহয় + মুদ্ৰাৰ চিন + শাখা চিহ্ন + ওপৰমুৱা কাঁড়ৰ চিন + বাওঁফালৰ কাঁড়ৰ চিন + টকাৰ চিহ্ন + কালা পান + প্ৰায় সমান চিহ্ন + সমান চিন + ৱন মুদ্ৰাৰ চিহ্ন + সন্দর্ভ চিহ্ন + বগা তৰাৰ চিহ্ন + ক\'লা তৰাৰ চিহ্ন + বগা পান চিহ্ন + বগা বৃত্তৰ চিহ্ন + ক\'লা বৃত্তৰ চিহ্ন + সৌৰ চিহ্ন + নিখুঁত লক্ষ্যস্থানৰ চিহ্ন + তাছৰ বগা চিড়িৰ চিহ্ন + তাছৰ বগা পানৰ চিহ্ন + বাওঁফাল নির্দেশ কৰা চিহ্ন + সোঁফাল নির্দেশ কৰা চিহ্ন + বাওঁফালৰ আধা অংশ ক\'লা ৰঙযুক্ত বৃত্ত + সোঁফালৰ আধা অংশ ক\'লা ৰঙযুক্ত বৃত্ত + বগা বর্গ + ক\'লা বৃত্ত + উর্ধমুখী বগা ত্ৰিভূজ + নিম্নমুখী বগা ত্ৰিভূজ + বাওঁফালে নির্দেশ কৰা বগা ত্ৰিভূজ + সোঁফালে নির্দেশ কৰা বগা ত্ৰিভূজ + বগা হীৰা চিহ্ন + গীতৰ স্বৰৰ চাৰিভাগৰ এভাগ বুজোৱা চিহ্ন + অষ্টম স্বৰৰ চিহ্ন + বীম কৰা ষষ্টাদশ স্বৰসমূহ + মহিলা বুজোৱা চিহ্ন + পুৰুষ বুজোৱা চিহ্ন + বাওঁফালৰ লেণ্টিকুলাৰ ক\'লা বন্ধনী + সোঁফালৰ লেণ্টিকুলাৰ ক\'লা বন্ধনী + বাওঁফালৰ চুকৰ বন্ধনী + সোঁফালৰ চুকৰ বন্ধনী + সোঁফালে নির্দেশ কৰা কাঁড়ৰ চিহ্ন + তলৰফালে নির্দেশ কৰা কাঁড়ৰ চিহ্ন + যোগ বিয়োগৰ চিহ্ন + লিটাৰ চিহ্ন + চেলছিয়াছ ডিগ্ৰী চিহ্ন + ফাইৰেনহাইট ডিগ্ৰী চিহ্ন + অনুমানিকভাৱে সমান বুজোৱা চিহ্ন + অখণ্ড চিহ্ন + গাণিতিক বাওঁকোণীয়া বন্ধনী + গাণিতিক সোঁকোণীয়া বন্ধনী + প’ষ্টেল চিহ্ন + ওপৰলৈ নিৰ্দেশ কৰি থকা ক’লা ত্ৰিভুজ + তললৈ নিৰ্দেশ কৰি থকা ক’লা ত্ৰিভুজ + হীৰাৰে তৈয়াৰী ক’লা ছুট + অৰ্ধ-প্ৰস্থৰ কাটাকানা মধ্য বিন্দু + সৰু ক’লা বৰ্গ + দুটা কোণ থকা বাওঁফালৰ বন্ধনী + দুটা কোণ থকা সোঁফালৰ বন্ধনী + ওলোটা ভাববোধক চিহ্ন + ওলোটা প্ৰশ্নবোধক চিহ্ন + ৱন মুদ্ৰাৰ চিহ্ন + সম্পূৰ্ণ-প্ৰস্থৰ কমা + সম্পূৰ্ণ-প্ৰস্থৰ ভাববোধক চিহ্ন + ইডিঅ’গ্ৰাফিক পূৰ্ণ বিৰাম + সম্পূৰ্ণ-প্ৰস্থৰ প্ৰশ্নবোধক চিহ্ন + মধ্য বিন্দু + সোঁফালৰ দ্বৈত ঊৰ্ধ্বকমা + ইডিঅ’গ্ৰাফিক কমা + সম্পূৰ্ণ-প্ৰস্থৰ কলন + সম্পূৰ্ণ-প্ৰস্থৰ চেমিকলন + সম্পূৰ্ণ-প্ৰস্থৰ এম্পাৰছেণ্ড + সম্পূৰ্ণ-প্ৰস্থৰ চাৰকামফ্লেক্স + সম্পূৰ্ণ-প্ৰস্থৰ টিল্ডা + বাওঁফালৰ দ্বৈত ঊৰ্ধ্বকমা + সম্পূৰ্ণ-প্ৰস্থৰ বাওঁফালৰ চন্দ্ৰবন্ধনী + সম্পূৰ্ণ-প্ৰস্থৰ সোঁফালৰ চন্দ্ৰবন্ধনী + সম্পূৰ্ণ-প্ৰস্থৰ তৰাচিহ্ন + সম্পূৰ্ণ-প্ৰস্থৰ আণ্ডাৰস্ক’ৰ + সোঁফালৰ একক ঊৰ্ধ্বকমা + সম্পূৰ্ণ-প্ৰস্থৰ বাওঁফালৰ বক্ৰাকাৰ বন্ধনী + সম্পূৰ্ণ-প্ৰস্থৰ সোঁফালৰ বক্ৰাকাৰ বন্ধনী + সম্পূৰ্ণ-প্ৰস্থৰ লেছ দেন চিহ্ন + সম্পূৰ্ণ-প্ৰস্থৰ গ্ৰেটাৰ দেন চিহ্ন + বাওঁফালৰ একক ঊৰ্ধ্বকমা + diff --git a/utils/src/main/res/values-az/strings.xml b/utils/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..3b261b3 --- /dev/null +++ b/utils/src/main/res/values-az/strings.xml @@ -0,0 +1,50 @@ + + + %1$d səhifədən %2$d səhifəyə simvollar + Simvol %1$d + başlıqsız + kopyalandı, %1$s + böyük %1$s + %1$d %2$s + %1$s istifadə edilir + Yeni qısayol yaratmaq üçün klaviş kombinasiyasına basın. Bu ən azı ALT və ya Kontrol düyməsindən ibarət olmalıdır. + Yeni qısayol yerləşdirmək üçün %1$s dəyişdirici açarı ilə birlikdə açar kombinasiyasına basın. + Təyin edilməyib + Shift + Alt + Ctrl + Axtarış + Sağ Ox + Sol Ox + Yuxarı Ox + Aşağı Ox + Defolt + Simvollar + Sözlər + Xətlər + Paraqraflar + Pəncərələr + Görməli yerlər + Başlıqlar + Siyahılar + Linklər + Kontrollar + Xüsusi məzmun + Başlıqlar + Kontrollar + Linklər + %1$s şəkil içində şəkil + %1$s yuxarıda, %2$s aşağıda + %1$s solda, %2$s sağda + %1$s sağda, %2$s solda + %3$d elementdən %1$d ədəddən %2$d ədədə qədər göstərilir. + %2$d elementdən %1$d ədədi göstərilir. + %2$d səhifədən %1$d səhifə + %2$d səhifədən %1$d səhifə + %1$s (%2$s) + Çıxın + %1$s göstərilir + klaviatura gizlədilib + Səsləndirilmiş rəy aktivdir + Səsləndirilmiş rəy deaktivdir + diff --git a/utils/src/main/res/values-az/strings_symbols.xml b/utils/src/main/res/values-az/strings_symbols.xml new file mode 100644 index 0000000..f0c7c20 --- /dev/null +++ b/utils/src/main/res/values-az/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Ampersant + Kiçikdir işarəsi + Böyükdür işarəsi + Ulduz + At + Tərsinə çəp xətt + Marker + Kursor + Sent işarəsi + İki nöqtə + Vergül + Müəllif hüququ + Sol buruq mötərizə + Sağ buruq mötərizə + Dərəcə işarəsi + Bölmə işarəsi + Dollar işarəsi + Ellips + Em tire + En tire + Avro + Nida işarəsi + Ağır vurğu + Tire + Cüt aşağı dırnaq + Vurma işarəsi + Yeni xətt + Abzas işarəsi + Sol mötərizə + Sağ mötərizə + Faiz + Period + Pi + Funt + Funt işarəsi + Sual işarəsi + Sitat + Qeydiyyat edilmiş əmtəə nişanı + Nöqtəli vergül + Çəp xətt + Boşluq + Sol kvadrat mötərizə + Sağ kvadrat mötərizə + Kvadrat kök + Əmtəə nişanı + Vurğulayın + Şaquli xətt + Yen + İmzalamala + Qırıq panel + Mikro işarə + Təqribən bərabərdir + Bərabər deyil + Valyuta işarəsi + Bölmə işarəsi + Yuxarı ox + Sol ox + Rupi + Qara ürək + Tilda + Bərabər işarəsi + Von işarəsi + İstinad nişanı + Ağ ulduz + Qara ulduz + Ağ ürək + Ağ dairə + Qara dairə + Günəş simvolu + Hədəf + Ağ klub kostyumu + Ağ-qara kostyum + Ağ sol göstərici indeks + Ağ sağ göstərici indeks + Sol hissəsi yarı-qara olan dairə + Sağ hissəsi yarı-qara olan dairə + Ağ kvadrat + Qara kvadrat + Ağ yuxarı işarələyən üçbucaq + Ağ aşağı göstərən üçbucaq + Ağ sol göstərici üçbucaq + Ağ sağ göstərici üçbucaq + Ağ almaz + Dörddı bir qeydi + Səkkizinci qeyd + Qrupşəkilli on altıncı qeyd + Qadın simvolu + Kişi simvolu + Sol qara linzavari mötərizə + Sağ qara linzavari mötərizə + Sol künc mötərizə + Sağ künc mötərizə + Sağ ox + Aşağı ox + Üstəgəl-çıx işarəsi + Litr + Selsi dərəcəsi + Farengeyt dərəcəsi + Təxminən bərabərdir + İnteqral + Riyazi sol bucaq mötərizəsi + Riyazi sağ bucaq mötərizəsi + Poçt işarəsi + Yuxarı göstərən qara üçbucaq + Aşağı göstərən qara üçbucaq + Qara brilyant dəsti + Ensiz Katakana orta nöqtəsi + Kiçik qara kvadrat + Sol qoşa bucaqlı mötərizə + Sağ qoşa bucaqlı mötərizə + Tərs nida işarəsi + Tərs sual işarəsi + Von işarəsi + Qalın vergül + Qalın nida işarəsi + İdeoqrafik nöqtə + Qalın sual işarəsi + Orta nöqtə + Sağ cüt dırnaq işarəsi + İdeoqrafik vergül + Qalın iki nöqtə + Qalın nöqtəli vergül + Qalın birləşdirmə işarəsi + Qalı sirkumfleks + Qalın tilda + Sol cüt dırnaq işarəsi + Qalın sol mötərizə + Qalın sağ mötərizə + Qalın ulduz + Qalın altdan xətt + Sağ tək dırnaq işarəsi + Qalın sol buruq mötərizə + Qalın sağ buruq mötərizə + Qalın kiçikdir işarəsi + Qalın böyükdür işarəsi + Sol tək dırnaq işarəsi + diff --git a/utils/src/main/res/values-b+es+419/strings.xml b/utils/src/main/res/values-b+es+419/strings.xml new file mode 100644 index 0000000..27e4427 --- /dev/null +++ b/utils/src/main/res/values-b+es+419/strings.xml @@ -0,0 +1,50 @@ + + + Caracteres %1$d a %2$d + Carácter %1$d + sin título + se copió %1$s + %1$s mayúscula + %1$d %2$s + %1$s en uso + Presiona una nueva combinación de teclas para acceso directo. Debe incluir la tecla Alt o Control. + Presiona la combinación de teclas con la tecla modificadora %1$s para configurar una combinación de teclas nueva. + Sin asignar + Mayúscula + Alt + Ctrl + Búsqueda + Flecha hacia la derecha + Flecha hacia la izquierda + Flecha hacia arriba + Flecha hacia abajo + Predeterminado + Caracteres + Palabras + Líneas + Párrafos + Ventanas + Puntos de referencia + Encabezados + Listas + Vínculos + Controles + Contenido especial + Encabezados + Controles + Vínculos + Pantalla en pantalla de %1$s + %1$s está en la parte superior y %2$s en la inferior + %1$s está a la izquierda y %2$s a la derecha + %1$s está a la derecha y %2$s a la izquierda + Mostrando elementos del %1$d al %2$d de %3$d + Mostrando elemento %1$d de %2$d + Página %1$d de %2$d + %1$d de %2$d + %1$s (%2$s) + Salir + Mostrando %1$s + teclado oculto + Los comentarios por voz están activados + Los comentarios por voz están desactivados + diff --git a/utils/src/main/res/values-b+es+419/strings_symbols.xml b/utils/src/main/res/values-b+es+419/strings_symbols.xml new file mode 100644 index 0000000..54eeeee --- /dev/null +++ b/utils/src/main/res/values-b+es+419/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apóstrofo + Et + Signo menor que + Signo mayor que + Asterisco + Arroba + Barra inversa + Viñeta + Signo de intercalación + Signo de centavos + Dos puntos + Coma + Copyright + Llave de apertura + Llave de cierre + Signo de grados + Signo de división + Signo de dólar + Elipsis + Guión largo + Guión corto + Euro + Signo de exclamación + Acento grave + Guión + Comilla doble baja + Signo de multiplicación + Nueva línea + Marca de párrafo + Paréntesis de apertura + Paréntesis de cierre + Porcentaje + Punto + Pi + Numeral + Símbolo de libra esterlina + Signo de interrogación + Comillas + Marca registrada + Punto y coma + Barra oblicua + Barra espaciadora + Corchete de apertura + Corchete de cierre + Raíz cuadrada + Marca + Subrayado + Barra vertical + Yen + Signo de negación + Barra partida + Signo de micro + Casi igual a + No igual a + Signo de moneda + Signo de sección + Flecha hacia arriba + Flecha hacia la izquierda + Rupia + Corazón negro + Tilde + Signo de igual + Símbolo de la divisa won + Marca de referencia + Estrella blanca + Estrella negra + Corazón blanco + Círculo blanco + Círculo negro + Símbolo solar + Ojo de buey + Palo de tréboles blanco + Palo de picas blanco + Índice blanco hacia la izquierda + Índice blanco hacia la derecha + Círculo con la mitad izquierda negra + Círculo con la mitad derecha negra + Cuadrado blanco + Cuadrado negro + Triángulo blanco hacia arriba + Triángulo blanco hacia abajo + Triángulo blanco hacia la izquierda + Triángulo blanco hacia la derecha + Diamante blanco + Nota negra + Corchea + Semicorcheas ligadas + Símbolo femenino + Símbolo masculino + Paréntesis negro lenticular de apertura + Paréntesis negro lenticular de cierre + Paréntesis esquina de apertura + Paréntesis esquina de cierre + Flecha hacia la derecha + Flecha hacia abajo + Signo más y menos + Litro + Grado centígrado + Grado Fahrenheit + Aproximadamente igual a + Integral + Corchete angular de apertura matemático + Corchete angular de cierre matemático + Marca postal + Triángulo negro que apunta hacia arriba + Triángulo negro que apunta hacia abajo + Diamante negro + Punto medio de Katakana de ancho medio + Cuadrado negro pequeño + Paréntesis angular doble de apertura + Paréntesis angular doble de cierre + Exclamación de apertura + Interrogación de apertura + Símbolo de la divisa won + Coma de ancho total + Signo de exclamación de ancho total + Punto final ideográfico + Signo de pregunta de ancho total + Punto medio + Comillas dobles derechas + Coma ideográfica + Dos puntos de ancho total + Punto y coma de ancho total + Y comercial de ancho total + Circunflejo de ancho total + Tilde de ancho total + Comillas dobles izquierdas + Paréntesis izquierdo de ancho total + Paréntesis derecho de ancho total + Asterisco de ancho total + Guion bajo de ancho total + Comilla simple de cierre + Llave de apertura de ancho total + Llave de cierre de ancho total + Signo menor que de ancho total + Signo mayor que de ancho total + Comilla simple de apertura + diff --git a/utils/src/main/res/values-b+sr+Latn/strings.xml b/utils/src/main/res/values-b+sr+Latn/strings.xml new file mode 100644 index 0000000..b00f6cb --- /dev/null +++ b/utils/src/main/res/values-b+sr+Latn/strings.xml @@ -0,0 +1,50 @@ + + + Znakovi %1$d do %2$d + %1$d. znak + nenaslovljeno + kopirano, %1$s + veliko %1$s + %1$d %2$s + Koristi se %1$s + Pritisnite kombinaciju tastera da biste podesili novu prečicu. Ona mora da sadrži bar tastere Alt ili Ctrl. + Pritisnite kombinaciju tastera sa modifikujućim tasterom %1$s da biste podesili novu prečicu. + Nedodeljeno + Shift + Alt + Ctrl + Pretraga + Strelica udesno + Strelica ulevo + Strelica nagore + Strelica nadole + Podrazumevano + Znakovi + Reči + Redovi + Pasusi + Prozori + Obeležja + Naslovi + Liste + Linkovi + Kontrole + Specijalan sadržaj + Naslovi + Kontrole + Linkovi + Slika u slici za aplikaciju %1$s + %1$s će biti u vrhu, a %2$s će biti u dnu + %1$s će biti levo, %2$s a će biti desno + %1$s će biti desno, a %2$s će biti levo + Prikazuju se stavke %1$d do %2$d od %3$d. + Prikazuje se stavka %1$d od %2$d. + Stranica %1$d od %2$d + %1$d od %2$d + %1$s (%2$s) + Zatvori + Prikazuje se %1$s + tastatura je sakrivena + Govorne povratne informacije su uključene + Govorne povratne informacije su isključene + diff --git a/utils/src/main/res/values-b+sr+Latn/strings_symbols.xml b/utils/src/main/res/values-b+sr+Latn/strings_symbols.xml new file mode 100644 index 0000000..3bf2d41 --- /dev/null +++ b/utils/src/main/res/values-b+sr+Latn/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Ampersand + Znak za manje + Znak za Veće od + Zvezdica + Majmunče + Obrnuta kosa crta + Nabrajanje + Karet + znak za cent + Dve tačke + Zarez + Autorska prava + Otvorena vitičasta zagrada + Zatvorena vitičasta zagrada + Znak za stepen + Znak za deljenje + Znak za dolar + Tri tačke + Duža povlaka + Kraća povlaka + Evro + Znak uzvika + Teški akcenat + Crta + Donji dvostruki navodnici + Znak za množenje + Novi red + Oznaka pasusa + Otvorena zagrada + Zatvorena zagrada + Procenat + Tačka + Pi + Funta + Znak valute za funtu + Znak pitanja + Navodnik + Registrovani žig + Tačka i zarez + Kosa crta + Razmak + Otvorena uglasta zagrada + Zatvorena uglasta zagrada + Kvadratni koren + Žig + Donja crta + Vertikalna linija + Jen + Znak Nije + Isprekidana uspravna crta + Znak mikro + Približno jednako + Nije jednako + Znak valute + Znak paragrafa + Strelica nagore + Strelica ulevo + Rupija + Crno srce + Tilda + Znak jednakosti + Znak valute za von + Znak za fusnotu + Bela zvezdica + Crna zvezdica + Belo srce + Beli krug + Crni krug + Simbol sunca + Meta + Beli simbol tref + Beli simbol pik + Beli kažiprst okrenut ulevo + Beli kažiprst okrenut udesno + Krug sa crnom levom polovinom + Krug sa crnom desnom polovinom + Beli kvadrat + Crni kvadrat + Beli trougao usmeren nagore + Beli trougao usmeren nadole + Beli trougao usmeren ulevo + Beli trougao usmeren udesno + Beli simbol karo + Četvrtina note + Osmina note + Dve šesnaestine note + Ženski simbol + Muški simbol + Leva crna lentikularna zagrada + Desna crna lentikularna zagrada + Leva uglasta zagrada + Desna ugaona zagrada + Strelica udesno + Strelica nadole + Znak plus minus + Malo slovo l + Stepen Celzijusa + Stepen Farenhajta + Znak za približan iznos + Integral + Matematička leva uglasta zagrada + Matematička desna uglasta zagrada + Poštanska oznaka + Crni trougao sa vrhom nagore + Crni trougao sa vrhom nadole + Crni rombovi + Poluširoka katakana srednja tačka + Mali crni kvadrat + Otvorena dvostruka ugaona zagrada + Zatvorena dvostruka ugaona zagrada + Obrnuti znak uzvika + Obrnuti znak pitanja + Znak valute za von + Zarez pune širine + Znak uzvika pune širine + Ideografska tačka + Znak pitanja pune širine + Srednja tačka + Zatvoreni navodnik + Ideografski zarez + Dve tačke pune širine + Tačka-zarez pune širine + Ampersand pune širine + Cirkumfleks pune širine + Znak tilda pune širine + Otvoreni navodnik + Otvorena zagrada pune širine + Zatvorena zagrada pune širine + Zvezdica pune širine + Donja crta pune širine + Zatvoreni polunavodnik + Otvorena vitičasta zagrada pune širine + Zatvorena vitičasta zagrada pune širine + Znak za manje pune širine + Znak za više pune širine + Otvoreni polunavodnik + diff --git a/utils/src/main/res/values-be/strings.xml b/utils/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..e96388c --- /dev/null +++ b/utils/src/main/res/values-be/strings.xml @@ -0,0 +1,50 @@ + + + Сімвалы з %1$d да %2$d + Сімвал %1$d + без назвы + скапіравана: %1$s + вялікая літара %1$s + %1$d %2$s + Выкарыстоўваецца %1$s + Націсніце спалучэнне клавіш, каб задаць новы шлях хуткага доступу. Адна з клавіш у спалучэнні павінна быць ALT або Ctrl. + Каб задаць новае спалучэнне клавіш, націсніце службовую клавішу %1$s у камбінацыі з патрэбнай клавішай. + Не прызначана + Shift + Alt + Ctrl + Пошук + Стрэлка ўправа + Стрэлка ўлева + Стрэлка ўверх + Стрэлка ўніз + Стандартная + Сімвалы + Словы + Радкі + Абзацы + Вокны + Арыенціры + Загалоўкі + Спісы + Спасылкі + Элементы кіравання + Спецыяльнае змесцiва + Загалоўкі + Кіраванне + Спасылкі + %1$s відарыс у відарысе + %1$s знаходзіцца зверху, %2$s – знізу + %1$s знаходзіцца злева, %2$s – справа + %1$s знаходзіцца справа, %2$s – злева + Паказаны элементы з %1$d па %2$d з %3$d. + Паказаны элементы: %1$d з %2$d. + Старонка %1$d з %2$d + %1$d з %2$d + %1$s (%2$s) + Выйсці + Паказ акна \"%1$s\" + клавіятура схавана + Галасавая зваротная сувязь уключана + Галасавая зваротная сувязь выключана + diff --git a/utils/src/main/res/values-be/strings_symbols.xml b/utils/src/main/res/values-be/strings_symbols.xml new file mode 100644 index 0000000..9936c4f --- /dev/null +++ b/utils/src/main/res/values-be/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Апостраф + Амперсанд + Знак \"менш за\" + Знак \"больш за\" + Зорачка + Сабачка + Зваротная касая рыса + Пункт маркіраванага спіса + Цыркумфлекс + Знак цэнта + Двукроп\'е + Коска + Аўтарскія правы + Левая фігурная дужка + Правая фігурная дужка + Знак градуса + Знак дзялення + Знак долара + Шматкроп\'е + Доўгі працяжнік + Працяжнік + Еўра + Клічнік + Гравіс + Злучок + Нізкае двукоссе + Знак множання + Новы радок + Знак абзаца + Левая дужка + Правая дужка + Працэнт + Кропка + Пі + Фунт + Знак фунта + Пытальнік + Двукоссе + Зарэгістраваны таварны знак + Кропка з коскай + Касая рыса + Прабел + Левая квадратная дужка + Правая квадратная дужка + Квадратны корань + Таварны знак + Знак падкрэслення + Вертыкальная лінія + Іена + Знак лагічнага адмаўлення + Перарывістая рыса + Сімвал мікра + Амаль супадае з наступным: + Не супадае з наступным: + Знак валюты + Значок параграфа + Стрэлка ўверх + Стрэлка ўлева + Рупія + Чорнае сэрца + Тыльда + Знак роўнасці + Знак воны + Знак зноскі + Незафарбаваная зорка + Чорная зорка + Незафарбаваны сімвал чырваў + Незафарбаваны круг + Чорны круг + Сонца + Аператар круг у крузе + Незафарбаваны сімвал трэфаў + Незафарбаваны сімвал пік + Незафарбаваная рука, якая паказвае ўлева + Незафарбаваная рука, якая паказвае ўправа + Круг з чорнай левай паловай + Круг з чорнай правай паловай + Незафарбаваны квадрат + Чорны квадрат + Незафарбаваны трохвугольнік, які паказвае ўверх + Незафарбаваны трохвугольнік, які паказвае ўніз + Незафарбаваны трохвугольнік, які паказвае ўлева + Незафарбаваны трохвугольнік, які паказвае ўправа + Незафарбаваны сімвал бубнаў + Чацвяртная нота + Восьмая нота + Згрупаваныя шаснаццатыя ноты + Жаночы знак + Мужчынскі знак + Левая чорная лінзападобная дужка + Правая чорная лінзападобная дужка + Дужка ў выглядзе левага кута + Дужка ў выглядзе правага кута + Стрэлка ўправа + Стрэлка ўніз + Знак «плюс-мінус» + Літр + Градус Цэльсія + Градус Фарэнгейта + Прыкладна роўна + Інтэграл + Матэматычная левая вуглавая дужка + Матэматычная правая вуглавая дужка + Паштовы знак + Чорны трохвугольнік з вяршыняй уверх + Чорны трохвугольнік з вяршыняй уніз + Чорны алмаз + Сярэдняя кропка катаканы ў палавінную шырыню + Маленькі чорны квадрат + Левае двукоссе \"ялінкі\" + Правае двукоссе \"ялінкі\" + Перавернуты клічнік + Перавернуты пытальнік + Знак воны + Коска ў поўную шырыню + Клічнік у поўную шырыню + Кропка для іерогліфаў + Пытальнік у поўную шырыню + Кропка ў цэнтры + Правае двукоссе + Коска для іерогліфаў + Двукоссе ў поўную шырыню + Кропка з коскай у поўную шырыню + Амперсанд у поўную шырыню + Цыркумфлекс у поўную шырыню + Тыльда ў поўную шырыню + Левае двукоссе + Левая круглая дужка ў поўную шырыню + Правая круглая дужка ў поўную шырыню + Зорачка ў поўную шырыню + Падкрэсліванне ў поўную шырыню + Правае адзінарнае двукоссе \"лапка\" + Левая фігурная дужка ў поўную шырыню + Правая фігурная дужка ў поўную шырыню + Знак \"Менш чым\" у поўную шырыню + Знак \"Больш чым\" у поўную шырыню + Левае адзінарнае двукоссе \"лапка\" + diff --git a/utils/src/main/res/values-bg/strings.xml b/utils/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..b0e516a --- /dev/null +++ b/utils/src/main/res/values-bg/strings.xml @@ -0,0 +1,50 @@ + + + Избрахте знаците от %1$d до %2$d + Знак %1$d + без заглавие + копирахте текста „%1$s“ + главна буква %1$s + %1$d %2$s + Използва се %1$s + Натиснете клавишната комбинация, за да зададете нов пряк път. Той трябва да съдържа поне клавишите „ALT“ или „Control“. + Натиснете клавишна комбинация с модифициращия клавиш „%1$s“, за да зададете нова комбинация. + Невъзложено + Shift + Alt + Ctrl + Търсене + Стрелка за надясно + Стрелка за наляво + Стрелка за нагоре + Стрелка за надолу + По подразбиране + Знаци + Думи + Редове + Абзаци + Прозорци + Ориентири + Заглавия + Списъци + Връзки + Контроли + Специално съдържание + Заглавия + Контроли + Връзки + %1$s е в режима „Картина в картината“ + %1$s е отгоре, а %2$s е отдолу + %1$s е отляво, а %2$s е отдясно + %1$s е отдясно, а %2$s е отляво + Показани са елементи %1$d до %2$d от %3$d. + Показан е елемент %1$d от %2$d. + Страница %1$d от %2$d + %1$d от %2$d + %1$s (%2$s) + Изход + Показва се %1$s + клавиатурата е скрита + Обратната връзка с говор е включена + Обратната връзка с говор е изключена + diff --git a/utils/src/main/res/values-bg/strings_symbols.xml b/utils/src/main/res/values-bg/strings_symbols.xml new file mode 100644 index 0000000..fff8b57 --- /dev/null +++ b/utils/src/main/res/values-bg/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Апостроф + Амперсанд + Знак за по-малко + Знак за по-голямо + Звездичка + Кльомба + Обратна наклонена черта + Водещ символ + Циркумфлекс + Знак за цент + Двоеточие + Запетая + Авторско право + Лява фигурна скоба + Дясна фигурна скоба + Знак за градуси + Знак за деление + Знак за долар + Многоточие + Дълго тире + Средно тире + Евро + Удивителен знак + Ударение + Тире + Долни двойни кавички + Знак за умножение + Нов ред + Знак за абзац + Лява скоба + Дясна скоба + Процент + Точка + Пи + Диез + Знак за британска лира + Въпросителен знак + Кавички + Регистрирана запазена марка + Точка и запетая + Наклонена черта + Интервал + Лява квадратна скоба + Дясна квадратна скоба + Корен квадратен + Запазена марка + Долно тире + Вертикална черта + Йена + Знак за не + Прекъсната вертикална линия + Знак за микро + Почти равно на + Не е равно на + Знак за валута + Знак за параграф + Стрелка за нагоре + Стрелка за наляво + Рупия + Черно сърце + Тилда + Знак за равенство + Знак за корейски вон + Знак за препратка + Бяла звезда + Черна звезда + Бяло сърце + Бял кръг + Черен кръг + Точка в кръг + Знак за мишена + Бяла спатия + Бяла пика + Сочещ наляво бял показалец + Сочещ надясно бял показалец + Кръг с черна лява половина + Кръг с черна дясна половина + Бял квадрат + Черен квадрат + Сочещ нагоре бял триъгълник + Сочещ надолу бял триъгълник + Сочещ наляво бял триъгълник + Сочещ надясно бял триъгълник + Бяло каро + Четвъртина нота + Осмина нота + Свързани шестнайсетини ноти + Символ на Венера + Символ на Марс + Лява черна лещовидна скоба + Дясна черна лещовидна скоба + Лява ъглова скоба + Дясна ъглова скоба + Сочеща надясно стрелка + Сочеща надолу стрелка + Знак плюс-минус + Знак за литър + Знак за градуси по Целзий + Знак за градуси по Фаренхайт + Знак за приблизително равенство + Интеграл + Математическа лява ъглова скоба + Математическа дясна ъглова скоба + Пощенска марка + Черен триъгълник, сочещ нагоре + Черен триъгълник, сочещ надолу + Черно каро + Средна точка с половин ширина на катакана + Черен малък квадрат + Лява двойна ъглова скоба + Дясна двойна ъглова скоба + Обърнат удивителен знак + Обърнат въпросителен знак + Знак за корейски вон + Запетая с пълна ширина + Удивителен знак с пълна ширина + Идеографска точка + Въпросителен знак с пълна ширина + Средна точка + Дясна двойна кавичка + Идеографска запетая + Двоеточие с пълна ширина + Точка и запетая с пълна ширина + Амперсанд с пълна ширина + Сложно ударение с пълна ширина + Тилда с пълна ширина + Лява двойна кавичка + Лява кръгла скоба с пълна ширина + Дясна кръгла скоба с пълна ширина + Звездичка с пълна ширина + Долна черта с пълна ширина + Дясна единична кавичка + Лява фигурна скоба с пълна ширина + Дясна фигурна скоба с пълна ширина + Знак за по-малко с пълна ширина + Знак за по-голямо с пълна ширина + Лява единична кавичка + diff --git a/utils/src/main/res/values-bn/strings.xml b/utils/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..88b80f9 --- /dev/null +++ b/utils/src/main/res/values-bn/strings.xml @@ -0,0 +1,50 @@ + + + %1$d থেকে %2$d পর্যন্ত অক্ষরগুলি + %1$d অক্ষর + শিরোনামহীন + %1$s কপি করা হয়েছে + বড় হাতের %1$s + %1$d %2$s + %1$s ব্যবহার করা হচ্ছে + নতুন শর্টকার্ট সেট করার কী সমন্বয় টিপুন৷ এর মধ্যে অবশ্যই অন্তত ALT অথবা কন্ট্রোল কী থাকতে হবে৷ + নতুন শর্টকাট সেট করতে %1$s সংশোধক কী সহ কী সমন্বয় টিপুন। + অনির্দিষ্ট অ্যাকশান + Shift + Alt + Ctrl + সার্চ + ডান তীর + বাঁ তীর + উপরে যাওয়ার তীর + নিচে যাওয়ার তীর + ডিফল্ট + অক্ষরগুলি + শব্দগুলি + লাইনগুলি + অনুচ্ছেদগুলি + উইন্ডো + ল্যান্ডমার্ক + শিরোনাম + তালিকাগুলি + লিঙ্কগুলি + নিয়ন্ত্রণগুলি + বিশেষ কন্টেন্ট + শিরোনাম + নিয়ন্ত্রণ + লিঙ্ক + %1$s ছবির মধ্যে ছবি + উপরে %1$s , নিচে %2$s + বাঁয়ে %1$s, ডানে %2$s + ডানে %1$s, বাঁয়ে %2$s + %3$dটির মধ্যে %1$d থেকে %2$d পর্যন্ত আইটেমগুলি দেখানো হচ্ছে৷ + %2$dটির মধ্যে %1$dটি আইটেম দেখানো হচ্ছে৷ + %2$dটির মধ্যে %1$d নং পৃষ্ঠা + %2$dটির মধ্যে %1$d নং + %1$s (%2$s) + বেরিয়ে আসুন + %1$s দেখানো হচ্ছে + কীবোর্ড লুকানো আছে + পড়ে শোনানো ফিচারটি চালু আছে + পড়ে শোনানো ফিচারটি বন্ধ আছে + diff --git a/utils/src/main/res/values-bn/strings_symbols.xml b/utils/src/main/res/values-bn/strings_symbols.xml new file mode 100644 index 0000000..22857e8 --- /dev/null +++ b/utils/src/main/res/values-bn/strings_symbols.xml @@ -0,0 +1,140 @@ + + + ঊর্ধকমা + এম্পারসেন্ড + অপেক্ষাকৃত ছোট বোঝানোর চিহ্ন + অপেক্ষাকৃত বড় বোঝানোর চিহ্ন + তারকাচিহ্ন + অ্যাট + ব্যাকস্ল্যাশ + বুলেট + ক্যারেট + \'সেন্ট\' চিহ্ন + কোলন + কমা + কপিরাইট + বাঁ দ্বিতীয় বন্ধনী + ডান দ্বিতীয় বন্ধনী + ডিগ্রি চিহ্ন + বিভাজন চিহ্ন + ডলার চিহ্ন + এলিপসিস + এম ড্যাশ + এন ড্যাশ + ইউরো + বিস্ময়বোধক চিহ্ন + গ্রেভ অ্যাকসেন্ট + ড্যাশ + নিম্ন ডাবল উদ্ধৃতি + গুণ চিহ্ন + নতুন লাইন + অনুচ্ছেদ চিহ্ন + বাঁ বন্ধনী + ডান বন্ধনী + শতাংশ + পূর্ণচ্ছেদ + পাই + পাউন্ড + \'পাউন্ড\' মুদ্রার চিহ্ন + প্রশ্নচিহ্ন + উদ্ধৃতি + নিবন্ধিত ট্রেডমার্ক + সেমিকোলন + স্ল্যাশ + ব্যবধান + বাঁ তৃতীয় বন্ধনী + ডান তৃতীয় বন্ধনী + বর্গমূল + ট্রেডমার্ক + আন্ডারস্কোর + উল্লম্ব রেখা + ইয়েন + না চিহ্ন + ভগ্ন দণ্ড + মাইক্রো চিহ্ন + প্রায় সমান + সমান নয় + মুদ্রা চিহ্ন + সেকশন চিহ্ন + ঊর্ধ্বমুখী তীর + বামমুখী তীর + টাকা + কালো হৃদয় + টিল্ডা + সমান চিহ্ন + কোরিয়ার \'ওন\' নামের মুদ্রার চিহ্ন + নির্দেশ-চিহ্ন + সাদা তারকা + কালো তারকা + সাদা হৃদয় + সাদা বৃত্ত + কালো বৃত্ত + সৌর প্রতীক + ধড়াচূড়া + সাদা ক্লাব স্যুইট + সাদা স্পেডি স্যুট + সাদা বামদিকে নির্দেশিত সূচক + সাদা ডানদিকে নির্দেশিত সূচক + বামে অর্ধেক কালো রঙ সহ বৃত্ত + ডানে অর্ধেক কালো রঙ সহ বৃত্ত + সাদা বর্গক্ষেত্র + কালো বর্গক্ষেত্র + সাদা উপরের দিকে নির্দেশিত ত্রিভুজ + সাদা নীচের দিকে নির্দেশিত ত্রিভুজ + সাদা বামদিকে নির্দেশিত ত্রিভুজ + সাদা ডানদিকে নির্দেশিত ত্রিভুজ + সাদা হীরা + কোয়ার্টার নোট + অষ্টম নোট + পাঠানো ষোড়শ টীকাগুলি + মহিলা প্রতীক + পুরুষ প্রতীক + বাঁ কালো লেন্টিকুলার বন্ধনী + ডান কালো লেন্টিকুলার বন্ধনী + বাঁ কোণ বন্ধনী + ডান কোণ বন্ধনী + ডানদিকে নির্দেশিত তীর + ডাউনলোডগুলি-এর তীর + যোগ বিয়োগ চিহ্ন + লিটার + ডিগ্রী সেলসিয়াস + ফারেনহাইট ডিগ্রী + প্রায় সমান + অখণ্ড + অঙ্কশাস্ত্রে ব্যবহার করা বাঁদিকের কৌণিক ব্র্যাকেট + অঙ্কশাস্ত্রে ব্যবহার করা ডানদিকের কৌণিক ব্র্যাকেট + পোস্টাল মার্ক + উর্দ্ধমুখী কালো রঙের ত্রিভূজ + নিম্মমুখী কালো রঙের ত্রিভূজ + ব্ল্যাক স্যুট অফ ডায়মন্ড + কাটকানা লিপির অর্ধপ্রস্থ বিন্দু + ছোট কালো রঙের স্কোয়ার + বাঁদিকের ডবল অ্যাঙ্গেল ব্র্যাকেট + ডানদিকে ডবল অ্যাঙ্গেল ব্র্যাকেট + উল্টানো বিস্ময়সূচক চিহ্ন + উল্টানো প্রশ্ন চিহ্ন + কোরিয়ার মুদ্রা \'ওন\'-এর চিহ্ন + সম্পূর্ণ প্রস্থে লেখা কমা + সম্পূর্ণ প্রস্থে লেখা বিস্ময়সূচক চিহ্ন + আইডিয়োগ্রাফিক যতি চিহ্ন + সম্পূর্ণ প্রস্থে লেখা প্রশ্ন চিহ্ন + মধ্য বিন্দু + ডানদিকের ডবল উদ্ধৃতি চিহ্ন + আইডিয়োগ্রাফিক কমা + সম্পূর্ণ প্রস্থে লেখা কোলন + সম্পূর্ণ প্রস্থে লেখা সেমিকোলন + সম্পূর্ণ প্রস্থে লেখা অ্যামপারসেন্ড + সম্পূর্ণ প্রস্থে লেখা সারকামফ্লেক্স + সম্পূর্ণ প্রস্থে লেখা টিল্ড + বাঁদিকের ডবল উদ্ধৃতি চিহ্ন + সম্পূর্ণ প্রস্থে লেখা বাঁদিকের বন্ধনী + সম্পূর্ণ প্রস্থে লেখা ডানদিকের বন্ধনী + সম্পূর্ণ প্রস্থে লেখা অ্যাসটেরিক্স + সম্পূর্ণ প্রস্থে লেখা আন্ডারস্কোর + ডানদিকের একক উদ্ধৃতি চিহ্ন + সম্পূর্ণ প্রস্থে লেখা বাঁদিকের দ্বিতীয় ব্র্যাকেট + সম্পূর্ণ প্রস্থে লেখা ডানদিকের দ্বিতীয় ব্র্যাকেট + সম্পূর্ণ প্রস্থে লেখা \'কম\'-এর চিহ্ন + সম্পূর্ণ প্রস্থে লেখা \'বেশি\'-এর চিহ্ন + বাঁদিকের একক উদ্ধৃতি চিহ্ন + diff --git a/utils/src/main/res/values-bs/strings.xml b/utils/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..43e75df --- /dev/null +++ b/utils/src/main/res/values-bs/strings.xml @@ -0,0 +1,50 @@ + + + Znakovi od %1$d do %2$d + Znak %1$d + Neimenovano + kopirano, %1$s + Veliko %1$s + %1$d %2$s + Koristi %1$s + Pritisnite kombinaciju tipki da postavite novu prečicu. Mora sadržavati barem tipku Alt ili Ctrl. + Za postavljanje nove prečice pritisnite kombinaciju tipki s modifikacijskom tipkom %1$s. + Nedodijeljeno + Shift + Alt + Ctrl + Pretraži + Desna strelica + Lijeva strelica + Gornja strelica + Donja strelica + Zadano + Znakovi + Riječi + Redovi + Pasusi + Prozori + Znamenitosti + Naslovi + Liste + Linkovi + Kontrole + Poseban sadržaj + Naslovi + Kontrole + Linkovi + Slika u slici aplikacije %1$s + %1$s na vrhu, %2$s na dnu + %1$s lijevo, %2$s desno + %1$s desno, %2$s lijevo + Prikazanih rezultata %1$d do %2$d od %3$d. + Prikazanih stavki %1$d od %2$d. + Stranica %1$d od %2$d + %1$d od %2$d + %1$s (%2$s) + Izađi + Trenutno se prikazuje %1$s + tastatura je skrivena + Govorne povratne informacije su uključene + Govorne povratne informacije su isključene + diff --git a/utils/src/main/res/values-bs/strings_symbols.xml b/utils/src/main/res/values-bs/strings_symbols.xml new file mode 100644 index 0000000..72ca9e4 --- /dev/null +++ b/utils/src/main/res/values-bs/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Ampersand + Znak za \"manje od\" + Znak za \"veće od\" + Zvjezdica + At + Obrnuta kosa crta + Oznaka + Znak za umetanje + znak za cent + Dvotačka + Zarez + Autorska prava + Otvorena vitičasta zagrada + Zatvorena vitičasta zagrada + Znak za stepen + Znak dijeljenja + Znak dolara + Elipsa + Duga crta + Povlaka + Euro + Uzvičnik + Gravis + Crta + Donji otvoreni navodnici + Znak množenja + Novi red + Alineja + Otvorena zagrada + Zatvorena zagrada + Postotak + Tačka + Pi + Funta + Znak za valutu funta + Upitnik + Navodnik + Registrirani zaštitni znak + Tačka-zarez + Kosa crta + Razmak + Otvorena uglasta zagrada + Zatvorena uglasta zagrada + Kvadratni korijen + Zaštitni znak + Donja crta + Vertikalna linija + Jen + Znak ne + Vertikalna isprekidana crta + Znak za mikro + Približno jednako + Nije jednako + Znak valute + Paragraf + Gornja strelica + Lijeva strelica + Rupija + Crno srce + Tilda + Znak jednakosti + Znak za valutu von + Referentni znak + Bijela zvijezda + Crna zvijezda + Bijelo srce + Bijeli krug + Crni krug + Solarni simbol + Centar + Skupina bijeli tref + Skupina bijeli pik + Bijeli kažiprst usmjeren lijevo + Bijeli kažiprst usmjeren desno + Krug s crnom lijevom polovinom + Krug s crnom desnom polovinom + Bijeli kvadrat + Crni kvadrat + Bijeli trokut usmjeren prema gore + Bijeli trokut usmjeren dolje + Bijeli trokut usmjeren lijevo + Bijeli trokut usmjeren desno + Bijeli karo + Nota četvrtinka + Nota osminka + Šesnaestinke povezane gredom + Znak za žene + Znak za muškarce + Crna otvorena konkavna zagrada + Crna zatvorena konkavna zagrada + Otvorena ugaona zagrada + Zatvorena ugaona zagrada + Desna strelica + Donja strelica + Znak plus-minus + Litar + Celzijus + Farenhajt + Približno jednako + Integral + Matematička otvorena izlomljena zagrada + Matematička zatvorena izlomljena zagrada + Poštanska oznaka + Crni trokut s vrhom okrenutim prema gore + Crni trokut s vrhom okrenutim prema dolje + Crni rombovi + Srednja tačka polovične širine u katakani + Mali crni kvadrat + Otvorena dvostruka uglasta zagrada + Zatvorena dvostruka uglasta zagrada + Obrnuti uzvičnik + Obrnuti upitnik + Znak za valutu von + Zarez pune širine + Uzvičnik pune širine + Ideogramska tačka + Upitnik pune širine + Srednja tačka + Zatvoreni dvostruki navodnik + Ideogramski zarez + Dvotačka pune širine + Tačka-zarez pune širine + Ampersand pune širine + Cirkumfleks pune širine + Tilda pune širine + Otvoreni dupli navodnik + Otvorena zagrada pune širine + Zatvorena zagrada pune širine + Zvjezdica pune širine + Donja crta pune širine + Zatvoreni navodnik + Otvorena vitičasta zagrada pune širine + Zatvorena vitičasta zagrada pune širine + Znak za manje pune širine + Znak za više pune širine + Otvoreni navodnik + diff --git a/utils/src/main/res/values-ca/strings.xml b/utils/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..7bf2f48 --- /dev/null +++ b/utils/src/main/res/values-ca/strings.xml @@ -0,0 +1,50 @@ + + + Caràcters de %1$d a %2$d + Caràcter %1$d + sense títol + s\'ha copiat %1$s + %1$s majúscula + %1$d %2$s + S\'està utilitzant %1$s + Prem una combinació de tecles per definir una drecera nova. S\'ha de fer servir, com a mínim, la tecla ALT o Control. + Prem la combinació de tecles amb la tecla modificadora %1$s per definir una drecera nova. + Sense assignar + Maj + Alt + Ctrl + Cerca + Fletxa dreta + Fletxa esquerra + Fletxa amunt + Fletxa avall + Predeterminat + Caràcters + Paraules + Línies + Paràgrafs + Finestres + Punts de referència + Títols + Llistes + Enllaços + Controls + Contingut especial + Títols + Controls + Enllaços + %1$s en pantalla en pantalla + %1$s a la part superior; %2$s a la part inferior + %1$s a l\'esquerra; %2$s a la dreta + %1$s a la dreta; %2$s a l\'esquerra + S\'estan mostrant els elements de %1$d a %2$d de %3$d. + S\'està mostrant l\'element %1$d de %2$d. + Pàgina %1$d de %2$d + %1$d de %2$d + %1$s (%2$s) + Surt + S\'està mostrant la finestra %1$s + el teclat està amagat + Els avisos de veu estan activats + Els avisos de veu estan desactivats + diff --git a/utils/src/main/res/values-ca/strings_symbols.xml b/utils/src/main/res/values-ca/strings_symbols.xml new file mode 100644 index 0000000..a4d8589 --- /dev/null +++ b/utils/src/main/res/values-ca/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apòstrof + Signe & + Símbol de menor que + Símbol de major que + Asterisc + Arrova + Barra inversa + Pic + Accent circumflex + Símbol de cèntim + Dos punts + Coma + Drets d\'autor + Clau d\'obertura + Clau de tancament + Símbol de grau + Signe de divisió + Signe de dòlar + Punts suspensius + Guió llarg + Guió curt + Euro + Signe d\'admiració + Accent greu + Guió + Cometes angulars + Signe de multiplicació + Línia nova + Marca de paràgraf + Parèntesi d\'obertura + Parèntesi de tancament + Percentatge + Punt + Pi + Coixinet + Símbol de moneda de la lliura esterlina + Signe d\'interrogació + Cometes + Marca comercial registrada + Punt i coma + Barra + Espai + Claudàtor d\'obertura + Claudàtor de tancament + Arrel quadrada + Marca comercial + Ratlla baixa + Barra vertical + Ien + Signe de negació + Barra trencada + Signe de micro + Aproximadament igual a + No és igual a + Signe de moneda + Signe de secció + Fletxa amunt + Fletxa esquerra + Rupia + Cor negre + Titlla + Signe igual + Símbol de moneda del won + Marca de referència + Estrella blanca + Estrella negra + Cor blanc + Cercle blanc + Cercle negre + Símbol solar + Diana + Trèvol blanc + Pica blanca + Dit índex que apunta cap a l\'esquerra + Dit índex que apunta cap a la dreta + Cercle amb la meitat esquerra negra + Cercle amb la meitat dreta negra + Quadre blanc + Quadrat negre + Triangle blanc que apunta cap amunt + Triangle blanc que apunta cap avall + Triangle blanc que apunta cap a l\'esquerra + Triangle blanc que apunta cap a la dreta + Diamant blanc + Negra + Corxera + Semicorxeres agrupades + Símbol de dona + Símbol d\'home + Parèntesi lenticular esquerre negre + Parèntesi lenticular dret negre + Parèntesi angular esquerre + Parèntesi angular dret + Fletxa dreta + Fletxa avall + Signe més o menys + Litre + Grau Celsius + Grau Fahrenheit + Aproximadament és igual a + Integral + Parèntesi angular matemàtic d\'obertura + Parèntesi angular matemàtic de tancament + Marca del servei postal + Triangle negre que assenyala cap amunt + Triangle negre que assenyala cap avall + Diamant negre + Punt al mig Katakana de mitja amplada + Quadrat negre petit + Claudàtor angular doble esquerre + Claudàtor angular doble dret + Signe d\'exclamació invertit + Signe d\'interrogació invertit + Símbol de moneda del won + Coma d\'amplada completa + Signe d\'exclamació d\'amplada completa + Punt ideogràfic + Signe d\'interrogació d\'amplada completa + Punt volat + Cometes dobles de tancament + Coma ideogràfica + Dos punts d\'amplada completa + Punt i coma d\'amplada completa + Signe et d\'ampada completa + Accent circumflex d\'amplada completa + Titlla d\'amplada completa + Cometes dobles d\'obertura + Parèntesi d\'obertura d\'amplada completa + Parèntesi de tancament d\'amplada completa + Asterisc d\'amplada completa + Ratlla baixa d\'amplada completa + Cometa simple de tancament + Clau d\'obertura d\'amplada completa + Clau de tancament d\'amplada completa + Símbol de menor que d\'amplada completa + Símbol de major que d\'amplada completa + Cometa simple d\'obertura + diff --git a/utils/src/main/res/values-cs/strings.xml b/utils/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..9d4108b --- /dev/null +++ b/utils/src/main/res/values-cs/strings.xml @@ -0,0 +1,50 @@ + + + Znaky %1$d až %2$d + Znak %1$d + bez názvu + zkopírováno, %1$s + velké %1$s + %1$dkrát %2$s + Používá %1$s + Stisknutím kombinace kláves nastavte novou zkratku. Je nutné zahrnout tlačítko ALT nebo CTRL. + Novou zkratku nastavíte stisknutím požadované kombinace kláves s modifikační klávesou %1$s. + Nepřiřazeno + Shift + Alt + Ctrl + Vyhledávání + Šipka vpravo + Šipka vlevo + Šipka nahoru + Šipka dolů + Výchozí + Znaky + Slova + Řádky + Odstavce + Okna + Orientační body + Nadpisy + Seznamy + Odkazy + Ovládací prvky + Speciální obsah + Nadpisy + Ovládací prvky + Odkazy + Obraz v obraze %1$s + %1$s nahoře, %2$s dole + %1$s nalevo, %2$s napravo + %1$s napravo, %2$s nalevo + Zobrazují se položky %1$d až %2$d z %3$d. + Zobrazuje se položka %1$d z %2$d. + Stránka %1$d z %2$d + %1$d z %2$d + %1$s (%2$s) + Ukončit + Zobrazené okno: %1$s + klávesnice je skryta + Je zapnutá hlasová zpětná vazba + Hlasová odezva je vypnutá + diff --git a/utils/src/main/res/values-cs/strings_symbols.xml b/utils/src/main/res/values-cs/strings_symbols.xml new file mode 100644 index 0000000..2be9c10 --- /dev/null +++ b/utils/src/main/res/values-cs/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Ampersand + Znak menší než + Znak větší než + Hvězdička + Zavináč + Zpětné lomítko + Odrážka + Stříška + Znak centu + Dvojtečka + Čárka + Autorská práva + Levá složená závorka + Pravá složená závorka + Znak stupně + Symbol děleno + Dolar + Tři tečky + Dlouhá pomlčka + Krátká pomlčka + Euro + Vykřičník + Těžký akcent + Spojovník + Uvozovky dole + Symbol krát + Nový řádek + Znak odstavce + Levá závorka + Pravá závorka + Procento + tečka + + Křížek + Znak měny (libra) + Otazník + Uvozovky + Registrovaná ochranná známka + Středník + Lomítko + Mezerník + Levá hranatá závorka + Pravá hranatá závorka + Odmocnina + Ochranná známka + Podtržítko + Svislá čára + Jen + Znak logického záporu + Přerušená svislá čára + Znak mikro + Téměř se rovná + Nerovná se + Znak měny + Znak oddílu + Šipka nahoru + Šipka vlevo + Rupie + Černé srdce + Tilda + Znak rovná se + Znak měny (won) + Znak odkazu + Prázdná hvězda + Černá hvězda + Prázdný symbol srdce + Prázdný kruh + Černá tečka + Tečka v kruhu + Dva soustředné kruhy + Prázdný symbol tref + Prázdný symbol pik + Prázdný symbol ukazováčku doleva + Prázdný symbol ukazováčku doprava + Kruh s levou polovinou černou + Kruh s pravou polovinou černou + Prázdný čtverec + Černý čtverec + Prázdný trojúhelník šipkou nahoru + Prázdný trojúhelník špičkou dolů + Prázdný trojúhelník špičkou doleva + Prázdný trojúhelník špičkou doprava + Prázdný symbol kár + Čtvrtinová nota + Osminová nota + Šestnáctinové noty spojené trámcem + Symbol ženy + Symbol muže + Levá plná čočkovitá závorka + Pravá plná čočkovitá závorka + Levá rohová závorka + Pravá rohová závorka + Šipka doprava + Šipka dolů + Znaménko plus minus + Litr + Stupeň Celsia + Stupeň Fahrenheita + Přibližně rovno + Integrál + Matematická levá lomená závorka + Matematická pravá lomená závorka + Poštovní značka + Černý trojúhelník ukazující nahoru + Černý trojúhelník ukazující dolů + Černý kosočtverec + Středová tečka katakany s poloviční šířkou + Malý černý čtverec + Levá francouzská uvozovka + Pravá francouzská uvozovka + Obrácený vykřičník + Obrácený otazník + Znak měny (won) + Čárka v plné šířce + Vykřičník v plné šířce + Ideografická tečka + Otazník v plné šířce + Středová tečka + Pravé dvojité uvozovky + Ideografická čárka + Dvojtečka v plné šířce + Středník v plné šířce + Ampersand v plné šířce + Stříška v plné šířce + Vlnovka v plné šířce + Levé dvojité uvozovky + Levá závorka v plné šířce + Pravá závorka v plné šířce + Hvězdička v plné šířce + Podtržítko v plné šířce + Pravé jednoduché uvozovky + Složená levá závorka v plné šířce + Složená pravá závorka v plné šířce + Symbol menší než v plné šířce + Symbol větší než v plné šířce + Levé jednoduché uvozovky + diff --git a/utils/src/main/res/values-da/strings.xml b/utils/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..f785f23 --- /dev/null +++ b/utils/src/main/res/values-da/strings.xml @@ -0,0 +1,50 @@ + + + Tegn %1$d til %2$d + Tegnet %1$d + unavngivet + kopieret, %1$s + stort %1$s + %1$d %2$s + Bruger %1$s + Tryk på en tastekombination for at oprette en ny genvej. Den skal mindst indeholde ALT- eller Ctrl-tasten. + Tryk på tastekombinationen med ændringstasten %1$s for at angive en ny genvej. + Ikke tildelt + Shift + Alt + Ctrl + Søg + Højrepil + Venstrepil + Pil op + Pil ned + Standard + Tegn + Ord + Linjer + Afsnit + Vinduer + Orienteringspunkter + Overskrifter + Lister + Link + Kontrolelementer + Særligt indhold + Overskrifter + Kontrolelementer + Links + Integreret billede i %1$s + %1$s øverst, %2$s nederst + %1$s til venstre, %2$s til højre + %1$s til højre, %2$s til venstre + Viser element %1$d til %2$d ud af %3$d. + Viser element %1$d ud af %2$d. + Side %1$d af %2$d + %1$d af %2$d + %1$s (%2$s) + Luk + Viser %1$s + tastaturet er skjult + Talefeedback er aktiveret + Talefeedback er deaktiveret + diff --git a/utils/src/main/res/values-da/strings_symbols.xml b/utils/src/main/res/values-da/strings_symbols.xml new file mode 100644 index 0000000..b5aa796 --- /dev/null +++ b/utils/src/main/res/values-da/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Og-tegn + Mindre end-tegn + Større end-tegn + Asterisk + Snabel-a + Omvendt skråstreg + Punkttegn + Cirkumfleks + Centtegn + Kolon + Komma + Ophavsret + Venstre krøllet parentes + Højre krøllet parentes + Gradtegn + Divisionstegn + Dollartegn + Ellipse + Lang tankestreg + Kort tankestreg + Euro + Udråbstegn + Accent grave + Tankestreg + Lave dobbelte anførselstegn + Multiplikationstegn + Ny linje + Afsnitstegn + Venstre parentes + Højre parentes + Procent + Punktum + pi + Pund + Pundtegn + Spørgsmålstegn + Citationstegn + Registreret varemærke + Semikolon + Skråstreg + Mellemrum + Venstre firkantet parentes + Højre firkantet parentes + Kvadratrod + Varemærke + Understregning + Lodret linje + Yen + Ikke-tegn + Brudt lodret streg + Mikrotegn + Næsten lig med + Ikke lig med + Valutategn + Paragraftegn + Opadvendt pil + Venstrevendt pil + Rupi + Sort hjerte + Tilde + Lighedstegn + Won-tegn + Henvisningstegn + Hvid stjerne + Sort stjerne + Hvidt hjerte + Hvid cirkel + Sort cirkel + Solsymbol + Målskive + Hvid klør + Hvid spar + Hvidt venstrevendt indeks + Hvidt højrevendt indeks + Cirkel med venstre halvdel i sort + Cirkel med højre halvdel i sort + Hvid firkant + Sort firkant + Hvid opadvendt trekant + Hvid nedadvendt trekant + Hvid venstrevendt trekant + Hvid højrevendt trekant + Hvid rombe + Fjerdedelsnode + Ottendedelsnode + Sammenbundne sekstendedelsnoder + Kvindesymbol + Mandesymbol + Venstre sort linseformet parentes + Højre sort linseformet parentes + Venstre hjørneparentes + Højre hjørneparentes + Højrepil + Pil ned + Plus- og minustegn + Liter + Celsiusgrad + Fahrenheit-grad + Svarer tilnærmelsesvis til + Integral + Matematisk venstre vinkelparentes + Matematisk højre vinkelparentes + Posttegn + Sort trekant, der peger opad + Sort trekant, der peger nedad + Sort diamant + Halvhøjt katakana-punkt i halv bredde + Lille sort firkant + Dobbelt venstre vinkelparentes + Dobbelt højre vinkelparentes + Omvendt udråbstegn + Omvendt spørgsmålstegn + Won-tegn + Komma i fuld bredde + Udråbstegn i fuld bredde + ideografisk punktum + Spørgsmålstegn i fuld bredde + Halvhøjt punkt + Dobbelt højre anførselstegn + ideografisk komma + Kolon i fuld bredde + Semikolon i fuld bredde + Og-tegn i fuld bredde + Cirkumfleks i fuld bredde + Tilde i fuld bredde + Dobbelt venstre anførselstegn + Venstreparentes i fuld bredde + Højreparentes i fuld bredde + Stjerne i fuld bredde + Understregning i fuld bredde + Enkelt højre anførselstegn + Venstre krøllet parentes i fuld bredde + Højre krøllet parentes i fuld bredde + Mindre end-tegn i fuld bredde + Større end-tegn i fuld bredde + Enkelt venstre anførselstegn + diff --git a/utils/src/main/res/values-de/strings.xml b/utils/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..5c548bb --- /dev/null +++ b/utils/src/main/res/values-de/strings.xml @@ -0,0 +1,50 @@ + + + Zeichen %1$d bis %2$d + Zeichen %1$d + Ohne Titel + Kopiert, %1$s + großes %1$s + %1$d %2$s + %1$s wird verwendet. + Drücke die Tastenkombination, um eine neue Kombination festzulegen. Diese muss mindestens eine der Tasten \"Alt\" oder \"Strg\" umfassen. + Drücke die Tastenkombination mit %1$s, um die neue Kombination festzulegen. + Nicht zugewiesen + Umschalttaste + Alt + Strg + Suche + Pfeil nach rechts + Pfeil nach links + Pfeil nach oben + Abwärtspfeil + Standard + Zeichen + Wörter + Zeilen + Absätze + Fenster + Markierungen + Überschriften + Listen + Links + Steuerungselemente + Spezieller Inhalt + Überschriften + Steuerelemente + Links + %1$s – Bild im Bild + %1$s oben, %2$s unten + %1$s links, %2$s rechts + %1$s rechts, %2$s links + Elemente %1$d bis %2$d von insgesamt %3$d werden angezeigt. + Element %1$d von %2$d wird angezeigt. + Seite %1$d von %2$d + %1$d von %2$d + %1$s (%2$s) + Beenden + %1$s wird angezeigt + Tastatur ausgeblendet + Gesprochenes Feedback ist aktiviert + Gesprochenes Feedback ist deaktiviert + diff --git a/utils/src/main/res/values-de/strings_symbols.xml b/utils/src/main/res/values-de/strings_symbols.xml new file mode 100644 index 0000000..17baff9 --- /dev/null +++ b/utils/src/main/res/values-de/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostroph + Kaufmännisches Und + Kleiner-als-Zeichen + Größer-als-Zeichen + Sternchen + At + Umgekehrter Schrägstrich (Backslash) + Aufzählungszeichen + Caret + Cent-Zeichen + Doppelpunkt + Komma + Copyright + Geschweifte Klammer links + Geschweifte Klammer rechts + Gradzeichen + Divisionszeichen + Dollarzeichen + Auslassungszeichen + Langer Gedankenstrich + Gedankenstrich + Euro + Ausrufezeichen + Accent grave + Bindestrich + Anführungszeichen unten + Multiplikationszeichen + Neue Zeile + Absatzzeichen + Linke Klammer + Rechte Klammer + Prozent + Punkt + Pi + Raute + Währungszeichen für britische Pfund + Fragezeichen + Anführungszeichen + Eingetragene Marke + Semikolon + Schrägstrich + Leertaste + Eckige Klammer links + Eckige Klammer rechts + Quadratwurzel + Marke + Unterstrich + Senkrechter Strich + Yen + \"Nicht\"-Zeichen + Unterbrochener Strich + Mikrozeichen + Fast-gleich-Zeichen + Ist-nicht-gleich-Zeichen + Währungssymbol + Paragraphenzeichen + Pfeil nach oben + Pfeil nach links + Rupie + Schwarzes Herz + Tilde + Gleichheitszeichen + Währungszeichen für südkoreanische Won + Referenzzeichen + Weißer Stern + Schwarzer Stern + Weißes Herz + Weißer Kreis + Schwarzer Kreis + Sonnensymbol + Zielscheibe + Weißes Kreuz + Weißes Pik + Weißer nach links weisender Zeigefinger + Weißer nach rechts weisender Zeigefinger + Kreis mit schwarzer linker Hälfte + Kreis mit schwarzer rechter Hälfte + Weißes Quadrat + Schwarzes Quadrat + Weißes Dreieck nach oben + Weißes Dreieck nach unten + Weißes Dreieck nach links + Weißes Dreieck nach rechts + Weiße Raute + Viertelnote + Achtelnote + Sechzehntelnote verbunden + Symbol für das weibliche Geschlecht + Symbol für das männliche Geschlecht + Schwarze bikonvexe Klammer links + Schwarze bikonvexe Klammer rechts + Winkelklammer links + Winkelklammer rechts + Rechtspfeil + Abwärtspfeil + Plus-/Minuszeichen + Liter + Grad Celsius + Grad Fahrenheit + Entspricht ungefähr + Integral + Mathematische linke Spitzklammer + Mathematische rechte Spitzklammer + Poststempel + Nach oben zeigendes schwarzes Dreieck + Nach unten zeigendes schwarzes Dreieck + Schwarze Raute + Katakana-Mittelpunkt (halbe Breite) + Kleines schwarzes Quadrat + Doppelte spitze Klammer links + Doppelte spitze Klammer rechts + Umgekehrtes Ausrufezeichen + Umgekehrtes Fragezeichen + Währungszeichen für südkoreanische Won + Komma (volle Breite) + Ausrufezeichen (volle Breite) + Ideografischer Punkt + Fragezeichen (volle Breite) + Mittelpunkt + Doppeltes Anführungszeichen rechts + Ideografisches Komma + Doppelpunkt (volle Breite) + Semikolon (volle Breite) + Und-Zeichen (volle Breite) + Zirkumflex (volle Breite) + Tilde (volle Breite) + Doppeltes Anführungszeichen links + Runde linke Klammer (volle Breite) + Runde rechte Klammer (volle Breite) + Sternchen (volle Breite) + Unterstrich (volle Breite) + Einfaches Anführungszeichen rechts + Geschweifte Klammer links (volle Breite) + Geschweifte Klammer rechts (volle Breite) + Kleiner-gleich-Zeichen (volle Breite) + Größer-gleich-Zeichen (volle Breite) + Einfaches Anführungszeichen links + diff --git a/utils/src/main/res/values-el/strings.xml b/utils/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..162afa0 --- /dev/null +++ b/utils/src/main/res/values-el/strings.xml @@ -0,0 +1,50 @@ + + + Χαρακτήρες %1$d έως %2$d + Χαρακτήρας %1$d + χωρίς τίτλο + έγινε αντιγραφή, %1$s + κεφαλαίο %1$s + %1$d %2$s + Χρήση %1$s + Πατήστε το συνδυασμό πλήκτρων για να ορίσετε μια νέα συντόμευση. Πρέπει να περιέχει τουλάχιστον το πλήκτρο ALT ή Control. + Πατήστε τον συνδυασμό πλήκτρων με το πλήκτρο τροποποίησης %1$s για να ορίσετε νέα συντόμευση. + Χωρίς αντιστοιχία + Shift + Alt + Ctrl + Αναζήτηση + Δεξί βέλος + Αριστερό βέλος + Επάνω βέλος + Κάτω βέλος + Προεπιλογή + Χαρακτήρες + Λέξεις + Γραμμές + Παράγραφοι + Παράθυρα + Ορόσημα + Επικεφαλίδες + Λίστες + Σύνδεσμοι + Στοιχεία ελέγχου + Ειδικό περιεχόμενο + Επικεφαλίδες + Στοιχεία ελέγχου + Σύνδεσμοι + %1$s picture-in-picture + %1$s επάνω, %2$s κάτω + %1$s αριστερά, %2$s δεξιά + %1$s δεξιά, %2$s αριστερά + Εμφάνιση στοιχείων %1$d έως %2$d από %3$d. + Εμφάνιση στοιχείου %1$d από %2$d. + Σελίδα %1$d από %2$d + %1$d από %2$d + %1$s (%2$s) + Έξοδος + Εμφάνιση %1$s + απόκρυψη πληκτρολογίου + Τα εκφωνημένα σχόλια είναι ενεργά + Τα εκφωνημένα σχόλια είναι ανενεργά + diff --git a/utils/src/main/res/values-el/strings_symbols.xml b/utils/src/main/res/values-el/strings_symbols.xml new file mode 100644 index 0000000..81fcd64 --- /dev/null +++ b/utils/src/main/res/values-el/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Απόστροφος + Συμπλεκτικό σύμβολο + Σύμβολο \"μικρότερο από\" + Σύμβολο \"μεγαλύτερο από\" + Αστερίσκος + Παπάκι + Ανάστροφη κάθετος + Κουκκίδα + Σύμβολο εκθέτη + Σύμβολο σεντ + Άνω και κάτω τελεία + Κόμμα + Πνευματικά δικαιώματα + Αριστερό άγκιστρο + Δεξιό άγκιστρο + Σύμβολο μοίρας + Σύμβολο διαίρεσης + Σύμβολο δολαρίου + Αποσιωπητικά + Μεγάλη παύλα + Παύλα + Σύμβολο ευρώ + Θαυμαστικό + Βαρεία + Παύλα + Χαμηλά διπλά εισαγωγικά + Σύμβολο πολλαπλασιασμού + Νέα γραμμή + Σημάδι παραγράφου + Αριστερή παρένθεση + Δεξιά παρένθεση + Σύμβολο ποσοστού + Τελεία + πι + Δίεση + Σύμβολο λίρας στερλίνας + Ερωτηματικό + Εισαγωγικά + Σήμα κατατεθέν + Άνω τελεία + Κάθετος + Πλήκτρο διαστήματος + Αριστερή αγκύλη + Δεξιά αγκύλη + Τετραγωνική ρίζα + Εμπορικό σήμα + Σύμβολο υπογράμμισης + Κάθετη γραμμή + Σύμβολο γιέν + Σύμβολο Not + Διακεκομμένη κάθετο + Σύμβολο Micro + Σχεδόν ίσον με + Δεν ισούται με + Σύμβολο νομίσματος + Σύμβολο παραγράφου + Βέλος προς τα επάνω + Βέλος προς τα αριστερά + Σύμβολο ρουπίας + Μαύρη καρδιά + Περισπωμένη + Σύμβολο ίσον + Σύμβολο νομίσματος ουόν + Σημείο παραπομπής + Λευκό αστέρι + Μαύρο αστέρι + Λευκή καρδιά + Λευκός κύκλος + Μαύρος κύκλος + Ηλιακό σύμβολο + Γεωμετρικό σύμβολο + Λευκά χαρτιά σπαθιά + Λευκά χαρτιά σπαθιά + Λευκός δείκτης προς τα αριστερά + Λευκός δείκτης προς τα δεξιά + Κύκλος με μαύρο αριστερό μισό + Κύκλος με μαύρο δεξιό μισό + Λευκό τετράγωνο + Μαύρο τετράγωνο + Λευκό τρίγωνο με κορυφή προς τα επάνω + Λευκό τρίγωνο προς τα κάτω + Λευκό τρίγωνο προς τα αριστερά + Λευκό τρίγωνο με κορυφή δεξιά + Λευκό διαμάντι + Τέταρτο νότας + Οκτέτο νότας + Δέσμη δεκαέξι νότων + Θηλυκό σύμβολο + Αρσενικό σύμβολο + Αριστερή μαύρη φακοειδής παρένθεση + Δεξιά μαύρη φακοειδής παρένθεση + Αριστερή γωνιακή αγκύλη + Δεξιά γωνιακή αγκύλη + Βέλος προς τα δεξιά + Βέλος προς τα κάτω + Σύμβολο συν-πλην + Λίτρο + Βαθμός Κελσίου + Βαθμός Φαρενάιτ + Περίπου ίσον + Ολοκλήρωμα + Μαθηματική αριστερή αγκύλη με γωνία + Μαθηματική δεξιά αγκύλη με γωνία + Ταχυδρομικό σήμα + Μαύρο τρίγωνο που δείχνει προς τα επάνω + Μαύρο τρίγωνο που δείχνει προς τα κάτω + Μαύρο καρό + Κατακάνα μισογεμάτη μεσαία γραμμή + Μικρός μαύρος κύβος + Αριστερή διπλή αγκύλη + Δεξιά διπλή αγκύλη + Αντίστροφο θαυμαστικό + Αντίστροφο ερωτηματικό + Σύμβολο νομίσματος ουόν + Κόμμα πλήρους πλάτους + Θαυμαστικό πλήρους πλάτους + Ιδεογραφική τελεία + Ερωτηματικό πλήρους πλάτους + Άνω τελεία + Δεξιά διπλά εισαγωγικά + Ιδεογραφικό κόμμα + Άνω και κάτω τελεία πλήρους πλάτους + Άνω τελεία πλήρους πλάτους + Συμπλεκτικό σύμβολο πλήρους πλάτους + Σιρκουμφλέξ πλήρους πλάτους + Περισπωμένη πλήρους πλάτους + Αριστερά διπλά εισαγωγικά + Αριστερή παρένθεση πλήρους πλάτους + Δεξιά παρένθεση πλήρους πλάτους + Αστερίσκος πλήρους πλάτους + Σύμβολο υπογράμμισης πλήρους πλάτους + Δεξιά μονά εισαγωγικά + Αριστερή αγκύλη πλήρους πλάτους + Δεξιά αγκύλη πλήρους πλάτους + Σύμβολο \"μικρότερο από\" πλήρους πλάτους + Σύμβολο \"μεγαλύτερο από\" πλήρους πλάτους + Αριστερά μονά εισαγωγικά + diff --git a/utils/src/main/res/values-en-rAU/strings.xml b/utils/src/main/res/values-en-rAU/strings.xml new file mode 100644 index 0000000..fb5f30a --- /dev/null +++ b/utils/src/main/res/values-en-rAU/strings.xml @@ -0,0 +1,50 @@ + + + Characters %1$d to %2$d + Character %1$d + untitled + copied, %1$s + capital %1$s + %1$d %2$s + Using %1$s + Press key combination to set new shortcut. It must contain at least ALT or Control key. + Press key combination with %1$s modifier key to set new shortcut. + Unassigned + Shift + Alt + Ctrl + Search + Arrow Right + Arrow Left + Arrow Up + Arrow Down + Default + Characters + Words + Lines + Paragraphs + Windows + Landmarks + Headings + Lists + Links + Controls + Special content + Headings + Controls + Links + %1$s picture in picture + %1$s on top, %2$s on bottom + %1$s on left, %2$s on right + %1$s on right, %2$s on left + Showing items %1$d to %2$d of %3$d. + Showing item %1$d of %2$d. + Page %1$d of %2$d + %1$d of %2$d + %1$s (%2$s) + Exit + Showing %1$s + keyboard hidden + Spoken feedback is on + Spoken feedback is off + diff --git a/utils/src/main/res/values-en-rAU/strings_symbols.xml b/utils/src/main/res/values-en-rAU/strings_symbols.xml new file mode 100644 index 0000000..de865d4 --- /dev/null +++ b/utils/src/main/res/values-en-rAU/strings_symbols.xml @@ -0,0 +1,141 @@ + + + Apostrophe + Ampersand + Less than sign + Greater than sign + Asterisk + At + Back slash + Bullet + Caret/chevron + Cent sign + Colon + Comma + Copyright + Left curly bracket + Right curly bracket + Degree sign + Division sign + Dollar sign + Ellipsis + Em dash + En dash + Euro + Exclamation mark + Grave accent + Dash + Low double quote + Multiplication sign + New line + Paragraph mark + Left parenthesis + Right parenthesis + Percent + Full stop + Pi + Hash + + Pound currency sign + Question mark + Quote + Registered trademark + Semi-colon + Slash + Space + Left square bracket + Right square bracket + Square root + Trademark + Underscore + Vertical line + Yen + Not sign + Broken bar + Micro sign + Almost equal to + Not equal to + Currency sign + Section sign + Upwards arrow + Leftwards arrow + Rupee + Black Heart + tilde + Equal sign + Won currency sign + Reference Mark + White star + Black star + White Heart + White circle + Black circle + Solar symbol + Bullseye + White club suit + White spade suit + White left pointing index + White right pointing index + Circle with left half black + Circle with right half black + White square + Black square + White up pointing triangle + White down pointing triangle + White left pointing triangle + White right pointing triangle + White diamond + Quarter Note + Eighth Note + Beamed sixteenth notes + Female symbol + Male symbol + Left Black Lenticular Bracket + Right Black Lenticular Bracket + Left Corner Bracket + Right Corner Bracket + Rightwards Arrow + Downwards Arrow + Plus minus sign + Liter + Celsius degree + Fahrenheit degree + Approximately equals + Integral + Mathematical left-angle bracket + Mathematical right-angle bracket + Postmark + Black triangle pointing up + Black triangle pointing down + Black suit of diamonds + Halfwidth Katakana middle dot + Small black square + Left double angle bracket + Right double angle bracket + Inverted exclamation mark + Inverted question mark + Won currency sign + Full-width comma + Full-width exclamation mark + Ideographic full stop + Full-width question mark + Middle dot + Right double quotation mark + Ideographic comma + Full-width colon + Full-width semicolon + Full-width ampersand + Full-width circumflex + Full-width tilde + Left double quotation mark + Full-width left parenthesis + Full-width right parenthesis + Full-width asterisk + Full-width underscore + Right single quotation mark + Full-width left curly bracket + Full-width right curly bracket + Full-width less than sign + Full-width greater than sign + Left single quotation mark + diff --git a/utils/src/main/res/values-en-rGB/strings.xml b/utils/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..fb5f30a --- /dev/null +++ b/utils/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,50 @@ + + + Characters %1$d to %2$d + Character %1$d + untitled + copied, %1$s + capital %1$s + %1$d %2$s + Using %1$s + Press key combination to set new shortcut. It must contain at least ALT or Control key. + Press key combination with %1$s modifier key to set new shortcut. + Unassigned + Shift + Alt + Ctrl + Search + Arrow Right + Arrow Left + Arrow Up + Arrow Down + Default + Characters + Words + Lines + Paragraphs + Windows + Landmarks + Headings + Lists + Links + Controls + Special content + Headings + Controls + Links + %1$s picture in picture + %1$s on top, %2$s on bottom + %1$s on left, %2$s on right + %1$s on right, %2$s on left + Showing items %1$d to %2$d of %3$d. + Showing item %1$d of %2$d. + Page %1$d of %2$d + %1$d of %2$d + %1$s (%2$s) + Exit + Showing %1$s + keyboard hidden + Spoken feedback is on + Spoken feedback is off + diff --git a/utils/src/main/res/values-en-rGB/strings_symbols.xml b/utils/src/main/res/values-en-rGB/strings_symbols.xml new file mode 100644 index 0000000..de865d4 --- /dev/null +++ b/utils/src/main/res/values-en-rGB/strings_symbols.xml @@ -0,0 +1,141 @@ + + + Apostrophe + Ampersand + Less than sign + Greater than sign + Asterisk + At + Back slash + Bullet + Caret/chevron + Cent sign + Colon + Comma + Copyright + Left curly bracket + Right curly bracket + Degree sign + Division sign + Dollar sign + Ellipsis + Em dash + En dash + Euro + Exclamation mark + Grave accent + Dash + Low double quote + Multiplication sign + New line + Paragraph mark + Left parenthesis + Right parenthesis + Percent + Full stop + Pi + Hash + + Pound currency sign + Question mark + Quote + Registered trademark + Semi-colon + Slash + Space + Left square bracket + Right square bracket + Square root + Trademark + Underscore + Vertical line + Yen + Not sign + Broken bar + Micro sign + Almost equal to + Not equal to + Currency sign + Section sign + Upwards arrow + Leftwards arrow + Rupee + Black Heart + tilde + Equal sign + Won currency sign + Reference Mark + White star + Black star + White Heart + White circle + Black circle + Solar symbol + Bullseye + White club suit + White spade suit + White left pointing index + White right pointing index + Circle with left half black + Circle with right half black + White square + Black square + White up pointing triangle + White down pointing triangle + White left pointing triangle + White right pointing triangle + White diamond + Quarter Note + Eighth Note + Beamed sixteenth notes + Female symbol + Male symbol + Left Black Lenticular Bracket + Right Black Lenticular Bracket + Left Corner Bracket + Right Corner Bracket + Rightwards Arrow + Downwards Arrow + Plus minus sign + Liter + Celsius degree + Fahrenheit degree + Approximately equals + Integral + Mathematical left-angle bracket + Mathematical right-angle bracket + Postmark + Black triangle pointing up + Black triangle pointing down + Black suit of diamonds + Halfwidth Katakana middle dot + Small black square + Left double angle bracket + Right double angle bracket + Inverted exclamation mark + Inverted question mark + Won currency sign + Full-width comma + Full-width exclamation mark + Ideographic full stop + Full-width question mark + Middle dot + Right double quotation mark + Ideographic comma + Full-width colon + Full-width semicolon + Full-width ampersand + Full-width circumflex + Full-width tilde + Left double quotation mark + Full-width left parenthesis + Full-width right parenthesis + Full-width asterisk + Full-width underscore + Right single quotation mark + Full-width left curly bracket + Full-width right curly bracket + Full-width less than sign + Full-width greater than sign + Left single quotation mark + diff --git a/utils/src/main/res/values-en-rIN/strings.xml b/utils/src/main/res/values-en-rIN/strings.xml new file mode 100644 index 0000000..fb5f30a --- /dev/null +++ b/utils/src/main/res/values-en-rIN/strings.xml @@ -0,0 +1,50 @@ + + + Characters %1$d to %2$d + Character %1$d + untitled + copied, %1$s + capital %1$s + %1$d %2$s + Using %1$s + Press key combination to set new shortcut. It must contain at least ALT or Control key. + Press key combination with %1$s modifier key to set new shortcut. + Unassigned + Shift + Alt + Ctrl + Search + Arrow Right + Arrow Left + Arrow Up + Arrow Down + Default + Characters + Words + Lines + Paragraphs + Windows + Landmarks + Headings + Lists + Links + Controls + Special content + Headings + Controls + Links + %1$s picture in picture + %1$s on top, %2$s on bottom + %1$s on left, %2$s on right + %1$s on right, %2$s on left + Showing items %1$d to %2$d of %3$d. + Showing item %1$d of %2$d. + Page %1$d of %2$d + %1$d of %2$d + %1$s (%2$s) + Exit + Showing %1$s + keyboard hidden + Spoken feedback is on + Spoken feedback is off + diff --git a/utils/src/main/res/values-en-rIN/strings_symbols.xml b/utils/src/main/res/values-en-rIN/strings_symbols.xml new file mode 100644 index 0000000..de865d4 --- /dev/null +++ b/utils/src/main/res/values-en-rIN/strings_symbols.xml @@ -0,0 +1,141 @@ + + + Apostrophe + Ampersand + Less than sign + Greater than sign + Asterisk + At + Back slash + Bullet + Caret/chevron + Cent sign + Colon + Comma + Copyright + Left curly bracket + Right curly bracket + Degree sign + Division sign + Dollar sign + Ellipsis + Em dash + En dash + Euro + Exclamation mark + Grave accent + Dash + Low double quote + Multiplication sign + New line + Paragraph mark + Left parenthesis + Right parenthesis + Percent + Full stop + Pi + Hash + + Pound currency sign + Question mark + Quote + Registered trademark + Semi-colon + Slash + Space + Left square bracket + Right square bracket + Square root + Trademark + Underscore + Vertical line + Yen + Not sign + Broken bar + Micro sign + Almost equal to + Not equal to + Currency sign + Section sign + Upwards arrow + Leftwards arrow + Rupee + Black Heart + tilde + Equal sign + Won currency sign + Reference Mark + White star + Black star + White Heart + White circle + Black circle + Solar symbol + Bullseye + White club suit + White spade suit + White left pointing index + White right pointing index + Circle with left half black + Circle with right half black + White square + Black square + White up pointing triangle + White down pointing triangle + White left pointing triangle + White right pointing triangle + White diamond + Quarter Note + Eighth Note + Beamed sixteenth notes + Female symbol + Male symbol + Left Black Lenticular Bracket + Right Black Lenticular Bracket + Left Corner Bracket + Right Corner Bracket + Rightwards Arrow + Downwards Arrow + Plus minus sign + Liter + Celsius degree + Fahrenheit degree + Approximately equals + Integral + Mathematical left-angle bracket + Mathematical right-angle bracket + Postmark + Black triangle pointing up + Black triangle pointing down + Black suit of diamonds + Halfwidth Katakana middle dot + Small black square + Left double angle bracket + Right double angle bracket + Inverted exclamation mark + Inverted question mark + Won currency sign + Full-width comma + Full-width exclamation mark + Ideographic full stop + Full-width question mark + Middle dot + Right double quotation mark + Ideographic comma + Full-width colon + Full-width semicolon + Full-width ampersand + Full-width circumflex + Full-width tilde + Left double quotation mark + Full-width left parenthesis + Full-width right parenthesis + Full-width asterisk + Full-width underscore + Right single quotation mark + Full-width left curly bracket + Full-width right curly bracket + Full-width less than sign + Full-width greater than sign + Left single quotation mark + diff --git a/utils/src/main/res/values-es/strings.xml b/utils/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..e7c9a0f --- /dev/null +++ b/utils/src/main/res/values-es/strings.xml @@ -0,0 +1,50 @@ + + + Caracteres del %1$d al %2$d + Carácter %1$d + sin título + Se ha copiado %1$s + %1$s mayúscula + %1$d %2$s + Estás usando %1$s + Pulsa una combinación de teclas para establecer un nuevo método abreviado de teclado. Debe contener como mínimo la tecla Alt o la tecla Control. + Pulsa cualquier combinación de teclas con la tecla modificadora %1$s para establecer un nuevo método abreviado. + Sin asignar + Mayús + Alt + Ctrl + Buscar + Flecha hacia la derecha + Flecha hacia la izquierda + Flecha hacia arriba + Flecha hacia abajo + Predeterminado + Caracteres + Palabras + Líneas + Párrafos + Ventanas + Puntos de referencia + Encabezados + Listas + Enlaces + Controles + Contenido especial + Encabezados + Controles + Enlaces + Imagen en imagen de %1$s + %1$s aparece en la parte superior y %2$s en la inferior + %1$s aparece a la izquierda y %2$s a la derecha + %1$s aparece a la derecha y %2$s a la izquierda + Mostrando elementos del %1$d al %2$d de %3$d + Mostrando elemento %1$d de %2$d + Página %1$d de %2$d + %1$d de %2$d + %1$s (%2$s) + Salir + Mostrando %1$s + teclado oculto + Los mensajes de voz están activados + Los mensajes de voz están desactivados + diff --git a/utils/src/main/res/values-es/strings_symbols.xml b/utils/src/main/res/values-es/strings_symbols.xml new file mode 100644 index 0000000..616b1d0 --- /dev/null +++ b/utils/src/main/res/values-es/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apóstrofe + Ampersand + Signo de menor que + Signo de mayor que + Asterisco + Arroba + Barra invertida + Viñeta + Acento circunflejo + Símbolo de céntimo + Dos puntos + Coma + Copyright + Llave de apertura + Llave de cierre + Signo de grado + Signo de división + Símbolo del dólar + Puntos suspensivos + Raya + Guion corto + Euro + Signo de exclamación + Acento grave + Guion + Comillas dobles bajas + Signo de multiplicación + Nueva línea + Marca de párrafo + Paréntesis de apertura + Paréntesis de cierre + Porcentaje + Punto + Pi + Almohadilla + Símbolo de la libra esterlina + Signo de interrogación + Comillas + Marca comercial registrada + Punto y coma + Barra + Espacio + Corchete de apertura + Corchete de cierre + Raíz cuadrada + Marca comercial + Guion bajo + Barra vertical + Yen + Signo de negación + Barra partida + Símbolo de micra + Casi igual a + No es igual a + Símbolo de moneda + Signo de sección + Flecha hacia arriba + Flecha hacia la izquierda + Rupia + Palo de corazones negro + Virgulilla + Signo de igual + Símbolo del won surcoreano + Marca de referencia + Estrella blanca + Estrella negra + Palo de corazones blanco + Círculo blanco + Círculo negro + Símbolo solar + Diana + Palo de tréboles blanco + Palo de picas blanco + Índice blanco hacia la izquierda + Índice blanco hacia la derecha + Círculo con la mitad izquierda negra + Círculo con la mitad derecha negra + Cuadrado blanco + Cuadrado negro + Triángulo blanco hacia arriba + Triángulo blanco hacia abajo + Triángulo blanco hacia la izquierda + Triángulo blanco hacia la derecha + Diamante blanco + Nota negra + Corchea + Semicorcheas ligadas + Símbolo femenino + Símbolo masculino + Paréntesis negro lenticular de apertura + Paréntesis negro lenticular de cierre + Paréntesis esquina de apertura + Paréntesis esquina de cierre + Flecha hacia la derecha + Flecha hacia abajo + Signo de más menos + Litro + Grados Celsius + Grados Fahrenheit + Aproximación + Integral + Paréntesis angular de apertura en matemáticas + Paréntesis angular de cierre en matemáticas + Marca de postal + Triángulo negro señalando hacia arriba + Triángulo negro señalando hacia abajo + Rombo negro + Punto medio katakana de ancho medio + Cuadrado negro pequeño + Paréntesis angular de apertura doble + Paréntesis angular de cierre doble + Signo de exclamación de apertura + Signo de interrogación de apertura + Símbolo del won surcoreano + Coma de ancho total + Signo de exclamación de ancho total + Punto ideográfico + Signo de interrogación de ancho total + Punto medio + Comillas dobles de cierre + Coma ideográfica + Dos puntos de ancho total + Punto y coma de ancho total + Separador et de ancho total + Circunflejo de ancho total + Tilde de ancho total + Comillas dobles de apertura + Paréntesis de ancho total de apertura + Paréntesis de ancho total de cierre + Asterisco de ancho total + Guion bajo de ancho total + Comilla simple de cierre + Llave de apertura de ancho total + Llave de cierre de ancho total + Signo menor que de ancho total + Signo mayor que de ancho total + Comilla simple de apertura + diff --git a/utils/src/main/res/values-et/strings.xml b/utils/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..f07abbc --- /dev/null +++ b/utils/src/main/res/values-et/strings.xml @@ -0,0 +1,50 @@ + + + Tähemärgid %1$d kuni %2$d + Tähemärk %1$d + pealkirjata + kopeeritud, %1$s + suurtäht %1$s + %1$d %2$s + Kasutatakse rakendust %1$s + Uue otsetee määramiseks vajutage klahvikombinatsiooni. See peab sisaldama klahvi ALT või juhtklahvi. + Uue otsetee määramiseks vajutage klahvikombinatsiooni koos muuteklahviga %1$s. + Määramata + Tõstuklahv + Alt + Ctrl + Otsing + Paremnool + Vasaknool + Ülesnool + Allanool + Vaikeseade + Tähemärgid + Sõnad + Read + Lõigud + Aknad + Orientiirid + Pealkirjad + Loendid + Lingid + Juhtelemendid + Erisisu + Pealkirjad + Juhtelemendid + Lingid + %1$s on režiimis Pilt pildis + %1$s on üleval, %2$s on all + %1$s on vasakul, %2$s on paremal + %1$s on paremal, %2$s on vasakul + Kuvatakse üksused %1$d kuni %2$d %3$d-st. + Kuvatakse üksus %1$d %2$d-st. + Leht %1$d/%2$d + %1$d/%2$d + %1$s (%2$s) + Välju + Kuvatud on %1$s + klaviatuur on peidetud + Suuline tagasiside on sisse lülitatud + Suuline tagasiside on välja lülitatud + diff --git a/utils/src/main/res/values-et/strings_symbols.xml b/utils/src/main/res/values-et/strings_symbols.xml new file mode 100644 index 0000000..2eb86ac --- /dev/null +++ b/utils/src/main/res/values-et/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Ülakoma + Ampersand + Märk Väiksem kui + Märk Suurem kui + Tärn + Ätt + Kurakaldkriips + Täpploend + Sisestusmärk + Sendi märk + Koolon + Koma + Autoriõigus + Vasak looksulg + Parem looksulg + Kraadi märk + Jagamismärk + Dollarimärk + Kolmikpunkt + Emm-kriips + Enn-kriips + Euro + Hüüumärk + Graavis + Sidekriips + Alumised jutumärgid + Korrutusmärk + Uus rida + Lõigumärk + Vasak ümarsulg + Parem ümarsulg + Protsent + Punkt + Pii + Nael + Naelsterlingi märk + Küsimärk + Tsitaat + Registreeritud kaubamärk + Semikoolon + Kaldkriips + Tühik + Vasak nurksulg + Parem nurksulg + Ruutjuur + Kaubamärk + Alljoon + Vertikaaljoon + Jeen + Ei-märk + Katkega püstkriips + Mikrotähis + Ligikaudu võrdne + Pole võrdne + Valuutamärk + Paragrahvimärk + Ülesnool + Vasaknool + Ruupia + Must ärtu + Tilde + Võrdusmärk + Vonni märk + Viitemärk + Valge täht + Must täht + Valge ärtu + Valge ring + Must ring + Päikese sümbol + Märklaua keskkoht + Valge risti + Valge poti + Valge vasakule osutav sõrm + Valge paremale osutav sõrm + Ring, mille vasak pool on must + Ring, mille parem pool on must + Valge ruut + Must ruut + Valge üles osutav kolmnurk + Valge alla osutav kolmnurk + Valge vasakule osutav kolmnurk + Valge paremale osutav kolmnurk + Valge ruutu + Veerandnoot + Kaheksandiknoot + Ühendatud kuueteistkümnendiknoodid + Naise sümbol + Mehe sümbol + Vasak must läätsekujuline sulg + Parem must läätsekujuline sulg + Vasak nurksulg + Parem nurksulg + Paremnool + Allanool + Pluss-miinusmärk + Liiter + Celsiuse kraadimärk + Fahrenheiti kraadimärk + Võrdub umbes + Integraal + Matemaatiline vasak nurksulg + Matemaatiline parem nurksulg + Postmark + Must ülessuunatud kolmnurk + Must allasuunatud kolmnurk + Must romb + Katakana poole laiusega keskmine punkt + Väike must ruut + Vasak topeltnurksulg + Parem topeltnurksulg + Pöördhüüumärk + Pöördküsimärk + Vonni märk + Koma täislaiuses + Hüüumärk täislaiuses + Ideograafiline punk + Küsimärk täislaiuses + Keskmine punkt + Parem jutumärk + Ideograafiline koma + Koolon täislaiuses + Semikoolon täislaiuses + Ampersand täislaiuses + Tsirkumfleks täislaiuses + Tilde täislaiuses + Vasak jutumärk + Vasaksulg täislaiuses + Paremsulg täislaiuses + Tärn täislaiuses + Alljoon täislaiuses + Parem ülakoma + Vasak looksulg täislaiuses + Parem looksulg täislaiuses + Märk Väiksem kui täislaiuses + Märk Suurem kui täislaiuses + Vasak ülakoma + diff --git a/utils/src/main/res/values-eu/strings.xml b/utils/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..0542907 --- /dev/null +++ b/utils/src/main/res/values-eu/strings.xml @@ -0,0 +1,50 @@ + + + %1$d eta %2$d arteko karaktereak + %1$d karakterea + izengabea + kopiatu da %1$s + %1$s maiuskula + %1$d %2$s + %1$s erabilita + Sakatu teklen konbinazioa beste lasterbide bat sortzeko. Gutxienez ALT edo Kontrol tekla izan behar du. + Beste lasterbide bat ezartzeko, sakatu teklen konbinazioa %1$s tekla aldatzailearekin batera. + Esleitu gabe + Maius + Alt + Ktrl + Bilatu + Eskuinera gezia + Ezkerrera gezia + Gora gezia + Behera gezia + Lehenetsia + Karaktereak + Hitzak + Lerroak + Paragrafoak + Leihoak + Mugarriak + Goiburuak + Zerrendak + Estekak + Kontrolatzeko aukerak + Eduki berezia + Goiburuak + Kontrolatzeko aukerak + Estekak + Pantaila txiki gainjarrian dago %1$s + Goialdean %1$s dago eta behealdean, berriz, %2$s + Ezkerrean %1$s dago eta eskuinean, berriz, %2$s + Eskuinean %1$s dago eta ezkerrean, berriz, %2$s + %1$d-%2$d/%3$d elementuak erakusten. + %1$d/%2$d elementua erakusten. + %2$d orritik %1$dgarrena + %2$d orritik %1$dgarrena + %1$s (%2$s) + Irten + %1$s ikusgai dago + teklatua ezkutatuta + Aktibatu dira ahozko argibideak + Desaktibatu dira ahozko argibideak + diff --git a/utils/src/main/res/values-eu/strings_symbols.xml b/utils/src/main/res/values-eu/strings_symbols.xml new file mode 100644 index 0000000..b115353 --- /dev/null +++ b/utils/src/main/res/values-eu/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrofoa + Et ikurra + Baino gutxiago ikurra + \"Baino gehiago\" ikurra + Izartxoa + A bildua + Alderantzizko barra + Buleta + Azentu zirkunflexua + Zentimoaren ikurra + Bi puntu + Koma + Copyrighta + Ezkerreko giltza + Eskuineko giltza + Graduen ikurra + Zatiketa-ikurra + Dolarraren ikurra + Etenpuntuak + Marra luzea + Marra laburra + Euroa + Harridura-ikurra + Azentu kamutsa + Marra + Komatxo baxuak + Biderketa-ikurra + Lerro berria + Paragrafo-marka + Ezkerreko parentesia + Eskuineko parentesia + Ehunekoa + Puntua + Pi + Traola + Libera dibisaren ikurra + Galdera-ikurra + Komatxoak + Marka erregistratua + Puntu eta koma + Barra + Zuriunea + Ezkerreko kako zuzena + Eskuineko kako zuzena + Erro karratua + Marka komertziala + Azpimarra + Marra bertikala + Yena + Ezezko ikurra + Barra bertikal etena + Mikro ikurra + Ia berdina ikurra + Desberdin ikurra + Dibisa-ikurra + Atal-ikurra + Gora gezia + Ezkerrera gezia + Errupia + Bihotz beltza + Azentu zorrotza edo tileta + Berdin + Won dibisaren ikurra + Erreferentzia-marka + Izar zuria + Izar beltza + Bihotz zuria + Zirkulu zuria + Borobil beltza + Eguzkia + Itua + Hirusta zuria + Pika zuria + Ezkerrera zuzendutako hatz zuria + Eskuinera zuzendutako hatz zuria + Ezkerreko erdia beltza duen zirkulua + Eskuineko erdia beltza duen zirkulua + Karratu zuria + Karratu beltza + Punta bat gora duen triangelu zuria + Punta bat behera duen triangelu zuria + Punta bat ezkerretarantz duen triangelu zuria + Punta bat eskuinera duen triangelu zuria + Diamante zuria + Beltza + Kortxea + Lotutako bi kortxeaerdi + Emea + Arra + Ezkerreko kako zuzen beltz lentikularra + Eskuineko kako zuzen beltz lentikularra + Ezkerreko izkinako parentesia + Eskuineko izkinako parentesia + Eskuineranzko gezia + Beheranzko gezia + Plus, minus + Litroa + Celsius gradua + Fahrenheit gradua + Gutxi gorabehera, berdin + Integrala + Matematikako ezkerreko angelu-parentesia + Matematikako eskuineko angelu-parentesia + Marka postala + Gorantz seinalatzen duen triangelu beltza + Beherantz seinalatzen duen triangelu beltza + Diamante beltza + Zabalera ertaineko erdiko puntua katakanaz + Karratu beltz txikia + Ezkerreko angelu-parentesi bikoitza + Eskuineko angelu-parentesi bikoitza + Harridura-ikur alderantzikatua + Galdera-ikur alderantzikatua + Won dibisaren ikurra + Zabalera osoko koma + Zabalera osoko harridura-ikurra + Puntu ideografikoa + Zabalera osoko galdera-ikurra + Erdiko puntua + Eskuineko komatxo bikoitza + Koma ideografikoa + Zabalera osoko bi puntu + Zabalera osoko puntu eta koma + Zabalera osoko et ikurra + Zabalera osoko azentu zirkunflexua + Zabalera osoko azentu-marka + Ezkerreko komatxo bikoitza + Zabalera osoko ezkerreko parentesia + Zabalera osoko eskuineko parentesia + Zabalera osoko izartxoa + Zabalera osoko azpimarra + Eskuineko komatxo bakarra + Zabalera osoko ezkerreko giltza + Zabalera osoko eskuineko giltza + Zabalera osoko baino gutxiago ikurra + Zabalera osoko baino handiago ikurra + Ezkerreko komatxo bakarra + diff --git a/utils/src/main/res/values-fa/strings.xml b/utils/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..60ddf1b --- /dev/null +++ b/utils/src/main/res/values-fa/strings.xml @@ -0,0 +1,50 @@ + + + نویسه‌های %1$d تا %2$d + نویسه %1$d + بدون عنوان + کپی شد، %1$s + %1$s بزرگ + %1$d %2$s + در حال استفاده از %1$s + ‏برای تنظیم میان‌بر جدید، ترکیب کلید را فشار دهید. میان‌بر باید حداقل شامل ALT یا کلید کنترل باشد. + برای تنظیم میان‌بر جدید، ترکیب کلید را با کلید تغییردهنده %1$s فشار دهید. + واگذارنشده + Shift + Alt + Ctrl + جستجو + پیکان راست + پیکان چپ + پیکان رو به بالا + پیکان رو به پایین + پیش‌فرض + نویسه + کلمه + خط + پاراگراف + پنجره + نشانگر مکانی + سرصفحه‌ها + فهرست‌ها + پیوندها + کنترل‌ها + محتوای ویژه + سرصفحه‌ها + کنترل‌ها + پیوندها + %1$s تصویر در تصویر + %1$s در بالا، %2$s در پایین + %1$s در راست، %2$s در چپ + %1$s در چپ، %2$s در راست + نمایش موارد %1$d تا %2$d از %3$d + در حال نمایش مورد %1$d از %2$d + صفحه %1$d از %2$d + %1$d از %2$d + %1$s (%2$s) + خروج + درحال نمایش %1$s + صفحه‌کلید پنهان شد + «بازخورد گفتاری» روشن است + «بازخورد گفتاری» خاموش است + diff --git a/utils/src/main/res/values-fa/strings_symbols.xml b/utils/src/main/res/values-fa/strings_symbols.xml new file mode 100644 index 0000000..01388a6 --- /dev/null +++ b/utils/src/main/res/values-fa/strings_symbols.xml @@ -0,0 +1,140 @@ + + + اپاستروف + امپرساند + علامت کوچک‌تر + علامت بزرگ‌تر + ستاره + "@" + خط اریب وارو + بولت + هشتک + علامت سنت + دو نقطه + کاما + حق نشر + آکولاد چپ + آکولاد راست + علامت درجه + علامت تقسیم + علامت دلار + سه نقطه + خط تیره کشیده + خط تیره + یورو + علامت تعجب + آکسان گراو + خط‌ فاصله + علامت نقل قول پایین + علامت ضرب + خط جدید + علامت پاراگراف + پرانتز چپ + پرانتز راست + درصد + نقطه + عدد پی + پوند + علامت واحد پول پوند + علامت سؤال + نقل قول + علامت تجاری ثبت شده + نقطه ویرگول + خط اریب + فاصله + قلاب سمت چپ + قلاب سمت راست + جذر + علامت تجاری + زیرخط + خط عمودی + ین + علامت نفی + خط چین + علامت میکرو + تقریباً مساوی با + نابرابر با + علامت ارز + علامت بخش + پیکان رو به بالا + پیکان رو به چپ + روپیه + قلب مشکی + مَد + علامت مساوی + علامت واحد پول وون + علامت اشاره + ستاره سفید + ستاره مشکی + قلب سفید + دایره سفید + دایره مشکی + نماد خورشید + نقطه هدف مرکزی + گشنیز سفید + پیک سفید + اشاره سفید به سمت چپ + اشاره سفید به سمت راست + دایره‌ای با نیمه چپ مشکی + دایره‌ای با نیمه راست مشکی + مربع سفید + مربع مشکی + مثلث سفید نشانگر بالا + مثلث سفید نشانگر پایین + مثلث سفید نشانگر چپ + مثلث سفید نشانگر راست + خشت سفید + نت یک چهارم + نت یک هشتم + نت‌های یک شانزدهم متصل + نماد زن + نماد مرد + قلاب هلال مشکی چپ + قلاب هلال مشکی راست + قلاب گوشه چپ + قلاب گوشه راست + پیکان رو به راست + پیکان رو به پایین + علامت بعلاوه منها + لیتر + درجه سانتی‌گراد + درجه فارنهایت + تقریباً مساوی + انتگرال + کمانک چپ ریاضی + کمانک راست ریاضی + علامت پستی + سه‌گوش مشکلی به‌سمت بالا + سه‌گوش مشکلی به‌سمت پایین + خال مشکی خشت + نقطه میانی کاتاکانا با عرض نیمه + مربع سیاه کوچک + پرانتز شکسته دوتایی چپ + پرانتز شکسته دوتایی راست + علامت تعجب وارونه + علامت سؤال وارونه + علامت واحد پول وون + کاما با پهنای کامل + علامت تعجب با پهنای کامل + نقطه اندیشه‌نگاری + علامت سؤال با پهنای کامل + نقطه وسط + علامت نقل‌قول دوتایی سمت راست + کامای اندیشه‌نگاری + دونقطه با پهنای کامل + کاما نقطه با پهنای کامل + اَمپرساند با پنهای کامل + هشتک با پهنای کامل + مَد با پهنای کامل + علامت نقل‌قول دوتایی چپ + پرانتز سمت چپ با پهنای کامل + پرانتز سمت راست با پهنای کامل + ستاره با پهنای کامل + زیرخط با پهنای کامل + علامت نقل‌قول تکی راست + کروشه چپ با پهنای کامل + کروشه راست با پهنای کامل + علامت کوچک‌تر از با پهنای کامل + علامت بزرگ‌تر از با پهنای کامل + علامت نقل‌قول تکی سمت چپ + diff --git a/utils/src/main/res/values-fi/strings.xml b/utils/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..2b530b9 --- /dev/null +++ b/utils/src/main/res/values-fi/strings.xml @@ -0,0 +1,50 @@ + + + Merkistä %1$d merkkiin %2$d + Merkki %1$d + nimetön + kopioitu, %1$s + iso kirjain %1$s + %1$d %2$s + Käytössä %1$s + Aseta uusi pikanäppäin painamalla näppäinyhdistelmää. Vähintään alt- tai ctrl-painike vaaditaan. + Määritä uusi pikanäppäin painamalla näppäinyhdistelmää, johon sisältyy muokkausnäppäin %1$s. + Määrittämätön + Vaihto + Alt + Ctrl + Haku + Oikea nuolinäppäin + Vasen nuolinäppäin + Ylänuolinäppäin + Alanuolinäppäin + Oletus + Merkit + Sanat + Rivit + Kappaleet + Windows + Tärkeimmät + Otsikot + Listat + Linkit + Ohjaimet + Erityissisältöä + Otsikot + Ohjaimet + Linkit + %1$s kuva kuvassa + %1$s ylhäällä, %2$s alhaalla + %1$s vasemmalla, %2$s oikealla + %1$s oikealla, %2$s vasemmalla + Näytetään kohteet %1$d–%2$d/%3$d. + Näytetään kohde %1$d/%2$d. + Sivu %1$d kautta %2$d + %1$d kautta %2$d + %1$s (%2$s) + Poistu + %1$s näkyvillä + näppäimistö piilotettu + Äänipalaute on käytössä + Äänipalaute ei ole käytössä + diff --git a/utils/src/main/res/values-fi/strings_symbols.xml b/utils/src/main/res/values-fi/strings_symbols.xml new file mode 100644 index 0000000..9113da3 --- /dev/null +++ b/utils/src/main/res/values-fi/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Heittomerkki + Et-merkki + Pienempi kuin -merkki + Suurempi kuin -merkki + Tähti + At + Kenoviiva + Luettelomerkki + Poisjääntimerkki + Senttimerkki + Kaksoispiste + Pilkku + Tekijänoikeudet + Vasen kaarisulje + Oikea kaarisulje + Astemerkki + Jakomerkki + Dollarimerkki + Ellipsi + Em-viiva + En-viiva + Euro + Huutomerkki + Gravisaksentti + Yhdysviiva + Rivinalinen lainausmerkki + Kertomerkki + Rivinvaihto + Kappalemerkki + Vasen sulje + Oikea sulje + Prosentti + Piste + Pii + Punta + Punnan valuuttamerkki + Kysymysmerkki + Lainausmerkki + Rekisteröity tavaramerkki + Puolipiste + Vinoviiva + Välilyönti + Vasen hakasulje + Oikea hakasulje + Neliöjuuri + Tavaramerkki + Alaviiva + Pystyviiva + Jeni + Ei-merkki + Katkaistu palkki + Mikro-merkki + Melkein yhtä suuri kuin + Erisuuri kuin + Valuuttamerkki + Osio-merkki + Ylänuoli + Vasen nuoli + Rupia + Musta sydän + Aaltoviiva + Yhtäsuuruusmerkki + Wonin valuuttamerkki + Viitemerkki + Valkoinen tähti + Musta tähti + Valkoinen sydän + Valkoinen ympyrä + Musta ympyrä + Auringon symboli + Napakymppi + Valkoinen pelikortin risti + Valkoinen pelikortin pata + Valkoinen vasemmalle osoittava sormi + Valkoinen oikealle osoittava sormi + Ympyrä, jonka vasen puoli on musta + Ympyrä, jonka oikea puoli on musta + Valkoinen neliö + Musta neliö + Valkoinen ylös osoittava kolmio + Valkoinen alas osoittava kolmio + Valkoinen vasemmalle osoittava kolmio + Valkoinen oikealle osoittava kolmio + Valkoinen pelikortin ruutu + Neljäsosanuotti + Kahdeksasosanuotti + Palkilliset kuudestoistaosanuotit + Naaraan symboli + Koiraan symboli + Vasen musta linssimäinen sulje + Oikea musta linssimäinen sulje + Vasen kulmasulje + Oikea kulmasulje + Oikealle-nuoli + Alas-nuoli + Plusmiinusmerkki + Litra + Celsiusaste + Fahrenheitaste + Likimäärin yhtäsuuri + Integraali + Matemaattinen vasen kulmasulje + Matemaattinen oikea kulmasulje + Postin merkki + Ylöspäin osoittava musta kolmio + Alaspäin osoittava musta kolmio + Korttipelin ruutu + Puolileveä rivinkeskinen katakana-piste + Pieni musta neliö + Vasen kaksoiskulmasulje + Oikea kaksoiskulmasulje + Ylösalainen huutomerkki + Ylösalainen kysymysmerkki + Wonin valuuttamerkki + Täysleveä pilkku + Täysleveä huutomerkki + Ideografinen piste + Täysleveä kysymysmerkki + Rivinkeskinen piste + Oikea lainausmerkki + Ideografinen pilkku + Täysleveä kaksoispiste + Täysleveä puolipiste + Täysleveä et-merkki + Täysleveä sirkumfleksi + Täysleveä aaltomerkki + Vasen lainausmerkki + Täysleveä vasen sulkumerkki + Täysleveä oikea sulkumerkki + Täysleveä tähtimerkki + Täysleveä alaviiva + Oikea puolilainausmerkki + Täysleveä vasen aaltosulje + Täysleveä oikea aaltosulje + Täysleveä pienempi kuin ‑merkki + Täysleveä suurempi kuin ‑merkki + Vasen puolilainausmerkki + diff --git a/utils/src/main/res/values-fil/strings.xml b/utils/src/main/res/values-fil/strings.xml new file mode 100644 index 0000000..1a057a6 --- /dev/null +++ b/utils/src/main/res/values-fil/strings.xml @@ -0,0 +1,50 @@ + + + Character %1$d hanggang %2$d + Character %1$d + walang pamagat + nakopya, %1$s + malaking %1$s + %1$d %2$s + Ginagamit ang %1$s + Pindutin ang kumbinasyon ng key upang magtakda ng bagong shortcut. Dapat itong maglaman ng kahit ALT o Control key lang. + Pindutin ang kumbinasyon ng key kasama ang %1$s na modifier key upang magtakda ng bagong shortcut. + Hindi nakatalaga + Shift + Alt + Ctrl + Maghanap + Pakanang Arrow + Pakaliwang Arrow + Pataas na Arrow + Pababang Arrow + Default + Mga Character + Mga Salita + Mga Linya + Mga Talata + Mga Window + Mga Landmark + Mga Heading + Mga Listahan + Mga Link + Mga Kontrol + Espesyal na content + Mga Heading + Mga Kontrol + Mga Link + %1$s na picture in picture + %1$s sa itaas, %2$s sa ibaba + %1$s sa kaliwa, %2$s sa kanan + %1$s sa kanan, %2$s sa kaliwa + Ipinapakita ang mga item mula %1$d hanggang %2$d ng %3$d. + Ipinapakita ang item mula %1$d ng %2$d. + Page %1$d ng %2$d + %1$d ng %2$d + %1$s (%2$s) + Lumabas + Ipinapakita ang %1$s + nakatago ang keyboard + Naka-on ang pasalitang feedback + Naka-off ang pasalitang feedback + diff --git a/utils/src/main/res/values-fil/strings_symbols.xml b/utils/src/main/res/values-fil/strings_symbols.xml new file mode 100644 index 0000000..379bb20 --- /dev/null +++ b/utils/src/main/res/values-fil/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Kudlit + Ampersand + Simbolo ng less than + Simbolo ng greater than + Asterisk + At + Backslash + Bullet + Caret + Simbolo ng sentimo + Tutuldok + Kuwit + Copyright + Kaliwang curly bracket + Kanang curly bracket + Simbolo ng degree + Simbolo ng paghahati + Simbolo ng dolyar + Ellipsis + Em dash + En dash + Euro + Tandang padamdam + Tuldik + Gitling + Mababang panipi + Simbolo ng multiplication + Bagong linya + Marka ng talata + Kaliwang panaklong + Kanang panaklong + Porsyento + Tuldok + Pi + Pound + Simbolo ng currency na pound + Tandang pananong + Panipi + Nakarehistrong trademark + Tuldukuwit + Slash + Espasyo + Kaliwang square bracket + Kanang square bracket + Square root + Trademark + Underscore + Patayong linya + Yen + Simbolo ng not + Broken bar + Simbolo ng micro + Halos katumbas ng + Hindi katumbas ng + Simbolo ng currency + Simbolo ng seksyon + Pataas na arrow + Pakaliwang arrow + Rupee + Itim na puso + Tilde + Sign ng equal + Simbolo ng currency na won + Marka ng Sanggunian + Puting bituin + Itim na bituin + Puting Puso + Puting bilog + Itim na bilog + Simbolo ng solar + Bullseye + Puting club suit + Puting spade suit + Puting index na nakaturo sa kaliwa + Puting index na nakaturo sa kanan + Bilog na may kalahating kaliwang itim + Bilog na may kalahating kanang itim + Puting parisukat + Itim na parisukat + Puting tatsulok na nakaturo sa itaas + Puting tatsulok na nakaturo sa ibaba + Puting tatsulok na nakaturo sa kaliwa + Puting tatsulok na nakaturo sa kanan + Puting diyamante + Quarter Note + Eighth Note + Mga naka-beam na sixteenth note + Simbolo ng babae + Simbolo ng lalaki + Itim na Kaliwang Lenticular Bracket + Itim na Kaliwang Lenticular Bracket + Kaliwang Sulok na Bracket + Kanang Sulok na Bracket + Kanang Arrow + Pababang Arrow + Sign ng plus minus + Liter + Celsius degree + Fahrenheit degree + Tinatayang katumbas ng + Integral + Kaliwang angle bracket na pang-math + Kanang angle bracket na pang-math + Postal mark + Itim na tatsulok na nakaturo sa itaas + Itim na tatsulok na nakaturo sa ibaba + Itim na diamond + Kalahating lapad na Katakana na gitnang tuldok + Maliit na itim na parisukat + Dobleng kaliwang angle bracket + Dobleng kanang angle bracket + Nakabaliktad na tandang padamdam + Nakabaliktad na tandang pananong + Simbolo ng currency na won + Buong lapad na kuwit + Buong lapad na tandang padamdam + Ideographic na tuldok + Buong lapad na tandang pananong + Gitnang tuldok + Dobleng kanang panipi + Ideographic na kuwit + Buong lapad na tutuldok + Buong lapad na tuldukuwit + Buong lapad na ampersand + Buong lapad na circumflex + Buong lapad na tilde + Dobleng kaliwang panipi + Buong lapad na kaliwang parenthesis + Buong lapad na kanang parenthesis + Buong lapad na asterisk + Buong lapad na underscore + Iisang kanang panipi + Buong lapad na kaliwang curly bracket + Buong lapad na kanang curly bracket + Buong lapad na simbolo ng less than + Buong lapad na simbolo ng greater than + Iisang kaliwang panipi + diff --git a/utils/src/main/res/values-fr-rCA/strings.xml b/utils/src/main/res/values-fr-rCA/strings.xml new file mode 100644 index 0000000..4615183 --- /dev/null +++ b/utils/src/main/res/values-fr-rCA/strings.xml @@ -0,0 +1,50 @@ + + + Caractères %1$d à %2$d + Caractère %1$d + sans titre + copié, %1$s + %1$s majuscule + %1$d %2$s + %1$s en cours d\'utilisation + Appuyez sur une combinaison de touches pour définir un nouveau raccourci. Ce dernier doit contenir les touches Alt ou Ctrl. + Appuyez sur une touche et sur la touche de modification %1$s pour définir un nouveau raccourci. + Non attribué + Majuscule + Alt + Ctrl + Rechercher + Flèche droite + Flèche gauche + Flèche vers le haut + Flèche vers le bas + Par défaut + Caractères + Mots + Lignes + Paragraphes + Fenêtres + Repères + En-têtes + Listes + Liens + Commandes + Contenu spécial + En-têtes + Commandes + Liens + %1$s est en mode d\'incrustation d\'image + %1$s en haut, %2$s en bas + %1$s à la gauche, %2$s à la droite + %1$s à la droite, %2$s à la gauche + Affichage des articles %1$d à %2$d sur un total de %3$d. + Affichage de l\'article %1$d sur un total de %2$d. + Page %1$d sur %2$d + %1$d sur %2$d + %1$s (%2$s) + Quitter + Affichage de la fenêtre %1$s + clavier masqué + La rétroaction vocale est activée + La rétroaction vocale est désactivée + diff --git a/utils/src/main/res/values-fr-rCA/strings_symbols.xml b/utils/src/main/res/values-fr-rCA/strings_symbols.xml new file mode 100644 index 0000000..30df026 --- /dev/null +++ b/utils/src/main/res/values-fr-rCA/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrophe + Esperluette + Symbole inférieur à + Symbole supérieur à + Astérisque + Arobase + Barre oblique inverse + Puce + Accent circonflex + Symbole de cent + Deux points + Virgule + Droits d\'auteur + Accolade gauche + Accolade droite + Symbole de degré + Symbole de division + Symbole du dollar + Ellipse + Tiret cadratin + Tiret demi-cadratin + Euro + Point d\'exclamation + Accent grave + Tiret + Guillemet bas double + Symbole de multiplication + Nouvelle ligne + Marque de paragraphe + Parenthèse gauche + Parenthèse droite + Pour cent + Point + Pi + Livre + Symbole monétaire de la livre + Point d\'interrogation + Guillemet + Marque déposée + Point-virgule + Barre oblique + Espace + Crochet gauche + Crochet droit + Racine carrée + Marque de commerce + Trait de soulignement + Barre verticale + Yen + Signe non + Barre verticale interrompue + Symbole micro + Symbole d\'approximation + Symbole différent de + Symbole monétaire + Signe de section + Flèche vers le haut + Flèche vers la gauche + Roupie + Cœur noir + Tilde + Signe égal à + Symbole monétaire du won sud-coréen + Marque de référence + Étoile blanche + Étoile noire + Cœur blanc + Cercle blanc + Disque noir + Opérateur point cerclé + Deux cercles concentriques + Trèfle blanc + Pique blanc + Index blanc pointant vers la gauche + Index blanc pointant vers la droite + Cercle avec moitié gauche noire + Cercle avec moitié droite noire + Carré blanc + Carré noir + Triangle blanc pointant vers le haut + Triangle blanc pointant vers le bas + Triangle blanc pointant vers la gauche + Triangle blanc pointant vers la droite + Losange blanc + Note noire + Note croche + Deux doubles croches ramées + Signe femelle + Signe mâle + Crochet noir lenticulaire gauche + Crochet noir lenticulaire droit + Anglet gauche + Anglet droit + Flèche vers la droite + Flèche vers le bas + Signe plus-ou-moins + Minuscule L de ronde + Degré Celsius + Degré Fahrenheit + Approximativement égal à + Intégrale + Crochet mathématique gauche + Crochet mathématique droite + Marque postale + Triangle noir pointant vers le haut + Triangle noir pointant vers le bas + Losange noir + Point médian katakana demi-chasse + Petit carré noir + Double chevron gauche + Double chevron droit + Point d\'exclamation renversé + Point d\'interrogation renversé + Symbole monétaire du won sud-coréen + Virgule pleine chasse + Point d\'exclamation pleine chasse + Point idéographique + Point d\'interrogation pleine chasse + Point médian + Guillemet-apostrophe double + Virgule idéographique + Deux-points pleine chasse + Point virgule pleine chasse + Perluète pleine chasse + Accent circonflexe pleine chasse + Tilde pleine chasse + Guillemet-apostrophe double culbuté + Parenthèse gauche pleine chasse + Parenthèse droite pleine chasse + Astérisque pleine chasse + Tiret bas pleine chasse + Guillemet-apostrophe + Accolade gauche pleine chasse + Accolade droite pleine chasse + Signe inférieur à pleine chasse + Signe supérieur à pleine chasse + Guillemet-apostrophe culbuté + diff --git a/utils/src/main/res/values-fr/strings.xml b/utils/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..d58a922 --- /dev/null +++ b/utils/src/main/res/values-fr/strings.xml @@ -0,0 +1,50 @@ + + + Caractères %1$d à %2$d + Caractère %1$d + sans titre + Le texte \"%1$s\" a été copié. + %1$s majuscule + %1$d %2$s + %1$s en cours d\'utilisation + Appuyez sur une combinaison de touches pour définir un nouveau raccourci. Ce dernier doit contenir les touches \"Alt\" ou \"Ctrl\". + Appuyez sur une touche et sur la touche de modification %1$s pour définir un nouveau raccourci. + Non attribué + Maj + Alt + Ctrl + Recherche + Flèche vers la droite + Flèche vers la gauche + Flèche vers le haut + Flèche vers le bas + Par défaut + Caractères + Mots + Lignes + Paragraphes + Fenêtres + Sections + Titres + Listes + Liens + Commandes + Contenu spécial + Titres + Commandes + Liens + %1$s en mode PIP + %1$s en haut, %2$s en bas + %1$s à gauche, %2$s à droite + %1$s à droite, %2$s à gauche + Affichage des articles %1$d à %2$d sur un total de %3$d + Affichage de l\'article %1$d sur un total de %2$d + Page %1$d sur %2$d + %1$d sur %2$d + %1$s (%2$s) + Quitter + Affichage de %1$s + clavier masqué + Commentaires audio activés + Commentaires audio désactivés + diff --git a/utils/src/main/res/values-fr/strings_symbols.xml b/utils/src/main/res/values-fr/strings_symbols.xml new file mode 100644 index 0000000..e4132dc --- /dev/null +++ b/utils/src/main/res/values-fr/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrophe + Esperluette + Signe inférieur à + Signe supérieur à + Astérisque + Arobase + Barre oblique inversée + Puces + Lambda + Symbole du cent + Deux-points + Virgule + Droits d\'auteur + Accolade gauche + Accolade droite + Symbole degré + Signe Division + Signe Dollar + Ellipse + Tiret cadratin + Tiret demi-cadratin + Euro + Point d\'exclamation + Accent grave + Tiret + Guillemet bas double + Signe Multiplication + Nouvelle ligne + Pied-de-mouche + Parenthèse gauche + Parenthèse droite + Pourcentage + Point + Pi + Dièse + Symbole monétaire de la livre + Point d\'interrogation + Guillemet droit + Marque déposée + Point-virgule + Barre oblique + Espace + Crochet gauche + Crochet droit + Racine carrée + Marque + Trait de soulignement + Ligne verticale + Yen + Signe Non + Barre verticale interrompue + Symbole Micro + Presque égal à + Différent de + Symbole monétaire + Symbole de paragraphe + Flèche vers le haut + Flèche vers la gauche + Roupie + Cœur noir + Tilde + Signe égal + Symbole monétaire du won + Marque de référence + Étoile blanche + Étoile noire + Cœur blanc + Cercle blanc + Disque noir + Opérateur point cerclé + Deux cercles concentriques + Trèfle blanc + Pique blanc + Index blanc pointant vers la gauche + Index blanc pointant vers la droite + Cercle avec moitié gauche noire + Cercle avec moitié droite noire + Carré blanc + Carré noir + Triangle blanc pointant vers le haut + Triangle blanc pointant vers le bas + Triangle blanc pointant vers la gauche + Triangle blanc pointant vers la droite + Losange blanc + Note noire + Note croche + Deux doubles croches ramées + Signe femelle + Signe mâle + Crochet noir lenticulaire gauche + Crochet noir lenticulaire droit + Anglet gauche + Anglet droit + Flèche vers la droite + Flèche vers le bas + Signe plus-ou-moins + Minuscule L de ronde + Degré Celsius + Degré Fahrenheit + Approximativement égal à + Intégrale + Chevron gauche mathématique + Chevron droit mathématique + Marque postale + Triangle noir pointant vers le haut + Triangle noir pointant vers le bas + Losange noir + Point médian katakana demi-chasse + Petit carré noir + Double chevron gauche + Double chevron droit + Point d\'exclamation inversé + Point d\'interrogation inversé + Symbole monétaire du won + Virgule pleine chasse + Point d\'exclamation pleine chasse + Point idéographique + Point d\'interrogation pleine chasse + Point médian + Double guillemet fermant + Virgule idéographique + Deux-points pleine chasse + Point-virgule pleine chasse + Perluète pleine chasse + Accent circonflexe pleine chasse + Tilde pleine chasse + Double guillemet ouvrant + Parenthèse ouvrante pleine chasse + Parenthèse fermante pleine chasse + Astérisque pleine chasse + Trait de soulignement pleine chasse + Simple guillemet fermant + Accolade ouvrante pleine chasse + Accolade fermante pleine chasse + Signe inférieur à pleine chasse + Signe supérieur à pleine chasse + Simple guillemet ouvrant + diff --git a/utils/src/main/res/values-gl/strings.xml b/utils/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..43768a8 --- /dev/null +++ b/utils/src/main/res/values-gl/strings.xml @@ -0,0 +1,50 @@ + + + Caracteres do %1$d ao %2$d + Carácter %1$d + sen título + copiouse %1$s + %1$s maiúsculo + %1$d %2$s + Utilizando %1$s + Preme a combinación de teclas para definir un atallo novo. Debe conter, polo menos, a tecla ALT ou Control. + Para definir un atallo novo, preme a combinación de teclas coa tecla modificadora %1$s. + Sen asignar + Maiús + Alt + Ctrl + Buscar + Frecha cara á dereita + Frecha cara á esquerda + Frecha cara a arriba + Frecha cara a abaixo + Predeterminada + Caracteres + Palabras + Liñas + Parágrafos + Ventás + Puntos de referencia + Títulos + Listas + Ligazóns + Controis + Contido especial + Títulos + Controis + Ligazóns + Pantalla superposta de: %1$s + %1$s na parte superior, %2$s na parte inferior + %1$s á esquerda, %2$s á dereita + %1$s á dereita, %2$s á esquerda + Mostrando elementos de %1$d a %2$d de %3$d. + Mostrando elemento %1$d de %2$d. + Páxina %1$d de %2$d + %1$d de %2$d + %1$s (%2$s) + Saír + Mostrando %1$s + teclado oculto + Os comentarios de voz están activados + Os comentarios de voz están desactivados + diff --git a/utils/src/main/res/values-gl/strings_symbols.xml b/utils/src/main/res/values-gl/strings_symbols.xml new file mode 100644 index 0000000..1586f80e --- /dev/null +++ b/utils/src/main/res/values-gl/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apóstrofo + Signo & + Signo menor que + Signo maior que + Asterisco + Arroba + Barra invertida + Viñeta + Acento circunflexo + Signo de céntimo + Dous puntos + Coma + Copyright + Chave de apertura + Chave de peche + Signo de grao + Signo de división + Signo de dólar + Puntos suspensivos + Trazo longo + Trazo curto + Euro + Signo de exclamación + Acento grave + Trazo + Comiñas dobres baixas + Signo de multiplicación + Nova liña + Marca de parágrafo + Paréntese de apertura + Paréntese de peche + Porcentaxe + Punto + Pi + Libra + Signo de libra esterlina + Signo de interrogación + Comiñas + Marca comercial rexistrada + Punto e coma + Barra + Espazo + Corchete de apertura + Corchete de peche + Raíz cadrada + Marca rexistrada + Guión baixo + Liña vertical + Ien + Signo de negación + Barra rota + Signo de micro + Case igual a + Non é igual a + Signo de moeda + Signo de sección + Frecha arriba + Frecha esquerda + Rupia + Corazón negro + Til + Signo igual + Signo de won + Marca de referencia + Estrela branca + Estrela negra + Corazón branco + Círculo branco + Círculo negro + Símbolo solar + Diana + Trevo branco + Pica branca + Índice branco apuntando cara á esquerda + Índice branco apuntando cara á dereita + Círculo coa metade esquerda negra + Círculo coa metade dereita negra + Cadrado branco + Cadrado negro + Triángulo branco coa punta cara arriba + Triángulo branco coa punta cara abaixo + Triángulo branco coa punta cara á esquerda + Triángulo branco coa punta cara á dereita + Diamante branco + Negra + Corchea + Semicorcheas ligadas + Símbolo feminino + Símbolo masculino + Corchete lenticular negro de apertura + Corchete lenticular negro de peche + Corchete de esquina de apertura + Corchete de esquina de peche + Frecha cara á dereita + Frecha cara abaixo + Signo máis/menos + Litro + Grao Celsius + Grao Fahrenheit + Aproximadamente iguais + Integral + Símbolo matemático \"menor que\" + Símbolo matemático \"maior que\" + Marca postal + Triángulo negro apuntando cara arriba + Triángulo negro apuntando cara abaixo + Diamante negro + Punto medio katakana de ancho medio + Cadrado negro pequeno + Corchete angular dobre de apertura + Corchete angular dobre de peche + Signo de exclamación de apertura + Signo de interrogación de apertura + Signo de won + Coma de ancho completo + Signo de exclamación de ancho completo + Punto ideográfico + Signo de interrogación de ancho completo + Punto medio + Comiñas dobres de peche + Coma ideográfica + Dous puntos de ancho completo + Punto e coma de ancho completo + Et de ancho completo + Acento circunflexo de ancho completo + Til de ancho completo + Comiñas dobres de apertura + Paréntese de apertura de ancho completo + Paréntese de peche de ancho completo + Asterisco de ancho completo + Guión baixo de ancho completo + Comiña simple de peche + Chave de apertura de ancho completo + Chave de peche de ancho completo + Signo menor que de ancho completo + Signo maior que de ancho completo + Comiña simple de apertura + diff --git a/utils/src/main/res/values-gu/strings.xml b/utils/src/main/res/values-gu/strings.xml new file mode 100644 index 0000000..6bc2769 --- /dev/null +++ b/utils/src/main/res/values-gu/strings.xml @@ -0,0 +1,50 @@ + + + %1$d થી %2$d સુધીના અક્ષર + અક્ષર %1$d + અનામાંકિત + %1$s, કૉપિ કર્યું + કેપિટલ %1$s + %1$d %2$s + %1$s નો ઉપયોગ કરી રહ્યું છે + નવું શોર્ટકટ સેટ કરવા માટે કી સંયોજન દબાવો. તેમાં ઓછામાં ઓછી ALT અથવા Control કી શામેલ હોવી આવશ્યક છે. + નવા શૉર્ટકટને સેટ કરવા માટે %1$s સંશોધક કી સાથે કી સંયોજન દબાવો. + સોંપેલ નહીં + Shift + Alt + Ctrl + શોધો + તીર જમણું + તીર ડાબું + ઉપર તીર + નીચે તીર + ડિફોલ્ટ + અક્ષરો + શબ્દો + લાઇન્સ + ફકરા + વિન્ડો + લૅન્ડમાર્ક + મથાળાં + સૂચિઓ + લિંક + નિયંત્રણો + વિશિષ્ટ કન્ટેન્ટ + મથાળા + નિયંત્રણો + લિંક + %1$s ચિત્રમાં ચિત્ર + %1$s ટોચ પર, %2$s તળિયે + %1$s ડાબી બાજુએ, %2$s જમણી બાજુએ + %1$s જમણી બાજુએ, %2$s ડાબી બાજુએ + %3$d ના %1$d થી %2$d આઇટમ્સ દર્શાવી રહ્યું છે. + %1$d થી %2$d સુધીની આઇટમ દર્શાવી રહ્યું છે. + %2$d માંથી %1$d પૃષ્ઠ + %2$d માંથી %1$d + %1$s (%2$s) + બહાર નીકળો + %1$s બતાવી રહ્યાં છીએ + કીબોર્ડ છુપાવેલું છે + \'બોલાયેલા પ્રતિસાદ\'નો વિકલ્પ ચાલુ છે + \'બોલાયેલા પ્રતિસાદ\'નો વિકલ્પ બંધ છે + diff --git a/utils/src/main/res/values-gu/strings_symbols.xml b/utils/src/main/res/values-gu/strings_symbols.xml new file mode 100644 index 0000000..5dad171 --- /dev/null +++ b/utils/src/main/res/values-gu/strings_symbols.xml @@ -0,0 +1,140 @@ + + + એપોસ્ટ્રોફી + એમ્પરસેન્ડ + આનાથી ઓછાનું ચિહ્ન + આનાથી વધારેનું ચિહ્ન + ફૂદડી + એટ + બૅકસ્લૅશ + બુલેટ + કૅરેટ + સેન્ટનું ચિહ્ન + ગુરુવિરામ + અલ્પવિરામ + કોપિરાઇટ + ડાબો છગડિયો કૌંસ + જમણો છગડિયો કૌંસ + ડિગ્રીનું ચિહ્ન + ભાગાકારનું ચિહ્ન + ડૉલરનું ચિહ્ન + અધ્યાહાર + Em ડેશ + En ડેશ + યુરો + ઉદ્ગારવાચક ચિહ્ન + ગ્રેવ એક્સેન્ટ + ડૅશ + નીચા બેવડા અવતરણ + ગુણાકારનું ચિહ્ન + નવી રેખા + ફકરાનું ચિહ્ન + ડાબો કૌંસ + જમણો કૌંસ + ટકા + પૂર્ણવિરામ + પાઇ + પાઉન્ડ + પાઉન્ડ ચલણનું ચિહ્ન + પ્રશ્ન ચિહ્ન + અવતરણ + નોંધાયેલ ટ્રેડમાર્ક + અર્ધવિરામ + સ્લૅશ + જગ્યા + ડાબો ચોરસ કૌંસ + જમણો ચોરસ કૌંસ + વર્ગમૂળ + ટ્રેડમાર્ક + અન્ડરસ્કૉર + ઊભી લાઇન + યેન + નથી ચિહ્ન + તૂટેલ બાર + માઇક્રો ચિહ્ન + લગભગ આના જેટલું જ + આના જેટલું નહીં + ચલણ ચિહ્ન + વિભાગ ચિહ્ન + ઉપરની તરફનો એરો + ડાબી તરફનો એરો + રૂપિયો + કાળું હૃદય + ટિલ્ડ + બરાબરનું ચિહ્ન + વૉન ચલણનું ચિહ્ન + સંદર્ભ ચિહ્ન + સફેદ તારો + કાળો તારો + સફેદ હૃદય + સફેદ વર્તુળ + કાળું વર્તુળ + સૌર પ્રતીક + બુલ્સઆઇ + સફેદ ક્લબ સ્યૂટ + સફેદ સ્પેડ સ્યુટ + સફેદ ડાબી તરફનો અનુક્રમ + સફેદ જમણી તરફનો અનુક્રમ + ડાબા અડધા કાળા સાથેનું વર્તુળ + જમણા અડધા કાળા સાથેનું વર્તુળ + સફેદ ચોરસ + કાળું ચોરસ + સફેદ ઉપરની તરફનો ત્રિકોણ + સફેદ નીચેની તરફનો ત્રિકોણ + સફેદ ડાબી તરફનો ત્રિકોણ + સફેદ જમણી તરફનો ત્રિકોણ + સફેદ હીરો + ક્વાર્ટર નોંધ + આઠમી નોંધ + બીમ કરેલ સોળમી નોંધ + સ્ત્રી પ્રતીક + પુરુષ પ્રતીક + ડાબો કાળો દ્વિબહિર્ગોળ કૌંસ + જમણો કાળો દ્વિબહિર્ગોળ કૌંસ + ડાબો ખૂણાનો કૌંસ + જમણો ખૂણાનો કૌંસ + જમણી તરફનો એરો + નીચેની તરફનો એરો + સરવાળા બાદબાકીનું ચિહ્ન + લીટર + સેલ્સિયસ ડિગ્રી + ફેરનહીટ ડિગ્રી + આશરે બરાબર + અભિન્ન + ગણિતનો ડાબા ખૂણાવાળો કૌંસ + ગણિતનો જમણા ખૂણાવાળો કૌંસ + પોસ્ટલ માર્ક + ઉપરની તરફ ચીંધી રહેલું કાળું ત્રિકોણ + નીચેની તરફ ચીંધી રહેલું કાળું ત્રિકોણ + ડાયમંડ આકારનું કાળું સ્યૂટ ચિહ્ન + કટાકાના લિપિનું મધ્યમાં ડોટ + નાનું કાળું ચોરસ + ડાબા બેવડાં ખૂણાવાળા કૌંસ + જમણા બેવડાં ખૂણાવાળા કૌંસ + ઊંધું ઉદ્ગારવાચક ચિહ્ન + ઊંધું પ્રશ્નાર્થ ચિહ્ન + વૉન ચલણનું ચિહ્ન + પૂર્ણ પહોળાઈનું અલ્પવિરામ + પૂર્ણ પહોળાઈનું ઉદ્ગારવાચક ચિહ્ન + આઇડિયોગ્રાફિક પૂર્ણવિરામ + પૂર્ણ પહોળાઈનું પ્રશ્નાર્થ ચિહ્ન + મધ્યમાં ડોટ + જમણું બેવડું અવતરણ ચિહ્ન + આઇડિયોગ્રાફિક અલ્પવિરામ + પૂર્ણ પહોળાઈનું ગુરુવિરામ + પૂર્ણ પહોળાઈનું અર્ધવિરામ + પૂર્ણ પહોળાઈનો એમ્પરસેન્ડ + પૂર્ણ પહોળાઈનું સર્કમફ્લૅક્સ + પૂર્ણ પહોળાઈનો ટિલ્ડ + ડાબું બેવડું અવતરણ ચિહ્ન + પૂર્ણ પહોળાઈનો ડાબો કૌંસ + પૂર્ણ પહોળાઈનો જમણો કૌંસ + પૂર્ણ પહોળાઈની ફૂદડી + પૂર્ણ પહોળાઈનો અન્ડરસ્કોર + જમણું એકલ અવતરણ ચિહ્ન + પૂર્ણ પહોળાઈનો ડાબો છગડિયો કૌંસ + પૂર્ણ પહોળાઈનો જમણો છગડિયો કૌંસ + પૂર્ણ પહોળાઈનું આનાથી ઓછાનું ચિહ્ન + પૂર્ણ પહોળાઈનું આનાથી વધુનું ચિહ્ન + ડાબું એકલ અવતરણ ચિહ્ન + diff --git a/utils/src/main/res/values-hi/strings.xml b/utils/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..ec8e8a4 --- /dev/null +++ b/utils/src/main/res/values-hi/strings.xml @@ -0,0 +1,50 @@ + + + वर्ण %1$d से %2$d तक + वर्ण %1$d + शीर्षक रहित + कॉपी किया गया, %1$s + कैपिटल %1$s + %1$d %2$s + %1$s का उपयोग करना + नया शॉर्टकट सेट करने के लिए कुंजी संयोजन दबाएं. उसमें कम से कम ALT या Control कुंजी शामिल होनी चाहिए. + नया शॉर्टकट सेट करने के लिए %1$s संशोधक कुंजी वाला कुंजी संयोजन दबाएं. + असाइन नहीं किया गया + Shift + Alt + Ctrl + खोजें + दायां तीर + बायां तीर + ऊपर तीर + नीचे तीर + डिफ़ॉल्ट + वर्ण + शब्‍द + लाइन + पैराग्राफ़ + विंडो + लैंडमार्क + शीर्षक + सूचियां + लिंक + कंट्रोल + विशेष कॉन्टेंट + शीर्षक + कंट्रोल + लिंक + %1$s पिक्चर में पिक्चर + %1$s शीर्ष पर है, %2$s नीचे है + %1$s बाईं ओर है, %2$s दाईं ओर है + %1$s दाईं ओर है, %2$s बाईं ओर है + %3$d में से %1$d से %2$d आइटम दिखा रहा है. + %2$d में से %1$d आइटम दिखा रहा है. + पेज %2$d में से %1$d + %2$d में से %1$d + %1$s (%2$s) + बाहर निकलें + %1$s दिखाया जा रहा है + कीबोर्ड छिपा हुआ है + बोलकर जवाब देने की सुविधा चालू है + बोलकर जवाब देने की सुविधा बंद है + diff --git a/utils/src/main/res/values-hi/strings_symbols.xml b/utils/src/main/res/values-hi/strings_symbols.xml new file mode 100644 index 0000000..438256f --- /dev/null +++ b/utils/src/main/res/values-hi/strings_symbols.xml @@ -0,0 +1,140 @@ + + + अपॉस्ट्रॉफ़ी + एंपरसेंड + \'इससे कम\' का चिह्न + \'इससे ज़्यादा\' का चिह्न + तारांकन + ऐट + बैकस्लैश + बुलेट + कैरेट + सेंट का चिह्न + कोलन + अल्पविराम + कॉपीराइट + बायां धनुषाकार कोष्ठक + दायां धनुषाकार कोष्ठक + डिग्री का चिह्न + विभाजन चिह्न + डॉलर चिह्न + पदलोप चिह्न + एम डैश + एन डैश + यूरो + विस्मयादिबोधक चिह्न + ग्रेव एक्सेंट + डैश + निम्न दोहरा उद्धरण + गुणन चिह्न + नई पंक्ति + पैराग्राफ़ चिह्न + बायां कोष्ठक + दायां कोष्ठक + प्रतिशत + पीरियड + पाइ + पाउंड + पाउंड स्टर्लिंग का चिह्न + प्रश्नचिह्न + उद्धरण चिह्न + पंजीकृत ट्रेडमार्क + अर्द्धविराम + स्‍लैश + स्पेस + बायां वर्ग कोष्ठक + दायां वर्ग कोष्ठक + वर्गमूल + ट्रेडमार्क + अंडरस्कोर + वर्टिकल लाइन + येन + नहीं का चिह्न + विभाजित बार + माइक्रो चिह्न + करीब-करीब इसके बराबर है + इसके बराबर नहीं है + मुद्रा चिह्न + अनुभाग चिह्न + ऊपर तीर + बायां तीर + रुपया + काला दिल + टिल्ड + बराबर का चिह्न + वॉन मुद्रा का चिह्न + संदर्भ चिह्न + सफ़ेद तारा + काला तारा + सफ़ेद दिल + सफ़ेद वृत्‍त + काला वृत्‍त + सौर प्रतीक + बुल्सआई + सफ़ेद क्‍लब सूट + सफ़ेद पान का चिह्न + बाएं इंगित करती सफ़ेद उंगली + दाएं इंगित करती सफ़ेद उंगली + बाएं आधे काले भाग वाला वृत्‍त + दाएं आधे काले भाग वाला वृत्‍त + सफ़ेद वर्ग + काला वर्ग + सफ़ेद ऊपर की ओर त्रिकोण + सफ़ेद नीचे की ओर त्रिकोण + सफ़ेद बायां त्रिकोण + सफ़ेद दायां त्रिकोण + सफ़ेद हीरा + क्‍वार्टर नोट + आठवां नोट + बीम किए गए सोलहवें नोट + महिला का चिह्न + पुरुष का चिह्न + बायां काला लैंटिक्‍यूलर ब्रैकेट + दायां काला लैंटिक्‍यूलर ब्रैकेट + दायां कॉर्नर ब्रैकेट + दायां कॉर्नर ब्रैकेट + दायां तीर + नीचे तीर + धन ऋण का चिह्न + लीटर + सेल्‍सियस डिग्री + फ़ैरेनहाइट डिग्री + अनुमानित रूप से बराबर + समाकलन + गणित में इस्तेमाल किया जाने वाला बाईं ओर का ब्रैकेट + गणित में इस्तेमाल किया जाने वाला दाईं ओर का ब्रैकेट + पोस्टल मार्क + ऊपर की ओर इशारा करता हुआ काले रंग का त्रिभुज + नीचे की ओर इशारा करता हुआ काले रंग का त्रिभुज + काले रंग का डायमंड के आकार वाला सूट (चिह्न) + कटाकाना लिपि का आधी चौड़ाई वाला डॉट + काले रंग का छोटा स्क्वेयर + बाईं तरफ़ का दो कोने वाला ब्रैकिट चिह्न + दाईं तरफ़ का दो कोने वाला ब्रैकिट चिह्न + उलटा विस्मयादिबोधक चिह्न + उलटा प्रश्नवाचक चिह्न + वॉन मुद्रा का चिह्न + पूरी चौड़ाई वाला कॉमा का चिह्न + पूरी चौड़ाई वाला विस्मयादिबोधक चिह्न + इडियोग्राफ़िक पूर्णविराम + पूरी चौड़ाई वाला प्रश्न चिह्न + बीच वाला बिंदु + दाईं तरफ़ का दोहरे कोटेशन का निशान + इडियोग्राफ़िक कॉमा + पूरी चौड़ाई वाला कोलन + पूरी चौड़ाई वाला सेमीकोलन + पूरी चौड़ाई वाला एंपरसैंड (&) + पूरी चौड़ाई वाला सर्कमफ़्लेक्स + पूरी चौड़ाई वाला टिल्ड + बाईं तरफ़ का दोहरे कोटेशन का निशान + पूरी चौड़ाई वाला बायां ब्रैकिट + पूरी चौड़ाई वाला दायां ब्रैकिट + पूरी चौड़ाई वाला स्टार का निशान + पूरी चौड़ाई वाला अंडरस्कोर + दाईं तरफ़ का सिंगल कोटेशन का निशान + पूरी चौड़ाई वाला बायां कर्ली ब्रैकिट + पूरी चौड़ाई वाला दायां कर्ली ब्रैकेट + पूरी चौड़ाई वाला \'कम\' का निशान + पूरी चौड़ाई वाला \'ज़्यादा\' का निशान + बाईं तरफ़ का सिंगल कोटेशन का निशान + diff --git a/utils/src/main/res/values-hr/strings.xml b/utils/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..7200d98 --- /dev/null +++ b/utils/src/main/res/values-hr/strings.xml @@ -0,0 +1,50 @@ + + + Znakovi od %1$d do %2$d + Znak %1$d + bez naslova + kopirano, %1$s + veliko slovo %1$s + %1$d %2$s + Upotreba tražilice %1$s + Pritisnite kombinaciju tipki da biste postavili novi prečac. Mora sadržavati barem tipku ALT ili Control. + Pritisnite kombinaciju tipki uz modifikatorsku tipku %1$s da biste postavili novi prečac. + Nije dodijeljeno + Shift + Alt + Ctrl + Pretraži + Strelica desno + Strelica lijevo + Strelica gore + Strelica dolje + Zadano + Znakovi + Riječi + Redci + Odlomci + Prozori + Orijentiri + Naslovi + Popisi + Veze + Kontrole + Poseban sadržaj + Naslovi + Kontrole + Veze + %1$s slika u slici + %1$s gore, %2$s dolje + %1$s slijeva, %2$s zdesna + %1$s zdesna, %2$s slijeva + Prikazuju se stavke od %1$d do %2$d od ukupno %3$d. + Prikazuje se stavka %1$d od ukupno %2$d. + Stranica %1$d od %2$d + %1$d od %2$d + %1$s (%2$s) + Izlaz + Prikazuje se prozor %1$s + tipkovnica je skrivena + Govorne povratne informacije su uključene + Govorne povratne informacije su isključene + diff --git a/utils/src/main/res/values-hr/strings_symbols.xml b/utils/src/main/res/values-hr/strings_symbols.xml new file mode 100644 index 0000000..3e8ef27 --- /dev/null +++ b/utils/src/main/res/values-hr/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Izostavnik + Ampersand + Znak za manje od + Znak za veće od + Zvjezdica + Znak pri + Obrnuta kosa crta + Grafička oznaka + Karet + Znak za cent + Dvotočka + Zarez + Autorska prava + Lijeva vitičasta zagrada + Desna vitičasta zagrada + Znak za stupanj + Znak dijeljenja + Znak za dolar + Tri točke + Duga crtica + Kratka crtica + Euro + Uskličnik + Kratkouzlazni naglasak + Crtica + Donji dvostruki navodnici + Znak množenja + Novi redak + Oznaka odlomka + Lijeva zagrada + Desna zagrada + Postotak + Točka + Pi + Funta + Znak funte + Upitnik + Navodnik + Registrirani zaštitni znak + Točka-zarez + Kosa crta + Razmaknica + Lijeva uglata zagrada + Desna uglata zagrada + Kvadratni korijen + Zaštitni znak + Podvlaka + Okomita crta + Jen + Znak ne + Okomita crta + Znak mikro + Gotovo jednako + Nije jednako + Znak valute + Paragraf + Strelica prema gore + Strelica ulijevo + Rupija + Crno srce + Tilda + Znak jednakosti + Znak za valutu von + Znak upućivanja + Bijela zvijezda + Crna zvijezda + Bijelo srce + Bijeli krug + Crni krug + Simbol sunca + Meta + Bijeli tref + Bijeli pik + Bijeli kažiprst usmjeren ulijevo + Bijeli kažiprst usmjeren udesno + Krug s crnom lijevom polovicom + Krug s crnom desnom polovicom + Bijeli kvadrat + Crni kvadrat + Bijeli trokut s vrhom prema gore + Bijeli trokut s vrhom prema dolje + Bijeli trokut s vrhom prema lijevo + Bijeli trokut s vrhom prema desno + Bijeli karo + Četvrtinka + Osminka + Šesnaestinke s gredom + Ženski simbol + Muški simbol + Otvorena crna zagrada u obliku leće + Zatvorena crna zagrada u obliku leće + Otvorena uglata zagrada + Zatvorena uglata zagrada + Desna strelica + Strelica prema dolje + Znak plusa i minusa + Litra + Celzijev stupanj + Fahrenheitov stupanj + Približno jednako + Integral + Matematička lijeva uglata zagrada + Matematička desna uglata zagrada + Poštanska oznaka + Crni trokut s vrhom prema gore + Crni trokut s vrhom prema dolje + Crni rombovi + Srednja točka polovične širine u katakani + Mali crni kvadrat + Lijeva dvostruka šiljasta zagrada + Desna dvostruka šiljasta zagrada + Obrnuti uskličnik + Obrnuti upitnik + Znak za valutu von + Zarez pune širine + Uskličnik pune širine + Ideogramska točka + Upitnik pune širine + Srednja točka + Desni dvostruki navodnik + Ideogramski zarez + Dvotočka pune širine + Točka sa zarezom pune širine + Ampersand pune širine + Cirkumfleks pune širine + Tilda pune širine + Lijevi dvostruki navodnik + Lijeva zagrada pune širine + Desna zagrada pune širine + Zvjezdica pune širine + Podvlaka pune širine + Desni jednostruki navodnik + Lijeva vitičasta zagrada pune širine + Desna vitičasta zagrada pune širine + Znak za manje pune širine + Znak za veće pune širine + Lijevi jednostruki navodnik + diff --git a/utils/src/main/res/values-hu/strings.xml b/utils/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..3e163d5 --- /dev/null +++ b/utils/src/main/res/values-hu/strings.xml @@ -0,0 +1,50 @@ + + + Karakterek %1$d és %2$d között + Karakter: %1$d + névtelen + másolva, %1$s + nagy %1$s + %1$d %2$s + A következőt használja: %1$s + Nyomja meg a kombinációt az új gyorsparancs beállításához. Az ALT vagy a Control billentyűnek mindenképpen szerepelnie kell a kombinációban. + Gyorsparancs törlése a %1$s módosító billentyűvel új gyorsparancs beállításához. + Nincs társítva + Shift + Alt + Ctrl + Keresés + Jobbra nyíl + Balra nyíl + Felfelé nyíl + Lefelé nyíl + Alapértelmezett + Karakterek + Szavak + Sorok + Bekezdések + Ablakok + Igazodási pontok + Címsorok + Listák + Linkek + Vezérlők + Speciális tartalom + Címsorok + Vezérlők + Linkek + %1$s kép a képben + %1$s felül, %2$s alul + %1$s balra, %2$s jobbra + %1$s jobbra, %2$s balra + %1$d-%2$d/%3$d. elem látható. + %1$d/%2$d. elem látható + %2$d/%1$d. oldal + %2$d/%1$d + %1$s (%2$s) + Kilépés + %1$s megjelenítve + billentyűzet elrejtve + Hangos visszajelzés bekapcsolva + Hangos visszajelzés kikapcsolva + diff --git a/utils/src/main/res/values-hu/strings_symbols.xml b/utils/src/main/res/values-hu/strings_symbols.xml new file mode 100644 index 0000000..3f2f27b --- /dev/null +++ b/utils/src/main/res/values-hu/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Aposztróf + És jel + Kisebb, mint jel + Nagyobb, mint jel + Csillag + Kukac + Fordított törtvonal + Listajel + Kalap + Cent jele + Kettőspont + Vessző + Szerzői jog + Bal kapcsos zárójel + Jobb kapcsos zárójel + Fokjel + Osztásjel + Dollár jel + Három pont + Hosszú gondolatjel + Rövid gondolatjel + Euró + Felkiáltójel + Tompa ékezet + Kötőjel + Alsó dupla idézőjel + Szorzásjel + Új sor + Bekezdésjel + Bal zárójel + Jobb zárójel + Százalék + Pont + Pi + Kettős kereszt + Font pénznem jele + Kérdőjel + Idézőjel + Bejegyzett védjegy + Pontosvessző + Törtvonal + Szóköz + Bal szögletes zárójel + Jobb szögletes zárójel + Négyzetgyök + Védjegy + Aláhúzás + Függőleges vonal + Jen + Tagadás jel + Szaggatott vonal + Mikro jel + Majdnem egyenlő + Nem egyenlő + Pénznem jel + Paragrafus jel + Felfelé nyíl + Balra nyíl + Rúpia + Fekete szív + Hullámvonal + Egyenlőségjel + Won pénznem jele + Referenciajel + Fehér csillag + Fekete csillag + Fehér szív + Fehér kör + Fekete kör + Napszimbólum + Célkör + Fehér treff + Fehér pikk + Fehér, balra mutató ujj + Fehér, jobbra mutató ujj + Kör, amelynek a bal fele fekete + Kör, amelynek a jobb fele fekete + Fehér négyzet + Fekete négyzet + Fehér, felfelé mutató háromszög + Fehér, lefelé mutató háromszög + Fehér, balra mutató háromszög + Fehér, jobbra mutató háromszög + Fehér káró + Negyedhangjegy + Nyolcad-hangjegyek + Gerendás tizenhatod-hangjegyek + Női szimbólum + Férfi szimbólum + Bal fekete lencse alakú zárójel + Jobb fekete lencse alakú zárójel + Bal kapcsos zárójel + Jobb kapcsos zárójel + Jobbra mutató nyíl + Lefelé mutató nyíl + Pluszmínusz-jel + Liter + Celsius-fok + Fahrenheit-fok + Körülbelül egyenlő + Integráljel + Balról nyíló relációs jel + Jobbról nyíló relációs jel + Postai jel + Felfelé mutató fekete háromszög + Lefelé mutató fekete háromszög + Fekete gyémánt + Félszélességű katakana középső pont + Kis fekete négyzet + Bal oldali dupla csúcsos zárójel + Jobb oldali dupla csúcsos zárójel + Fordított felkiáltójel + Fordított kérdőjel + Won pénznem jele + Teljes szélességű vessző + Teljes szélességű felkiáltójel + Ideografikus mondatvégi pont + Teljes szélességű kérdőjel + Középső pont + Jobb dupla idézőjel + Ideografikus vessző + Teljes szélességű kettőspont + Teljes szélességű pontosvessző + Teljes szélességű „és” jel + Teljes szélességű „háztető” jel + Teljes szélességű hullámvonal + Bal dupla idézőjel + Teljes szélességű bal oldali zárójel + Teljes szélességű jobb oldali zárójel + Teljes szélességű csillag + Teljes szélességű alulvonás + Jobb oldali félidézőjel + Teljes szélességű bal oldali kapcsos zárójel + Teljes szélességű jobb oldali kapcsos zárójel + Teljes szélességű „kisebb, mint” jel + Teljes szélességű „nagyobb, mint” jel + Bal oldali félidézőjel + diff --git a/utils/src/main/res/values-hy/strings.xml b/utils/src/main/res/values-hy/strings.xml new file mode 100644 index 0000000..3ae413a --- /dev/null +++ b/utils/src/main/res/values-hy/strings.xml @@ -0,0 +1,50 @@ + + + %1$d-ից մինչև %2$d նիշեր + %1$d նիշ + անվերնագիր + պատճենվել է, %1$s + մեծատառ %1$s + %1$d %2$s + Օգտագործվում է %1$s + Նոր դյուրանցում սահմանելու համար սեղմեք ստեղների համակցությունը: Այն պետք է պարունակի կամ ALT, կամ Control ստեղնը: + Նոր դյուրանցում կարգավորելու համար սեղմեք %1$s կերպափոխիչ ստեղնի և այլ ստեղների համադրություն: + Չնշանակված + Shift + Alt + Ctrl + Որոնում + Սլաքը աջ + Սլաքը ձախ + Սլաքը վերև + Սլաքը ներքև + Կանխադրված + Նիշեր + Բառեր + Տողեր + Պարբերություններ + Պատուհաններ + Ուղենիշներ + Վերնագրեր + Ցանկեր + Հղումներ + Կառավարման տարրեր + Հատուկ բովանդակություն + Վերնագրեր + Կառավարման տարրեր + Հղումներ + %1$s՝ նկար նկարի մեջ + %1$s-ը վերևում, %2$s-ը ներքևում + %1$s-ը ձախ կողմում, %2$s-ը աջ կողմում + %1$s-ը աջ կողմում, %2$s-ը ձախ կողմում + %1$d-ից մինչև %2$d նյութերը՝ %3$d-ից: + Ցուցադրվում է %1$d տարրը՝ %2$d-ից: + Էջ %1$d՝ %2$d-ից + %1$d՝ %2$d-ից + %1$s (%2$s) + Ելք + Ցուցադրվում է «%1$s» ստեղնաշարը + ստեղնաշարը թաքցված է + Ձայնային արձագանքը միացված է + Տեքստի հնչեցումն անջատված է + diff --git a/utils/src/main/res/values-hy/strings_symbols.xml b/utils/src/main/res/values-hy/strings_symbols.xml new file mode 100644 index 0000000..e2ce395 --- /dev/null +++ b/utils/src/main/res/values-hy/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Ապաթարց + Ամպերսանդ + Փոքրի նշան + Մեծի նշան + Աստղանիշ + Ըթ + Հետադարձ սլեշ + Պարբերակ + Տանիք + Ցենտի նշան + Միջակետ + Ստորակետ + Հեղինակային իրավունք + Ձախ ձևավոր փակագիծ + Աջ ձևավոր փակագիծ + Աստիճանի նշան + Բաժանարարի նշան + Դոլարի նշան + Զեղչում + M-աչափ գիծ + N-աչափ գիծ + Եվրո + Բացականչության նշան + Բութ + Գիծ + Ներքևի կրկնակի չակերտ + Բազմապատկման նշան + Նոր տող + Պարբերության նշան + Ձախ փակագիծ + Աջ փակագիծ + Տոկոս + Կետ + Պի + Ֆունտ + Ֆունտ արտարժույթի նշան + Հարցական նշան + Չակերտ + Գրանցված ապրանքային նշան + Կետ-ստորակետ + Շեղ գիծ + Բացատ + Ձախ քառակուսի փակագիծ + Աջ քառակուսի փակագիծ + Քառակուսի արմատ + Ապրանքանիշ + Ընդգծում + Ուղղաձիգ գիծ + Յեն + Անհամար + Ընդհատվող գիծ + Մյու + Մոտավոր հավասարության նշան + Անհավասարության նշան + Փոխարժեքի նշան + Բաժնի նշան + Դեպի վեր սլաք + Ձախ սլաք + Ռուփի + Սև սիրտ + Ալիքանշան + Հավասարության նշան + Վոն արտարժույթի նշան + Հղման նշան + Սպիտակ աստղ + Սև աստղ + Սպիտակ սրտի նշան + Սպիտակ շրջանակ + Սև շրջանակ + Արևի նշան + Թիրախ + Սպիտակ խաչի նշան + Սպիտակ ղառի նշան + Սպիտակ, դեպի ձախ ուղղված ցուցամատ + Սպիտակ, դեպի աջ ուղղված ցուցամատ + Շրջանակ, որի ձախ կեսը սև է + Շրջանակ, որի աջ կեսը սև է + Սպիտակ քառակուսի + Սև քառակուսի + Սպիտակ, դեպի վերև ուղղված եռանկյունի + Սպիտակ, դեպի ներքև ուղղված եռանկյունի + Սպիտակ, դեպի ձախ ուղղված եռանկյունի + Սպիտակ, դեպի աջ ուղղված եռանկյունի + Սպիտակ քյարփինջի նշան + Քառորդ նոտա + Ութերորդ նոտա + Խմբավորված տասնվեցերորդ նոտաներ + Իգական սեռի նշան + Արական սեռի նշան + Ձախ սև ոսպնյակաձև փակագիծ + Աջ սև ոսպնյակաձև փակագիծ + Ձախ անկյունաձև փակագիծ + Աջ անկյունաձև փակագիծ + Աջ սլաք + Ներքևի սլաք + «Գումարած-հանած» նշան + Տառ + Ցելսիուսի սանդղակ + Ֆարենհայտի սանդղակ + Մոտավորապես հավասար է + Ինտեգրալ + Անկյունավոր ձախ փակագիծ + Անկյունավոր աջ փակագիծ + Փոստային նշան + Դեպի վերև սև եռանկյուն + Դեպի ներքև սև եռանկյուն + Սև շեղանկյուն + Կիսալայնք կատականա միջին կետ + Փոքր սև քառակուսի + Ձախ կրկնակի անկյունավոր փակագիծ + Աջ կրկնակի անկյունավոր փակագիծ + Շրջած բացականչական նշան + Շրջած հարցական նշան + Վոն արտարժույթի նշան + Լիալայնք ստորակետ + Լիալայնք բացականչական նշան + Իդեոգրաֆիկ միջակետ + Լիալայնք հարցական նշան + Միջնակետ + Վերին կրկնակի աջ չակերտ + Իդեոգրաֆիկ ստորակետ + Լիալայնք երկկետ + Լիալայնք կետ-ստորակետ + Լիալայնք ամպերսանդ + Լիալայնք կոչանիշ + Լիալայնք ալիքանշան + Ձախ վերին կրկնակի չակերտ + Լիալայնք ձախ փակագիծ + Լիալայնք աջ փակագիծ + Լիալայնք աստղանիշ + Լիալայնք ընդգծման նշան + Աջ վերին եզակի չակերտ + Լիալայնք ձախ ձևավոր փակագիծ + Լիալայնք աջ ձևավոր փակագիծ + Լիալայնք փոքրի նշան + Լիալայնք մեծի նշան + Ձախ վերին եզակի չակերտ + diff --git a/utils/src/main/res/values-id/strings.xml b/utils/src/main/res/values-id/strings.xml new file mode 100644 index 0000000..7c6baf0 --- /dev/null +++ b/utils/src/main/res/values-id/strings.xml @@ -0,0 +1,50 @@ + + + Dari karakter %1$d hingga %2$d + Karakter %1$d + tanpa judul + %1$s disalin + huruf besar %1$s + %1$d %2$s + Menggunakan %1$s + Tekan kombinasi tombol untuk menyetel pintasan baru. Setidaknya harus menggunakan tombol ALT atau Control. + Tekan kombinasi tombol dengan kunci pengubah %1$s untuk menyetel pintasan baru. + Tidak ditetapkan + Shift + Alt + Ctrl + Telusuri + Panah Kanan + Panah Kiri + Panah Atas + Panah Bawah + Default + Karakter + Kata + Baris + Paragraf + Jendela + Penanda + Judul + Daftar + Link + Kontrol + Konten khusus + Judul + Kontrol + Link + %1$s picture in picture + %1$s di atas, %2$s di bawah + %1$s di kiri, %2$s di kanan + %1$s di kanan, %2$s di kiri + Menampilkan item %1$d hingga %2$d dari %3$d. + Menampilkan item %1$d dari %2$d. + Halaman %1$d dari %2$d + %1$d dari %2$d + %1$s (%2$s) + Keluar + Menampilkan %1$s + keyboard disembunyikan + Respons lisan aktif + Respons lisan nonaktif + diff --git a/utils/src/main/res/values-id/strings_symbols.xml b/utils/src/main/res/values-id/strings_symbols.xml new file mode 100644 index 0000000..408f1c2 --- /dev/null +++ b/utils/src/main/res/values-id/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Ampersand + Simbol kurang dari + Simbol lebih dari + Tanda bintang + Di + Garis miring terbalik + Butir + Tanda sisipan + Simbol sen + Titik Dua + Koma + Hak cipta + Tanda kurung kurawal buka + Tanda kurung kurawal tutup + Simbol derajat + Tanda bagi + Simbol Dolar + Elipsis + Tanda pisah Em + Tanda pisah En + Euro + Tanda seru + Aksen nontirus + Tanda pisah + Tanda petik dua bawah + Tanda kali + Garis baru + Tanda paragraf + Kurung buka + Kurung tutup + Persen + Titik + Pi + Pon + Simbol mata uang pound sterling + Tanda tanya + Tanda petik + Merek dagang terdaftar + Titik koma + Tanda garis miring + Spasi + Tanda kurung siku buka + Tanda kurung siku tutup + Akar pangkat dua + Merek dagang + Garis bawah + Garis vertikal + Yen + Simbol larangan + Garis putus + Simbol mikro + Hampir sama dengan + Tidak sama dengan + Simbol mata uang + Simbol paragraf + Panah atas + Panah kiri + Rupee + Hati Hitam + Tanda gelombang + Tanda sama dengan + Simbol mata uang won + Tanda Referensi + Bintang putih + Bintang hitam + Hati Putih + Lingkaran putih + Lingkaran hitam + Simbol surya + Lingkaran target + Suit keriting putih + Suit sekop putih + Telunjuk putih menunjuk ke kiri + Telunjuk putih menunjuk ke kanan + Lingkaran dengan separuh hitam sebelah kiri + Lingkaran dengan separuh hitam sebelah kanan + Persegi putih + Persegi hitam + Segitiga putih menunjuk ke atas + Segitiga putih menunjuk ke bawah + Segitiga putih menunjuk ke kiri + Segitiga putih menunjuk ke kanan + Wajik putih + Not Seperempat + Not Kedelapan + Not seperenambelas digabungkan + Simbol perempuan + Simbol pria + Kurung Lentikular Hitam Kiri + Kurung Lentikular Kanan Hitam + Kurung Sudut Kiri + Kurung Sudut Kanan + Panah Kanan + Panah Bawah + Tanda plus minus + Liter + Derajat Celcius + Derajat Fahrenheit + Kira-kira sama + Integral + Kurung buka matematika + Kurung tutup matematika + Cap pos + Segitiga hitam menunjuk ke atas + Segitiga hitam menunjuk ke bawah + Gambar wajik hitam + Huruf Katakana bertitik tengah setengah lebar + Persegi hitam kecil + Tanda kurung sudut ganda buka + Tanda kurung sudut ganda tutup + Tanda seru terbalik + Tanda tanya terbalik + Simbol mata uang won + Koma lebar penuh + Tanya seru lebar penuh + Tanda titik ideografis + Tanda tanya lebar penuh + Titik tengah + Tanda petik ganda kanan + Koma ideografis + Titik dua lebar penuh + Titik koma lebar penuh + Ampersand lebar penuh + Sirkumfleks lebar penuh + Tanda gelombang lebar penuh + Tanda petik ganda kiri + Tanda kurung buka lebar penuh + Tanda kurung tutup lebar penuh + Tanda bintang lebar penuh + Garis bawah lebar penuh + Tanda petik tunggal kanan + Tanda kurung kurawal buka lebar penuh + Tanda kurung kurawal tutup lebar penuh + Tanda lebih kecil dari lebar penuh + Tanda lebih besar dari lebar penuh + Tanda petik tunggal kiri + diff --git a/utils/src/main/res/values-in/strings.xml b/utils/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..7c6baf0 --- /dev/null +++ b/utils/src/main/res/values-in/strings.xml @@ -0,0 +1,50 @@ + + + Dari karakter %1$d hingga %2$d + Karakter %1$d + tanpa judul + %1$s disalin + huruf besar %1$s + %1$d %2$s + Menggunakan %1$s + Tekan kombinasi tombol untuk menyetel pintasan baru. Setidaknya harus menggunakan tombol ALT atau Control. + Tekan kombinasi tombol dengan kunci pengubah %1$s untuk menyetel pintasan baru. + Tidak ditetapkan + Shift + Alt + Ctrl + Telusuri + Panah Kanan + Panah Kiri + Panah Atas + Panah Bawah + Default + Karakter + Kata + Baris + Paragraf + Jendela + Penanda + Judul + Daftar + Link + Kontrol + Konten khusus + Judul + Kontrol + Link + %1$s picture in picture + %1$s di atas, %2$s di bawah + %1$s di kiri, %2$s di kanan + %1$s di kanan, %2$s di kiri + Menampilkan item %1$d hingga %2$d dari %3$d. + Menampilkan item %1$d dari %2$d. + Halaman %1$d dari %2$d + %1$d dari %2$d + %1$s (%2$s) + Keluar + Menampilkan %1$s + keyboard disembunyikan + Respons lisan aktif + Respons lisan nonaktif + diff --git a/utils/src/main/res/values-in/strings_symbols.xml b/utils/src/main/res/values-in/strings_symbols.xml new file mode 100644 index 0000000..408f1c2 --- /dev/null +++ b/utils/src/main/res/values-in/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Ampersand + Simbol kurang dari + Simbol lebih dari + Tanda bintang + Di + Garis miring terbalik + Butir + Tanda sisipan + Simbol sen + Titik Dua + Koma + Hak cipta + Tanda kurung kurawal buka + Tanda kurung kurawal tutup + Simbol derajat + Tanda bagi + Simbol Dolar + Elipsis + Tanda pisah Em + Tanda pisah En + Euro + Tanda seru + Aksen nontirus + Tanda pisah + Tanda petik dua bawah + Tanda kali + Garis baru + Tanda paragraf + Kurung buka + Kurung tutup + Persen + Titik + Pi + Pon + Simbol mata uang pound sterling + Tanda tanya + Tanda petik + Merek dagang terdaftar + Titik koma + Tanda garis miring + Spasi + Tanda kurung siku buka + Tanda kurung siku tutup + Akar pangkat dua + Merek dagang + Garis bawah + Garis vertikal + Yen + Simbol larangan + Garis putus + Simbol mikro + Hampir sama dengan + Tidak sama dengan + Simbol mata uang + Simbol paragraf + Panah atas + Panah kiri + Rupee + Hati Hitam + Tanda gelombang + Tanda sama dengan + Simbol mata uang won + Tanda Referensi + Bintang putih + Bintang hitam + Hati Putih + Lingkaran putih + Lingkaran hitam + Simbol surya + Lingkaran target + Suit keriting putih + Suit sekop putih + Telunjuk putih menunjuk ke kiri + Telunjuk putih menunjuk ke kanan + Lingkaran dengan separuh hitam sebelah kiri + Lingkaran dengan separuh hitam sebelah kanan + Persegi putih + Persegi hitam + Segitiga putih menunjuk ke atas + Segitiga putih menunjuk ke bawah + Segitiga putih menunjuk ke kiri + Segitiga putih menunjuk ke kanan + Wajik putih + Not Seperempat + Not Kedelapan + Not seperenambelas digabungkan + Simbol perempuan + Simbol pria + Kurung Lentikular Hitam Kiri + Kurung Lentikular Kanan Hitam + Kurung Sudut Kiri + Kurung Sudut Kanan + Panah Kanan + Panah Bawah + Tanda plus minus + Liter + Derajat Celcius + Derajat Fahrenheit + Kira-kira sama + Integral + Kurung buka matematika + Kurung tutup matematika + Cap pos + Segitiga hitam menunjuk ke atas + Segitiga hitam menunjuk ke bawah + Gambar wajik hitam + Huruf Katakana bertitik tengah setengah lebar + Persegi hitam kecil + Tanda kurung sudut ganda buka + Tanda kurung sudut ganda tutup + Tanda seru terbalik + Tanda tanya terbalik + Simbol mata uang won + Koma lebar penuh + Tanya seru lebar penuh + Tanda titik ideografis + Tanda tanya lebar penuh + Titik tengah + Tanda petik ganda kanan + Koma ideografis + Titik dua lebar penuh + Titik koma lebar penuh + Ampersand lebar penuh + Sirkumfleks lebar penuh + Tanda gelombang lebar penuh + Tanda petik ganda kiri + Tanda kurung buka lebar penuh + Tanda kurung tutup lebar penuh + Tanda bintang lebar penuh + Garis bawah lebar penuh + Tanda petik tunggal kanan + Tanda kurung kurawal buka lebar penuh + Tanda kurung kurawal tutup lebar penuh + Tanda lebih kecil dari lebar penuh + Tanda lebih besar dari lebar penuh + Tanda petik tunggal kiri + diff --git a/utils/src/main/res/values-is/strings.xml b/utils/src/main/res/values-is/strings.xml new file mode 100644 index 0000000..340a89f --- /dev/null +++ b/utils/src/main/res/values-is/strings.xml @@ -0,0 +1,50 @@ + + + Stafir %1$d til %2$d + Stafur %1$d + ónefnt + afritað, %1$s + hástafurinn %1$s + %1$d %2$s + %1$s í notkun + Ýttu á lyklasamsetningu til að vista nýja flýtileið. Hún verður að innihalda að minnsta kosti Alt-lykilinn eða Control-lykilinn. + Til að búa til nýja flýtileið skaltu ýta á lyklasamsetningu með breytilyklinum %1$s. + Ekki úthlutað + Shift-lykill + Alt + Ctrl + Leita + Ör til hægri + Ör til vinstri + Ör upp + Ör niður + Sjálfgefið + Stafir + Orð + Línur + Efnisgreinar + Windows + Kennileiti + Fyrirsagnir + Listar + Tenglar + Stýringar + Sérstakt efni + Fyrirsagnir + Stýringar + Tenglar + %1$s mynd í mynd + %1$s fyrir ofan, %2$s fyrir neðan + %1$s til vinstri, %2$s til hægri + %1$s til hægri, %2$s til vinstri + Sýnir atriði %1$d til %2$d af %3$d. + Sýnir atriði %1$d af %2$d. + Síða %1$d af %2$d + %1$d af %2$d + %1$s (%2$s) + Loka + Sýnir %1$s + lyklaborð falið + Kveikt er á raddsvörun + Slökkt er á raddsvörun + diff --git a/utils/src/main/res/values-is/strings_symbols.xml b/utils/src/main/res/values-is/strings_symbols.xml new file mode 100644 index 0000000..26c8067 --- /dev/null +++ b/utils/src/main/res/values-is/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Úrfellingarkomma + Og-merki + Minna en-merki + Meira en-merki + Stjarna + Att-merki + Öfugt skástrik + Áherslumerki + Innskotsmerki + Sent-merki + Tvípunktur + Komma + Höfundarréttur + Vinstri slaufusvigi + Hægri slaufusvigi + Gráðumerki + Deilingarmerki + Dollaramerki + Þrípunktur + Langt þankastrik + Stutt þankastrik + Evra + Upphrópunarmerki + Öfugur broddur + Bandstrik + Gæsalappir opnast + Margföldunarmerki + Ný lína + Efnisgreinarmerki + Vinstri svigi + Hægri svigi + Prósent + Punktur + + Myllumerki + Gjaldmiðilsmerki sterlingspunds + Spurningarmerki + Gæsalappir + Skráð vörumerki + Semíkomma + Skástrik + Bil + Vinstri hornklofi + Hægri hornklofi + Kvaðratrót + Vörumerki + Undirstrik + Lóðrétt strik + Yen + Ekki-merki + Brotin pípa + Míkrómerki + Um það bil + Ekki jafnt og + Gjaldmiðilsmerki + Kaflamerki + Ör upp + Ör til vinstri + Rúpía + Fyllt hjarta + Tilda + Samasemmerki + Gjaldmiðilsmerki Won + Tilvísunarmerki + Ófyllt stjarna + Fyllt stjarna + Ófyllt hjarta + Ófylltur hringur + Fylltur hringur + Sólartákn + Skotmark + Ófyllt lauf + Ófylltur spaði + Ófylltur vísifingur sem bendir til vinstri + Ófylltur vísifingur sem bendir til hægri + Hringur fylltur vinstra megin + Hringur fylltur hægra megin + Ófylltur ferningur + Fylltur ferningur + Ófylltur þríhyrningur sem vísar upp + Ófylltur þríhyrningur sem vísar niður + Ófylltur þríhyrningur sem vísar til vinstri + Ófylltur þríhyrningur sem vísar til hægri + Ófylltur tígull + Fjórðapartsnóta + Áttundapartsnóta + Sextándapartsnótur á bjálka + Kvenkynstákn + Karlkynstákn + Fylltur kúptur vinstrisvigi + Fylltur kúptur hægrisvigi + Vinstri hornsvigi + Hægri hornsvigi + Hægriör + Niðurör + Plúsmínus-merki + Lítri + Celsíus-gráða + Fahrenheit-gráða + Námundunarmerki + Heildunarmerki + Stærðfræðilegt „minna en“ tákn + Stærðfræðilegt „stærra en“ tákn + Póststimpill + Svartur þríhyrningur sem vísar upp + Svartur þríhyrningur sem vísar niður + Svartur tígull + Miðpunktur Katakana í hálfri breidd + Lítill svartur ferningur + Tvöfaldur vinstri oddklofi + Tvöfaldur hægri oddklofi + Öfugt upphrópunarmerki + Öfugt spurningarmerki + Gjaldmiðilsmerki Won + Komma í fullri breidd + Upphrópunarmerki í fullri breidd + Myndleturstáknspunktur + Spurningarmerki í fullri breidd + Miðjupunktur + Tvöfaldar hægri gæsalappir + Myndleturstáknskomma + Tvípunktur í fullri breidd + Semíkomma í fullri breidd + Táknið & í fullri breidd + Tvíbroddur í fullri breidd + Tilda í fullri breidd + Tvöfaldar vinstri gæsalappir + Vinstri svigi í fullri breidd + Hægri svigi í fullri breidd + Stjörnumerki í fullri breidd + Undirstrik í fullri breidd + Einfaldar hægri gæsalappir + Vinstri slaufusvigi í fullri breidd + Hægri slaufusvigi í fullri breidd + Vinstri fleygur í fullri breidd + Hægri fleygur í fullri breidd + Einfaldar vinstri gæsalappir + diff --git a/utils/src/main/res/values-it/strings.xml b/utils/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..b218480 --- /dev/null +++ b/utils/src/main/res/values-it/strings.xml @@ -0,0 +1,50 @@ + + + Caratteri da %1$d a %2$d + Carattere %1$d + senza titolo + copiato, %1$s + %1$s maiuscola + %1$d %2$s + In uso: %1$s + Premi una combinazione di tasti per impostare la nuova scorciatoia. Includi nella combinazione almeno un tasto tra ALT o Ctrl. + Per impostare la nuova scorciatoia, premi la combinazione di tasti insieme al tasto di modifica %1$s. + Non assegnata + MAIUSC + ALT + CTRL + Ricerca + Freccia destra + Freccia sinistra + Freccia su + Freccia giù + Predefinita + Caratteri + Parole + Righe + Paragrafi + Finestre + Punti di riferimento + Intestazioni + Elenchi + Link + Controlli + Contenuti speciali + Intestazioni + Controlli + Link + %1$s Picture in picture + %1$s in alto, %2$s in basso + %1$s a sinistra, %2$s a destra + %1$s a destra, %2$s a sinistra + Elementi da %1$d a %2$d su %3$d visualizzati. + %1$d elemento su %2$d visualizzato. + Pagina %1$d di %2$d + %1$d di %2$d + %1$s (%2$s) + Esci + È visualizzata la finestra %1$s + Tastiera nascosta + La funzione di lettura vocale è attiva + La funzione di lettura vocale non è attiva + diff --git a/utils/src/main/res/values-it/strings_symbols.xml b/utils/src/main/res/values-it/strings_symbols.xml new file mode 100644 index 0000000..ada593a --- /dev/null +++ b/utils/src/main/res/values-it/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrofo + E commerciale + Segno di minore + Segno di maggiore + Asterisco + Chiocciola + Barra rovesciata + Punto elenco + Accento circonflesso + Simbolo dei centesimi + Due punti + Virgola + Copyright + Parentesi graffa aperta + Parentesi graffa chiusa + Simbolo dei gradi + Segno di divisione + Simbolo del dollaro + Puntini di sospensione + Lineetta + Trattino + Euro + Punto esclamativo + Accento grave + Trattino breve + Virgolette doppie basse + Segno di moltiplicazione + Nuova riga + Segno di paragrafo + Parentesi aperta + Parentesi chiusa + Percentuale + Punto + Pi greco + Cancelletto + Simbolo della sterlina + Punto interrogativo + Virgolette + Marchio registrato + Punto e virgola + Barra + Spazio + Parentesi quadra aperta + Parentesi quadra chiusa + Radice quadrata + Marchio + Carattere di sottolineatura + Linea verticale + Yen + Segno di negazione + Barra verticale interrotta + Simbolo micro + Quasi uguale a + Non uguale a + Segno di valuta + Simbolo di paragrafo + Freccia su + Freccia sinistra + Rupia + Cuore nero + Tilde + Segno di uguale + Simbolo del won + Segno di riferimento + Stella bianca + Stella nera + Cuore bianco + Cerchio bianco + Cerchio nero + Simbolo solare + Bersaglio + Fiori (seme, bianco) + Picche (seme, bianco) + Indice bianco che punta a sinistra + Indice bianco che punta a destra + Cerchio con metà sinistra nera + Cerchio con metà destra nera + Quadrato bianco + Quadrato nero + Triangolo verso l\'alto bianco + Triangolo verso il basso bianco + Triangolo verso sinistra bianco + Triangolo verso destra bianco + Rombo bianco + Semiminima + Ottava nota + Semicroma + Simbolo della donna + Simbolo dell\'uomo + Parentesi lenticolare aperta nera + Parentesi lenticolare chiusa nera + Parentesi ad angolo aperta + Parentesi ad angolo chiusa + Freccia destra + Freccia giù + Segno più-o-meno + Litro + Grado Celsius + Grado Fahrenheit + Quasi uguale a + Integrale + Parentesi angolare aperta + Parentesi angolare chiusa + Simbolo postale + Triangolo nero rivolto verso l\'alto + Triangolo nero rivolto verso il basso + Diamanti neri (seme) + Punto centrale katakana spessore ridotto + Quadratino nero + Doppia parentesi angolare aperta + Doppia parentesi angolare chiusa + Punto esclamativo invertito + Punto interrogativo invertito + Simbolo del won + Virgola spessore pieno + Punto esclamativo spessore pieno + Punto ideografico + Punto interrogativo spessore pieno + Punto centrale + Virgolette doppie chiuse + Virgola ideografica + Due punti spessore pieno + Punto e virgola spessore pieno + E commerciale spessore pieno + Accento circonflesso spessore pieno + Tilde spessore pieno + Virgolette doppie aperte + Parentesi aperta spessore pieno + Parentesi chiusa spessore pieno + Asterisco spessore pieno + Trattino basso spessore pieno + Virgoletta singola chiusa + Parentesi graffa aperta spessore pieno + Parentesi graffa chiusa spessore pieno + Simbolo minore di spessore pieno + Simbolo maggiore di spessore pieno + Virgoletta singola aperta + diff --git a/utils/src/main/res/values-iw/strings.xml b/utils/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..4c61f78 --- /dev/null +++ b/utils/src/main/res/values-iw/strings.xml @@ -0,0 +1,50 @@ + + + תווים %1$d עד %2$d + תו %1$d + ללא שם + מועתק, %1$s + %1$s גדולה + %1$d %2$s + משתמש ב-%1$s + ‏הקש על שילוב מקשים כדי להגדיר קיצור חדש. הוא צריך להכיל לפחות את המקש ALT או Control. + הקש על צירוף מקשים הכולל את מקש הצירוף %1$s כדי להגדיר מקש קיצור חדש. + ללא הקצאה + Shift + Alt + Ctrl + חיפוש + חץ ימינה + חץ שמאלה + חץ למעלה + חץ למטה + ברירת מחדל + תווים + מילים + קווים + פסקאות + חלונות + ציוני דרך + כותרות + רשימות + קישורים + פקדים + תוכן מיוחד + כותרות + פקדים + קישורים + %1$s תמונה בתוך תמונה + %1$s למעלה, %2$s למטה + %1$s מצד שמאל, %2$s מצד ימין + %1$s מצד ימין, %2$s מצד שמאל + מציג פריטים %1$d עד %2$d מתוך %3$d. + מציג פריט %1$d מתוך %2$d. + עמוד %1$d מתוך %2$d + %1$d מתוך %2$d + %1$s (%2$s) + יציאה + המערכת מציגה %1$s + המקלדת מוסתרת + קורא מסך קולי מופעל + קורא מסך קולי מושבת + diff --git a/utils/src/main/res/values-iw/strings_symbols.xml b/utils/src/main/res/values-iw/strings_symbols.xml new file mode 100644 index 0000000..c374e39 --- /dev/null +++ b/utils/src/main/res/values-iw/strings_symbols.xml @@ -0,0 +1,140 @@ + + + גרש + אמפרסנד + הסימן פחות מ- + הסימן גדול מ- + כוכבית + שטרודל + קו נטוי הפוך + תבליט + קארה + סימן של סנט + נקודתיים + פסיק + זכויות יוצרים + סוגר מסולסל שמאלי + סוגר מסולסל ימני + סימן המעלה + סימן חילוק + סימן דולר + שלוש נקודות + מקף ארוך + מקף בינוני + יורו + סימן קריאה + ‏סימון הטעמה מסוג Grave + מקף + מירכאות כפולות תחתונות + סימן כפל + שורה חדשה + סימן פיסקה + סוגר שמאלי + סוגר ימני + אחוז + נקודה + פאי + סולמית + סימן המטבע פאונד + סימן שאלה + גרש + סימן מסחרי רשום + נקודה ופסיק + קו נטוי + רווח + סוגר מרובע שמאלי + סוגר מרובע ימני + שורש ריבועי + סימן מסחרי + מקף תחתון + קו אנכי + ין + סימן \'אינו\' + פס חצוי + סימן מיקרו + כמעט שווה ל- + לא שווה + סימן מטבע + סימן קטע + חץ למעלה + חץ שמאלה + רופיות + לב שחור + טילדה + סימן שוויון + סימן המטבע של וון דרום קוריאני + סימן ייחוס + כוכב לבן + כוכב שחור + לב לבן + מעגל לבן + עיגול שחור + סימן שמש + מרכז המטרה + סימן תלתן לבן + סימן עלה לבן + אצבע לבנה מצביעה שמאלה + אצבע לבנה מצביעה ימינה + מעגל שחציו השמאלי שחור + מעגל שחציו הימני שחור + ריבוע לבן + ריבוע שחור + משולש לבן מצביע למעלה + משולש לבן מצביע למטה + משולש לבן מצביע שמאלה + משולש לבן מצביע ימינה + יהלום לבן + תו רבע + תו שמינית + תווים במקצב חלקי שש עשרה + סמל נקבה + סמל זכר + סוגר שמאלי שחור בצורת עדשה + סוגר ימני שחור בצורת עדשה + סוגר פינתי שמאלי + סוגר פינתי ימני + חץ ימינה + חץ למטה + סימן פלוס מינוס + ליטר + מעלות צלזיוס + מעלות פרנהייט + שווה בערך + אינטגרל + סוגר זוויתי שמאלי מתמטי + סוגר זוויתי ימני מתמטי + סימן דואר + משולש שחור שמצביע למעלה + משולש שחור שמצביע למטה + סדרת יהלום בצבע שחור + נקודה אמצעית קטאקאנה בחצי רוחב + ריבוע שחור קטן + סוגר שמאלי בעל זווית כפולה + סוגר ימני בעל זווית כפולה + סימן קריאה הפוך + סימן שאלה הפוך + סימן המטבע של וון דרום קוריאני + פסיק ברוחב מלא + סימן קריאה ברוחב מלא + נקודה אידאוגרפית + סימן שאלה ברוחב מלא + נקודה אמצעית + מירכאות כפולות ימניות + פסיק אידאוגרפי + נקודתיים ברוחב מלא + נקודה-פסיק ברוחב מלא + אמפרסנד ברוחב מלא + גג ברוחב מלא + טילדה ברוחב מלא + מירכאות כפולות שמאליות + סוגר שמאלי ברוחב מלא + סוגר ימני ברוחב מלא + כוכבית ברוחב מלא + קו תחתון ברוחב מלא + סימן גרש ימני יחיד + סוגר מסולסל שמאלי ברוחב מלא + סוגר מסולסל ימני ברוחב מלא + סימן \'קטן מ-\' ברוחב מלא + סימן \'גדול מ-\' ברוחב מלא + סימן גרש שמאלי יחיד + diff --git a/utils/src/main/res/values-ja/strings.xml b/utils/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..b349549 --- /dev/null +++ b/utils/src/main/res/values-ja/strings.xml @@ -0,0 +1,50 @@ + + + 文字 %1$d~%2$d + %1$d文字目 + 無題 + コピーしました、%1$s + 大文字の%1$s + %1$d個の%2$s + %1$sを使用しています + キーの組み合わせを押して新しいショートカットを設定します。AltキーまたはCtrlキーのどちらかを必ず使用してください。 + 新しいショートカットを設定するには、修飾キーとして %1$s キーを使ったキーの組み合わせを押してください。 + 未割り当て + Shift + Alt + Ctrl + 検索 + 右矢印 + 左矢印 + 上矢印 + 下矢印 + デフォルト + 文字 + 単語 + + 段落 + ウィンドウ + ランドマーク + 見出し + リスト + リンク + コントロール + 特殊コンテンツ + 見出し + コントロール + リンク + %1$s はピクチャー イン ピクチャーです + 上側が %1$s で、下側が %2$s です + 左側が %1$s、右側が %2$s です + 右側が %1$s で、左側が %2$s です + %3$d件中%1$d件目から%2$d件目までを表示しています。 + %2$d件中%1$d件目を表示しています。 + %2$d ページ中 %1$d ページ目です + %2$d ページ中 %1$d ページ目です + %1$s(%2$s) + 終了 + %1$sを表示しています + キーボードは非表示です + 音声フィードバックが ON です + 音声フィードバックが OFF です + diff --git a/utils/src/main/res/values-ja/strings_symbols.xml b/utils/src/main/res/values-ja/strings_symbols.xml new file mode 100644 index 0000000..dcf802d --- /dev/null +++ b/utils/src/main/res/values-ja/strings_symbols.xml @@ -0,0 +1,140 @@ + + + アポストロフィ + アンパサンド + 小なり記号 + 大なり記号 + アスタリスク + アットマーク + バックスラッシュ + 箇条書き記号 + キャレット + セント記号 + コロン + カンマ + 著作権 + 左中かっこ + 右中かっこ + 度記号 + 除算記号 + ドル記号 + 省略記号 + 全角ダッシュ + 半角ダッシュ + ユーロ + 感嘆符 + グレーブ + ダッシュ + 下付き二重引用符 + 乗算記号 + 改行 + 段落記号 + 左かっこ + 右かっこ + パーセント + ピリオド + 円周率記号 + 井げた + ポンド記号 + 疑問符 + 引用符 + 登録商標 + セミコロン + スラッシュ + スペース + 左角かっこ + 右角かっこ + 平方根 + 商標 + アンダースコア + 縦棒 + + 否定記号 + ブロークンバー + マイクロ記号 + ニアリーイコール記号 + ノットイコール記号 + 通貨記号 + 節記号 + 上矢印 + 左矢印 + ルピー + 黒ハート + 波形符号 + 等記号 + ウォン記号 + コメ印 + 白星 + 黒星 + 白ハート + 白丸 + 黒丸 + 太陽記号 + 二重丸 + 白クローバー + 白スペード + 白左人差し指 + 白右人差し指 + 左半分が黒い円 + 右半分が黒い円 + 白四角 + 黒四角 + 上向き白三角 + 下向き白三角 + 左向き白三角 + 右向き白三角 + 白ダイヤ + 4分音符 + 8分音符 + 連桁付き16分音符 + 女性記号 + 男性記号 + 左隅付き括弧 + 右隅付き括弧 + 左かぎ括弧 + 右かぎ括弧 + 右矢印 + 下矢印 + プラスマイナス記号 + リットル + 摂氏温度 + 華氏温度 + ほぼ等しい + 積分記号 + 左山かっこです + 右山かっこです + 郵便記号 + 上を指差す黒三角形 + 下を指差す黒三角形 + 黒いダイヤモンド形 + 半角のカタカナ中黒 + 小さい黒四角 + 左二重山かっこ + 右二重山かっこ + 倒立感嘆符 + 倒立疑問符 + ウォン記号 + 全角カンマ + 全角感嘆符 + 全角句点 + 全角疑問符 + 中黒 + 右二重引用符 + 全角読点 + 全角コロン + 全角セミコロン + 全角アンパサンド + 全角サーカムフレックス + 全角チルダ + 左二重引用符 + 全角左かっこ + 全角右かっこ + 全角アスタリスク + 全角アンダースコア + 右引用符 + 全角左中かっこ + 全角右中かっこ + 全角小なり記号 + 全角大なり記号 + 左引用符 + diff --git a/utils/src/main/res/values-ka/strings.xml b/utils/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..af00e09 --- /dev/null +++ b/utils/src/main/res/values-ka/strings.xml @@ -0,0 +1,50 @@ + + + სიმბოლო %1$d-დან %2$d-მდე + სიმბოლო %1$d + უსათაურო + დაკოპირებულია, %1$s + მაღალი რეგისტრის %1$s + %1$d %2$s + გამოიყენება %1$s + ახალი მალსახმობის დასაყენებლად, დააჭირეთ კლავიშთა კომბინაციას. ის უნდა შედგებოდეს ALT ან Control კლავიშისგან მაინც. + ახალი მალსახმობის დასაყენებლად, დააჭირეთ კლავიშთა კომბინაციას %1$s მოდიფიკატორ კლავიშთან ერთად. + მიუმაგრებელი + Shift + Alt + Ctrl + ძიება + ისარი მარჯვნივ + ისარი მარცხნივ + ისარი ზემოთ + ისარი ქვემოთ + ნაგულისხმევი + ასოები + სიტყვები + ხაზები + აბზაცები + ფანჯრები + ორიენტირები + სათაურები + სიები + ბმულები + მართვის საშუალებები + სპეციალური კონტენტი + სათაურები + მართვის საშუალებები + ბმულები + %1$s „ეკრანი ეკრანში“ + %1$s ზემოთ, %2$s ქვემოთ + %1$s მარცხნივ, %2$s მარჯვნივ + %1$s მარჯვნივ, %2$s მარცხნივ + ნაჩვენებია %1$d-დან %2$d-მდე / სულ %3$d-დან. + ნაჩვენებია %1$d ერთული %2$d-დან. + გვერდი %1$d / %2$d-დან + %1$d / %2$d-დან + %1$s (%2$s) + გასვლა + ნაჩვენებია %1$s + კლავიატურა დამალულია + გახმოვანებული უკუკავშირი ჩართულია + გახმოვანებული უკუკავშირი გამორთულია + diff --git a/utils/src/main/res/values-ka/strings_symbols.xml b/utils/src/main/res/values-ka/strings_symbols.xml new file mode 100644 index 0000000..d068621 --- /dev/null +++ b/utils/src/main/res/values-ka/strings_symbols.xml @@ -0,0 +1,140 @@ + + + აპოსტროფი + ამპერსანდი + ნაკლებობის ნიშანი + მეტობის ნიშანი + ვარსკვლავი + ეტ ნიშანი + უკუწილადი + ბურთულა + კარეტის ნიშანი + ცენტის ნიშანი + ორწერტილი + მძიმე + საავტორო უფლებები + მარცხენა ფიგურული ფრჩხილი + მარჯვენა ფიგურული ფრჩხილი + გრადუსის ნიშანი + გაყოფის ნიშანი + დოლარის ნიშანი + სამწერტილი + გრძელი ტირე + საშუალო ტირე + ევრო + ძახილის ნიშანი + გრავისი + ტირე + ორმაგი ქვედა ბრჭყალი + გამრავლების ნიშანი + ახალი ხაზი + პარაგრაფის ნიშანი + მარცხენა ფრჩხილი + მარჯვენა ფრჩხილი + პროცენტი + წერტილი + პი + დიეზი + გირვანქა სტერლინგის ნიშანი + კითხვის ნიშანი + ბრჭყალი + რეგისტრირებული სავაჭრო ნიშანი + წერტილმძიმე + წილადი + შორისი + მარცხენა კვადრატული ფრჩხილი + მარჯვენა კვადრატული ფრჩხილი + კვადრატული ფესვი + სავაჭრო ნიშანი + ქვედატირე + ვერტიკალური ხაზი + იენი + უარყოფის ნიშანი + გაწყვეტილი ვერტიკალური ხაზი + ნიშანი მიკრო + თითქმის ტოლობა + არ უდრის + ვალუტის ნიშანი + სექციის ნიშანი + ისარი ზევით + ისარი მარცხნივ + რუპია + შავი გული + ტილდა + ტოლობის ნიშანი + ვონის სავალუტო ნიშანი + სქოლიოს ნიშანი + თეთრი ვარსკვლავი + შავი ვარსკვლავი + თეთრი გული + თეთრი წრე + შავი წრე + მზის სიმბოლო + სამიზნე + თეთრი ჯვარი + თეთრი პიკი + მარცხნივ მიმართული თეთრი ინდექსი + მარჯვნივ მიმართული თეთრი ინდექსი + წრე შავი მარცხენა ნახევრით + წრე შავი მარჯვენა ნახევრით + თეთრი კვადრატი + შავი კვადრატი + ზემოთ მიმართული თეთრი სამკუთხედი + ქვემოთ მიმართული თეთრი სამკუთხედი + მარცხნივ მიმართული თეთრი სამკუთხედი + მარჯვნივ მიმართული თეთრი სამკუთხედი + თეთრი აგური + მეოთხედი ნოტი + მერვედი ნოტი + სხივით გადაცემული მეთექვსმეტედი ნოტები + ქალის სიმბოლო + მამაკაცის სიმბოლო + მარცხენა შავი ლენტიკულარული ფრჩხილი + მარჯვენა შავი ლენტიკულარული ფრჩხილი + მარცხენა კუთხოვანი ფრჩხილი + მარჯვენა კუთხოვანი ფრჩხილი + მარჯვნივ მიმართული ისარი + ქვემოთ მიმართული ისარი + პლუს-მინუსის ნიშანი + ლიტრი + ცელსიუსის გრადუსი + ფარენჰეიტის გრადუსი + დაახლოებით უდრის + ინტეგრალი + მარცხენა მათემატიკური ფრჩხილი + მარჯვენა მათემატიკური ფრჩხილი + საფოსტო ნიშანი + შავი სამკუთხედი, რომელიც ზემოთ მიუთითებს + შავი სამკუთხედი, რომელიც ქვემოთ მიუთითებს + შავი კოსტიუმი ბრილიანტებით + ნახევარსიგანიანი კატაკანის შუაწერტილი + პატარა შავი კვადრატი + ორმაგი ნაკლებობის ნიშანი + ორმაგი მეტობის ნიშანი + ამობრუნებული ძახილის ნიშანი + ამობრუნებული კითხვის ნიშანი + ვონის სავალუტო ნიშანი + სრული სიგანის მძიმე + სრული სიგანის ძახილის ნიშანი + იდეოგრაფიული წერტილი + სრული სიგანის კითხვის ნიშანი + შუაწერტილი + მარჯვენა ორმაგი ბრჭყალი + იდეოგრაფიული მძიმე + სრული სიგანის ორწერტილი + სრული სიგანის წერტილმძიმე + სრული სიგანის ამპერსანდი + სრული სიგანის ცირკუმფლექსი + სრული სიგანის ტილდა + მარცხენა ორმაგი ბრჭყალი + სრული სიგანის მარცხენა ფრჩხილი + სრული სიგანის მარჯვენა ფრჩხილი + სრული სიგანის ვარსკვლავი + სრული სიგანის ქვედა ტირე + მარჯვენა ერთმაგი ბრჭყალი + სრული სიგანის მარცხენა ფიგურული ფრჩხილი + სრული სიგანის მარჯვენა ფიგურული ფრჩხილი + სრული სიგანის ნაკლებობის ნიშანი + სრული სიგანის მეტობის ნიშანი + მარცხენა ერთმაგი ბრჭყალი + diff --git a/utils/src/main/res/values-kk/strings.xml b/utils/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000..85138cd --- /dev/null +++ b/utils/src/main/res/values-kk/strings.xml @@ -0,0 +1,50 @@ + + + %1$d-%2$d таңбалары + \"%1$d\" таңбасы + атаусыз + көшірілді, %1$s + бас %1$s + %1$d %2$s + %1$s пайдаланылуда + Жаңа таңбаша орнату үшін пернелер тіркесімін басыңыз. Ол кемінде ALT немесе Control пернесін қамтуы керек. + Жаңа перне тіркесімін орнату үшін оны %1$s өзгерткіш пернесімен бірге басыңыз. + Тағайындалмаған + Shift + Alt + Ctrl + Іздеу + Оң жақ көрсеткісі + Сол жақ көрсеткісі + Жоғары жақ көрсеткісі + Төмен жақ көрсеткісі + Әдепкі + Таңбалар + Сөздер + Жолдар + Абзацтар + Терезелер + Көрнекті белгілер + Тақырыптар + Тізімдер + Сілтемелер + Басқару элементтері + Арнайы мазмұн + Тақырыптар + Басқару элементтері + Сілтемелер + %1$s суреттегі суреті + %1$s үстінде, %2$s астында + %1$s сол жақта, %2$s оң жақта + %1$s оң жақта, %2$s сол жақта + %3$d ішінен %1$d – %2$d элемент көрсетілген. + %2$d ішінен %1$d элемент көрсетілген. + Бет: %1$d/%2$d + %1$d/%2$d + %1$s (%2$s) + Шығу + %1$s ашылды. + Пернетақтасы жасырылды. + Ауызша пікір функциясы қосулы + Ауызша пікір функциясы өшірулі + diff --git a/utils/src/main/res/values-kk/strings_symbols.xml b/utils/src/main/res/values-kk/strings_symbols.xml new file mode 100644 index 0000000..7ebf771 --- /dev/null +++ b/utils/src/main/res/values-kk/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Апостроф + Амперсанд + Кішілік белгісі + Үлкендік белгісі + Жұлдызша + At белгісі + Кері қиғаш сызық + Таңбалауыш + Кірістіру белгісі + Цент белгісі + Қос нүкте + Үтір + Авторлық құқық + Сол жақ ирек жақша + Оң жақ ирек жақша + Градус белгісі + Бөлу белгісі + Доллар белгісі + Көп нүкте + Ұзын сызықша + Қысқа сызықша + Еуро + Леп белгісі + Гравис + Сызық + Төмен қос тырнақша + Көбейту белгісі + Жаңа жол + Еже белгі + Сол жақша + Оң жақша + Пайыз + Кезең + Пи + Фунт + Фунт стерлинг белгісі + Сұрақ белгісі + Дәйексөз + Тіркелген сауда белгісі + Нүктелі үтір + Қиғаш сызық + Бос орын + Сол жақ тік жақша + Оң жақ тік жақша + Шаршы түбір + Сауда белгісі + Астыңғы сызық + Тік сызық + Йена + Емес белгісі + Тік сызық + Микро белгісі + Жуықтық белгісі + Тең емес + Валюта белгісі + Бөлім белгісі + Жоғары көрсеткі + Солға көрсеткісі + Рупий + Қара жүрек + Тильда + Тең белгісі + Вон валютасының белгісі + Анықтама белгісі + Ақ жұлдыз + Қара жұлдыз + Ақ жүрек + Ақ шеңбер + Қара шеңбер + Күн таңбасы + Өгіздің көзі + Ақ клуб костюмі + Ақ күректер жинағы + Ақ сол жаққа көрсететін көрсеткіш + Ақ оң жаққа көрсететін көрсеткіш + Сол жақ жартысы қара шеңбер + Оң жақ жартысы қара шеңбер + Ақ шаршы + Қара шаршы + Ақ жоғары көрсететін үшбұрыш + Ақ төмен көрсететін үшбұрыш + Ақ сол жаққа көрсететін үшбұрыш + Ақ оң жаққа көрсететін үшбұрыш + Ақ бриллиант + Ширек ескертпе + Сегізінші ескертпе + Таратылатын он алтыншы ескертпелері + Әйел таңбасы + Еркек таңбасы + Сол жақ қара линза тәрізді жақша + Оң жақ қара линза тәрізді жақша + Сол жақ бұрыштық жақша + Оң жақ бұрыштық жақша + Оң жақ көрсеткі + Төмен көрсеткі + Плюс минус белгісі + Литр + Цельсий градус + Фаренгейт градусы + Шамамен келесіге тең + Интеграл + Сол жақ математикалық тік жақша + Оң жақ математикалық тік жақша + Пошта белгісі + Жоғары қаратылған қара үшбұрыш + Төмен қаратылған қара үшбұрыш + Қара ромб + Жарты енді катакана ортаңғы нүктесі + Кішкентай қара шаршы + Ашатын қос бұрыштық жақша + Жабатын қос бұрыштық жақша + Аударылған леп белгісі + Аударылған сұрақ белгісі + Вон валютасының белгісі + Толық енді үтір + Толық енді леп белгісі + Идеографиялық нүкте + Толық енді сұрақ белгісі + Ортаңғы нүкте + Оң жақ қос тырнақша + Идеографиялық үтір + Толық енді қос нүкте + Толық енді нүктелі үтір + Толық енді амперсанд + Толық енді циркумфлекс + Толық енді тильда + Ашатын қос тырнақша + Толық енді ашатын жақша + Толық енді жабатын жақша + Толық енді жұлдызша + Толық енді астыңғы сызық + Жабатын жалғыз тырнақша + Толық енді ашатын ирек жақша + Толық енді жабатын ирек жақша + Толық енді кем белгісі + Толық енді артық белгісі + Ашатын жалғыз тырнақша + diff --git a/utils/src/main/res/values-km/strings.xml b/utils/src/main/res/values-km/strings.xml new file mode 100644 index 0000000..783c3f5 --- /dev/null +++ b/utils/src/main/res/values-km/strings.xml @@ -0,0 +1,50 @@ + + + តួអក្សរ %1$d ទៅ %2$d + តួអក្សរ %1$d + គ្មានចំណងជើង + បានថតចម្លង %1$s + អក្សរ​ធំ %1$s + %1$d %2$s + ប្រើ %1$s + ចុចបន្សំក្តារចុចដើម្បីកំណត់ផ្លូវកាត់ថ្មី។ វាត្រូវមានយ៉ាងហោចណាស់ប៊ូតុង ALT ឬ Control។ + ចុចការផ្សំគ្រាប់ចុចដោយប្រើគ្រាប់ចុចបន្សំ %1$s ដើម្បីកំណត់ផ្លូវកាត់ថ្មី។ + មិនបានចាត់ + Shift + Alt + Ctrl + ស្វែងរក + ព្រួញទៅស្ដាំ + ព្រួញទៅឆ្វេង + ព្រួញឡើងលើ + ព្រួញចុះក្រោម + លំនាំដើម + តួអក្សរ + ពាក្យ + ជួរ + កឋាខណ្ឌ + វិនដូ + ទីតាំង​សម្គាល់ + ចំណងជើង + បញ្ជី + តំណ + អង្គគ្រប់គ្រង + ខ្លឹមសារពិសេស + ចំណងជើង + ការគ្រប់គ្រង + តំណ + %1$s មុខងាររូបភាពក្នុងរូបភាព + %1$s ខាងលើ %2$s ខាងក្រោម + %1$s នៅខាងឆ្វេង %2$s នៅខាងស្តាំ + %1$s នៅខាងស្តាំ %2$s នៅខាងឆ្វេង + បង្ហាញ​ធាតុ %1$d ដល់ %2$d នៃ %3$d ។ + បង្ហាញ​ធាតុ %1$d នៃ %2$d ។ + ទំព័រ %1$d នៃ %2$d + %1$d នៃ %2$d + %1$s (%2$s) + ចាកចេញ + កំពុងបង្ហាញ %1$s + បានលាក់ក្តារចុច + ការអាន​អេក្រង់​ត្រូវ​បាន​បើក + ការអានអេក្រង់ត្រូវបានបិទ + diff --git a/utils/src/main/res/values-km/strings_symbols.xml b/utils/src/main/res/values-km/strings_symbols.xml new file mode 100644 index 0000000..5714ba8 --- /dev/null +++ b/utils/src/main/res/values-km/strings_symbols.xml @@ -0,0 +1,140 @@ + + + វណ្ណយុត្ត + និង + សញ្ញា​តូចជាង + សញ្ញា​ធំជាង + សញ្ញា​ផ្កាយ + នៅ + ឆូតបកក្រោយ + ចំណុច + ការ៉ាត់ + សញ្ញា​សេន + ចុចពីរ + ក្បៀស + រក្សាសិទ្ធិ + ដង្កៀបអង្កាញ់ឆ្វេង + ដង្កៀបអង្កាញ់ស្តាំ + សញ្ញា​អង្សា + សញ្ញា​ចែក + សញ្ញាដុល្លារ + ចុចបី + ឆូតវែង + ឆូតខ្លី + អឺរ៉ូ + សញ្ញាឧទាន + បន្តក់ទម្លាក់សំឡេង + ឆូត + សញ្ញា​សម្រង់​ទាប + សញ្ញា​គុណ + បន្ទាត់​ថ្មី + សម្គាល់​កថាខណ្ឌ + វង់​ក្រចកឆ្វេង + វង់​ក្រចក​ស្ដាំ + ភាគរយ + ចុច + Pi + ផោន + សញ្ញា​រូបិយបណ្ណ​ផោន + សញ្ញាសួរ + សម្រង់ + សញ្ញា​សម្គាល់​ដែល​បាន​ចុះ​ឈ្មោះ + ក្បៀសចុច + ឆូតទៅមុខ + ដកឃ្លា + ដង្កៀបជ្រុងឆ្វេង + ដង្កៀបជ្រុងស្តាំ + ឫសការ៉េ + និក្ខិត្ត​សញ្ញា​ + សញ្ញា _ + បន្ទាត់​បញ្ឈរ + យ៉ន + គ្មាន​សញ្ញា + របារ​ខូច + សញ្ញា​មីក្រូហ្វូន + ប្រហែល + ខុសពី + សញ្ញា​រូបិយប័ណ្ណ + សញ្ញាផ្នែក + សញ្ញា​ព្រួញ​ឡើង​លើ + សញ្ញា​ព្រួញ​ទៅ​ឆ្វេង + រូពី + បេះពណ៌ខ្មៅ + សញ្ញាទឹករលក + សញ្ញាស្មើ + សញ្ញា​រូបិយបណ្ណ​វ៉ុន + សញ្ញាសេចក្តីយោង + ផ្កាយស + ផ្កាយខ្មៅ + បេះដូងស + រង្វង់ស + រង្វង់ខ្មៅ + សញ្ញាព្រះអាទិត្យ + ក្តាររង្វង់ + សញ្ញាជួង + សញ្ញាសន្លឹកប៊ិច + សន្ទស្សន៍ចង្អុលទៅខាងឆ្វេងពណ៌ស + សន្ទស្សន៍ចង្អុលខាងស្តាំពណ៌ស + រង្វង់ពណ៌ខ្មៅខាងឆ្វេងពាក់កណ្តាល + រង្វង់ពណ៌ខ្មៅខាងស្តាំពាក់កណ្តាល + ការេស + ការេខ្មៅ + ត្រីកោណចង្អុលឡើងលើពណ៌ + ត្រីកោណចង្អុលចុះក្រោមពណ៌ស + ត្រីកោណចង្អុលទៅខាងឆ្វេងពណ៌ស + ត្រីកោណចង្អុលទៅស្តាំពណ៌ស + ពេជ្រស + សញ្ញាត្រីមាស + ណោតភ្លេងទីប្រាំបី + សញ្ញាភ្លេង + និមិត្មសញ្ញាស្រី + និមិត្មសញ្ញាបុរស + តង្រៀប Lenticular ខ្មៅខាងឆ្វេង + តង្កៀប Lenticular ខ្មៅខាងស្តាំ + តង្កៀបជ្រុងខាងឆ្វេង + សញ្ញាតង្កៀបជ្រុងខាងស្តាំ + ព្រួញទៅស្តាំ + ព្រួញចុះក្រោម + សញ្ញាបូកដក + លីត្រ + ដឺក្រេស៊ែលស៊ីស + កម្រិតហ្វារិនហៃ + ប្រហែលនឹង + រួមបញ្ជូល + សញ្ញាសំណុំគណិតវិទ្យាខាងឆ្វេង + សញ្ញាសំណុំគណិតវិទ្យាខាងស្តាំ + សញ្ញា​ប្រៃសណីយ៍ + សញ្ញាត្រីកោណពណ៌ខ្មៅ​បែរឡើងលើ + សញ្ញាត្រីកោណពណ៌ខ្មៅ​ផ្កាប់ចុះក្រោម + សញ្ញាការ៉ូពណ៌ខ្មៅ + សញ្ញាចុចនៅកណ្ដាល​កាតាកាណាពាក់កណ្ដាល​ទទឹង + សញ្ញាការ៉េតូចពណ៌ខ្មៅ + សញ្ញាវង់ក្រចកទ្វេ​រាងជ្រុងខាង​ឆ្វេង + សញ្ញាវង់ក្រចកទ្វេ​រាងជ្រុងខាង​ស្ដាំ + សញ្ញាឧទានបញ្ច្រាស + សញ្ញាសួរបញ្ច្រាស + សញ្ញា​រូបិយបណ្ណ​វ៉ុន + សញ្ញាក្បៀស​ទទឹងពេញ + សញ្ញាឧទាន​ទទឹងពេញ + សញ្ញាចុចខណ្ឌនៃសញ្ញាតំណាង + សញ្ញាសួរ​ទទឹងពេញ + សញ្ញាចុចនៅកណ្តាល + សញ្ញាសម្រង់សម្ដី​ទ្វេខាងស្តាំ + សញ្ញាក្បៀស​នៃសញ្ញាតំណាង + សញ្ញាចុចពីរ​ទទឹងពេញ + សញ្ញាចំណុចក្បៀស​ទទឹងពេញ + សញ្ញានិង​ទទឹងពេញ + មានសក់ព្រួញ​ទទឹងពេញ + សញ្ញាប្រហែល​ទទឹងពេញ + សញ្ញាសម្រង់សម្ដី​ទ្វេខាងឆ្វេង + សញ្ញាវង់ក្រចក​ខាងឆ្វេង​ទទឹងពេញ + សញ្ញាវង់ក្រចក​ខាងស្ដាំ​ទទឹងពេញ + សញ្ញាផ្កាយ​ទទឹងពេញ + សញ្ញា (_) ទទឹងពេញ + សញ្ញាសម្រង់សម្ដីទោលខាងស្តាំ + សញ្ញាដង្កៀបអង្កាញ់​ខាងឆ្វេង​ទទឹងពេញ + សញ្ញាដង្កៀបអង្កាញ់​ខាងស្តាំ​ទទឹងពេញ + សញ្ញាតូចជាង​ទទឹងពេញ + សញ្ញាធំជាង​ទទឹងពេញ + សញ្ញា​សម្រង់សម្ដីទោល​ខាងឆ្វេង + diff --git a/utils/src/main/res/values-kn/strings.xml b/utils/src/main/res/values-kn/strings.xml new file mode 100644 index 0000000..d94121c --- /dev/null +++ b/utils/src/main/res/values-kn/strings.xml @@ -0,0 +1,50 @@ + + + %1$d ದಿಂದ %2$d ಅಕ್ಷರಗಳು + ಅಕ್ಷರ %1$d + ಶೀರ್ಷಿಕೆರಹಿತ + %1$s ನಕಲಿಸಲಾಗಿದೆ + ದೊಡ್ಡಕ್ಷರ %1$s + %1$d %2$s + %1$s ಬಳಸಲಾಗುತ್ತಿದೆ + ಹೊಸ ಶಾರ್ಟ್‌ಕಟ್ ಹೊಂದಿಸಲು ಕೀ ಸಂಯೋಜನೆಯನ್ನು ಒತ್ತಿರಿ. ಇದು ALT ಅಥವಾ ಕಂಟ್ರೋಲ್ ಕೀ ಹೊಂದಿರಬೇಕು. + ಹೊಸ ಶಾರ್ಟ್‌ಕಟ್ ಹೊಂದಿಸಲು %1$s ಮಾರ್ಪಡಿಸುವಿಕೆ ಕೀ ಬಳಸಿಕೊಂಡು ಕೀ ಸಂಯೋಜನೆಯನ್ನು ಒತ್ತಿರಿ. + ನಿಯೋಜಿಸದಿರುವುದು + Shift + Alt + Ctrl + ಹುಡುಕು + ಬಲ ಬಾಣದ ಗುರುತು + ಎಡ ಬಾಣದ ಗುರುತು + ಮೇಲಿನ ಬಾಣದ ಗುರುತು + ಕೆಳಗಿನ ಬಾಣದ ಗುರುತು + ಡಿಫಾಲ್ಟ್ + ಅಕ್ಷರಗಳು + ಪದಗಳು + ಸಾಲುಗಳು + ಪ್ಯಾರಾಗ್ರಾಫ್‌ಗಳು + ವಿಂಡೋಗಳು + ಲ್ಯಾಂಡ್‌ಮಾರ್ಕ್‌‌ಗಳು + ಶಿರೋನಾಮೆಗಳು + ಪಟ್ಟಿಗಳು + ಲಿಂಕ್‌ಗಳು + ನಿಯಂತ್ರಣಗಳು + ವಿಶೇಷ ವಿಷಯ + ಶಿರೋನಾಮೆಗಳು + ನಿಯಂತ್ರಣಗಳು + ಲಿಂಕ್‌ಗಳು + %1$s ಪಿಕ್ಚರ್ ಇನ್ ಪಿಕ್ಚರ್ + %1$s ಮೇಲ್ಭಾಗದಲ್ಲಿ, %2$s ಕೆಳಭಾಗದಲ್ಲಿ + %1$s ಎಡಭಾಗದಲ್ಲಿ, %2$s ಬಲಭಾಗದಲ್ಲಿ + %1$s ಬಲಭಾಗದಲ್ಲಿ, %2$s ಎಡಭಾಗದಲ್ಲಿ + %3$d ರಲ್ಲಿ %1$d ರಿಂದ %2$d ಐಟಂಗಳನ್ನು ತೋರಿಸಲಾಗುತ್ತಿದೆ. + %2$d ರಲ್ಲಿ %1$d ಐಟಂ ತೋರಿಸಲಾಗುತ್ತಿದೆ. + %2$d ರಲ್ಲಿ %1$d ಪುಟಗಳು + %2$d ರಲ್ಲಿ %1$d + %1$s (%2$s) + ನಿರ್ಗಮಿಸಿ + %1$s ಅನ್ನು ತೋರಿಸಲಾಗುತ್ತಿದೆ + ಕೀಬೋರ್ಡ್ ಅನ್ನು ಮರೆಮಾಡಲಾಗಿದೆ + ಮಾತಿನ ಪ್ರತಿಕ್ರಿಯೆ ಆನ್ ಆಗಿದೆ + ಮಾತಿನ ಪ್ರತಿಕ್ರಿಯೆ ಆಫ್ ಆಗಿದೆ + diff --git a/utils/src/main/res/values-kn/strings_symbols.xml b/utils/src/main/res/values-kn/strings_symbols.xml new file mode 100644 index 0000000..e0c567f --- /dev/null +++ b/utils/src/main/res/values-kn/strings_symbols.xml @@ -0,0 +1,140 @@ + + + ಅಪಾಸ್ಟ್ರಫಿ (Apostrophe ) + ಆಂಪರ್‌ಸೆಂಡ್ + ಕಡಿಮೆ ಸೂಚಕ ಚಿಹ್ನೆ + ಹೆಚ್ಚು ಸೂಚಕ ಚಿಹ್ನೆ + ನಕ್ಷತ್ರ ಚಿಹ್ನೆ + ಅಟ್ + ಬ್ಯಾಕ್‍ಸ್ಲ್ಯಾಷ್ + ಬುಲೆಟ್ + ಕ್ಯಾರೆಟ್ + ಸೆಂಟ್ ಚಿಹ್ನೆ + ಅಪೂರ್ಣ ವಿರಾಮ + ಅಲ್ಪವಿರಾಮ + ಹಕ್ಕುಸ್ವಾಮ್ಯ + ಎಡ ಪುಷ್ಪಾವರಣ + ಬಲ ಪುಷ್ಪಾವರಣ + ಡಿಗ್ರಿ ಚಿಹ್ನೆ + ವಿಭಾಗ ಸೈನ್ + ಡಾಲರ್ ಚಿಹ್ನೆ + ಎಲಿಪ್ಸಿಸ್ + ಎಮ್ ಡ್ಯಾಶ್ + ಎನ್ ಡ್ಯಾಶ್ + ಯೂರೊ + ಆಶ್ಚರ್ಯಸೂಚಕ ಗುರುತು + ಅನುದಾತ್ತ ಸ್ವರ + ಡ್ಯಾಶ್‌ + ಕೆಳ ಜಂಟಿ ಉದ್ಧರಣ + ಗುಣಾಕಾರ ಚಿಹ್ನೆ + ಹೊಸ ರೇಖೆ + ಪ್ಯಾರಾಗ್ರಾಫ್‌‌ ಗುರುತು + ಎಡ ಆವರಣ + ಬಲ ಆವರಣ + ಶೇಕಡಾ + ಕಾಲಾವಧಿ + ಪೈ + ಪೌಂಡ್ + ಪೌಂಡ್ ಕರೆನ್ಸಿ ಚಿಹ್ನೆ + ಪ್ರಶ್ನಾರ್ಥಕ ಗುರುತು + ಕೋಟ್‌ ಚಿಹ್ನೆ + ನೊಂದಾಯಿಸಲಾದ ಟ್ರೇಡ್‌ಮಾರ್ಕ್‌ + ಅರ್ಧ ವಿರಾಮ ಚಿಹ್ನೆ + ಸ್ಲ್ಯಾಷ್‌ + ಸ್ಪೇಸ್ + ಎಡ ಆವರಣ ಚಿಹ್ನೆ + ಬಲ ಆವರಣ ಚಿಹ್ನೆ + ವರ್ಗಮೂಲ + ಟ್ರೇಡ್‌ಮಾರ್ಕ್‌ + ಅಂಡರ್‌ಸ್ಕೋರ್ + ವರ್ಟಿಕಲ್‌ ರೇಖೆ + ಯೆನ್ + ಚಿಹ್ನೆಯಲ್ಲ + ಬ್ರೊಕನ್ ಪಟ್ಟಿ + ಮೈಕ್ರೋ ಚಿಹ್ನೆ + ಇದಕ್ಕೆ ಬಹುತೇಕ ಸಮಾನವಾಗಿದೆ + ಇದಕ್ಕೆ ಸಮನಾಗಿಲ್ಲ + ಕರೆನ್ಸಿ ಚಿಹ್ನೆ + ವಿಭಾಗ ಚಿಹ್ನೆ + ಮೇಲ್ಮುಖದ ಬಾಣ + ಎಡಗಡೆಯ ಬಾಣ + ರೂಪಾಯಿ + ಕಪ್ಪು ಹೃದಯ + ಟಿಲ್ಡ್ + ಸಮ ಚಿಹ್ನೆ + ವೋನ್ ಕರೆನ್ಸಿ ಚಿಹ್ನೆ + ಉಲ್ಲೇಖ ಗುರುತು + ಬಿಳಿ ನಕ್ಷತ್ರ + ಕಪ್ಪು ನಕ್ಷತ್ರ + ಬಿಳಿ ಹೃದಯ + ಬಿಳಿ ವೃತ್ತ + ಕಪ್ಪು ವೃತ್ತ + ಸೌರ ಚಿಹ್ನೆ + ಬುಲ್ಸ್‌ಐ + ಬಿಳಿ ಕ್ಲಬ್ ಸೂಟ್ + ಬಿಳಿ ಸ್ಪೇಡ್ ಸೂಟ್ + ಬಿಳಿ ಎಡಮುಖ ಸೂಚಿಕೆ + ಬಿಳಿ ಬಲಮುಖ ಸೂಚಕೆ + ಕಪ್ಪು ಎಡ ಅರ್ಧ ಜೊತೆಗೆ ವೃತ್ತ + ಕಪ್ಪು ಬಲ ಅರ್ಧ ಜೊತೆಗೆ ವೃತ್ತ + ಬಿಳಿ ಚೌಕ + ಕಪ್ಪು ಚೌಕ + ಬಿಳಿ ಮೇಲ್ಮುಖ ತ್ರಿಕೋನ + ಬಿಳಿ ಕೆಳಮುಖ ತ್ರಿಕೋನ + ಬಿಳಿ ಎಡಮುಖ ತ್ರಿಕೋನ + ಬಿಳಿ ಬಲಮುಖ ತ್ರಿಕೋನ + ಬಿಳಿ ವಜ್ರ + ಕ್ವಾರ್ಟರ್ ನೋಟ್ + ಎಂಟನೇ ನೋಟ್ + ಬೀಮ್ ಮಾಡಿದ ಹದಿನಾರನೆಯ ನೋಟ್‌ಗಳು + ಸ್ತ್ರೀ ಚಿಹ್ನೆ + ಪುರುಷ ಚಿಹ್ನೆ + ಎಡ ಕಪ್ಪು ಉಬ್ಬಿದ ಆವರಣ + ಬಲ ಕಪ್ಪು ಉಬ್ಬಿದ ಆವರಣ + ಎಡ ಮೂಲೆಯ ಆವರಣ + ಬಲ ಕೋನ ಆವರಣ + ಬಲಮುಖ ಬಾಣ + ಕೆಳಗಿನ ಬಾಣ + ಪ್ಲಸ್ ಮೈನಸ್ ಚಿಹ್ನೆ + ಲೀಟರ್ + ಸೆಲ್ಶಿಯಸ್ ಡಿಗ್ರಿ + ಫ್ಯಾರನ್‌ಹೀಟ್ ಡಿಗ್ರಿ + ಅಂದಾಜು ಸಮವಾಗಿರುತ್ತದೆ + ಅವಿಭಾಜ್ಯ + ಗಣಿತದ ಎಡ ಕೋನ ಆವರಣ + ಗಣಿತದ ಬಲ ಕೋನ ಆವರಣ + ಅಂಚೆ ಚಿಹ್ನೆ + ಮೇಲ್ಮುಖವಾಗಿರುವ ಕಪ್ಪು ಬಣ್ಣದ ತ್ರಿಕೋಣ + ಕೆಳಮುಖವಾಗಿರುವ ಕಪ್ಪು ಬಣ್ಣದ ತ್ರಿಕೋಣ + ಕಪ್ಪು ಬಣ್ಣದ ಸೂಟ್ ಆಫ್ ಡೈಮಂಡ್ಸ್ + ಹಾಫ್‌ವಿಡ್ತ್ ಕಟಕಾನಾ ಮಧ್ಯದ ಡಾಟ್ + ಸಣ್ಣ ಕಪ್ಪು ಬಣ್ಣದ ಚೌಕ + ಎಡಭಾಗದ ಡಬಲ್ ಆ್ಯಂಗಲ್ ಆವರಣ + ಬಲಭಾಗದ ಡಬಲ್ ಆ್ಯಂಗಲ್ ಆವರಣ + ತಲೆಕೆಳಗಾದ ಆಶ್ಚರ್ಯಸೂಚಕ ಚಿಹ್ನೆ + ತಲೆಕೆಳಗಾದ ಪ್ರಶ್ನಾರ್ಥಕ ಚಿಹ್ನೆ + ವೋನ್ ಕರೆನ್ಸಿ ಚಿಹ್ನೆ + ಪೂರ್ಣ ಅಗಲದ ಅಲ್ಪವಿರಾಮ + ಪೂರ್ಣ ಅಗಲದ ಆಶ್ಚರ್ಯಸೂಚಕ ಚಿಹ್ನೆ + ಐಡಿಯೊಗ್ರಾಫಿಕ್ ಪೂರ್ಣವಿರಾಮ + ಪೂರ್ಣ ಅಗಲದ ಪ್ರಶ್ನಾರ್ಥಕ ಚಿಹ್ನೆ + ಮಧ್ಯದ ಡಾಟ್ + ಬಲಭಾಗದ ಡಬಲ್ ಉದ್ಧರಣ ಚಿಹ್ನೆ + ಐಡಿಯೊಗ್ರಾಫಿಕ್ ಅಲ್ಪವಿರಾಮ + ಪೂರ್ಣ ಅಗಲದ ಕೋಲನ್ + ಪೂರ್ಣ ಅಗಲದ ಸೆಮಿಕೋಲನ್ + ಪೂರ್ಣ ಅಗಲದ ಆ್ಯಂಪರ್‌ಸ್ಯಾಂಡ್ + ಪೂರ್ಣ ಅಗಲದ ಸರ್ಕಮ್‌ಫ್ಲೆಕ್ಸ್ + ಪೂರ್ಣ ಅಗಲದ ಟಿಲ್ಡ್ + ಎಡಭಾಗದ ಡಬಲ್ ಉದ್ಧರಣ ಚಿಹ್ನೆ + ಪೂರ್ಣ ಅಗಲದ ಎಡ ಆವರಣ + ಪೂರ್ಣ ಅಗಲದ ಬಲ ಆವರಣ + ಪೂರ್ಣ ಅಗಲದ ನಕ್ಷತ್ರ ಚಿಹ್ನೆ + ಪೂರ್ಣ ಅಗಲದ ಅಂಡರ್‌ಸ್ಕೋರ್ + ಬಲಭಾಗದ ಸಿಂಗಲ್ ಉದ್ಧರಣ ಚಿಹ್ನೆ + ಪೂರ್ಣ ಅಗಲದ ಎಡಭಾಗದ ಪುಷ್ಪಾವರಣ + ಪೂರ್ಣ ಅಗಲದ ಬಲಭಾಗದ ಪುಷ್ಪಾವರಣ + ಪೂರ್ಣ ಅಗಲದ ಕಡಿಮೆ ಮೌಲ್ಯವನ್ನು ಸೂಚಿಸುವ ಚಿಹ್ನೆ + ಪೂರ್ಣ ಅಗಲದ ಹೆಚ್ಚು ಮೌಲ್ಯವನ್ನು ಸೂಚಿಸುವ ಚಿಹ್ನೆ + ಎಡಭಾಗದ ಏಕ ಉದ್ಧರಣ ಚಿಹ್ನೆ + diff --git a/utils/src/main/res/values-ko/bools.xml b/utils/src/main/res/values-ko/bools.xml new file mode 100644 index 0000000..3b8aa51 --- /dev/null +++ b/utils/src/main/res/values-ko/bools.xml @@ -0,0 +1,7 @@ + + + + + false + + diff --git a/utils/src/main/res/values-ko/strings.xml b/utils/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..604af52 --- /dev/null +++ b/utils/src/main/res/values-ko/strings.xml @@ -0,0 +1,50 @@ + + + %1$d부터 %2$d까지의 문자 + 문자 %1$d + 제목 없음 + %1$s 복사 완료 + 대문자 %1$s + %1$d개의 %2$s + %1$s 사용 + 새로운 바로가기를 설정하려면 키 조합을 누르세요. ALT 또는 Control 키가 포함되어야 합니다. + %1$s 특수키가 포함된 키 조합을 눌러 새로운 단축키를 설정합니다. + 지정 안함 + Shift + Alt + Ctrl + 검색 + 오른쪽 화살표 + 왼쪽 화살표 + 위쪽 화살표 + 아래쪽 화살표 + 기본값 + 문자 + 단어 + + 단락 + + 랜드마크 + 제목 + 목록 + 링크 + 컨트롤 + 특별한 콘텐츠 + 제목 + 컨트롤 + 링크 + %1$s PIP + 상단에 %1$s, 하단에 %2$s + 왼쪽에 %1$s, 오른쪽에 %2$s + 오른쪽에 %1$s, 왼쪽에 %2$s + 총 %3$d개 항목 중에서 %1$d번째에서 %2$d번째 항목을 표시 중입니다. + 총 %2$d개 항목 중에서 %1$d번째 항목을 표시 중입니다. + 총 %2$d페이지 중 %1$d페이지 + %2$d페이지 중 %1$d페이지 + %1$s(%2$s) + 종료 + %1$s 표시 중 + 키보드 숨김 + 음성 피드백 사용 설정됨 + 음성 피드백 사용 중지됨 + diff --git a/utils/src/main/res/values-ko/strings_symbols.xml b/utils/src/main/res/values-ko/strings_symbols.xml new file mode 100644 index 0000000..fe3b970 --- /dev/null +++ b/utils/src/main/res/values-ko/strings_symbols.xml @@ -0,0 +1,140 @@ + + + 작은 따옴표 + 앤드 기호 + 미만 기호 + 초과 기호 + 별표 + 골뱅이 + 역슬래시 + 글머리기호 + 삿갓 표시 + 센트 기호 + 콜론 + 쉼표 + 저작권 + 여는 중괄호 + 닫는 중괄호 + 도 기호 + 나누기 기호 + 달러 + 말줄임표 + 엠 대시 + 엔 대시 + 유로 + 느낌표 + 강세 부호 + 대시 + 아래쪽 큰따옴표 + 곱하기 기호 + 줄바꿈 + 단락 기호 + 여는 소괄호 + 닫는 소괄호 + 퍼센트 + 마침표 + 파이 + 우물정 + 파운드 통화 기호 + 물음표 + 큰 따옴표 + 등록 상표 + 세미콜론 + 슬래시 + 띄어쓰기 + 여는 대괄호 + 닫는 대괄호 + 제곱근 + 상표 기호 + 밑줄 + 세로 막대 + + 부정 기호 + 수직 막대 + 마이크로 기호 + 거의 같음 + 같지 않음 + 통화 기호 + 섹션 기호 + 위쪽 화살표 + 왼쪽 화살표 + 루피 기호 + 검은 하트 + 물결표시 + 등호 + 원화 기호 + 참조 부호 + 하얀 별 + 검은 별 + 하얀 하트 + 하얀 동그라미 + 검은 동그라미 + 태양 기호 + 겹동그라미 + 하얀 클로버 + 하얀 스페이드 + 왼쪽을 가리키는 하얀 손 + 오른쪽을 가리키는 하얀 손 + 왼쪽 절반이 검은 동그라미 + 오른쪽 절반이 검은 동그라미 + 하얀 정사각형 + 검은 정사각형 + 위쪽을 가리키는 하얀 삼각형 + 아래쪽을 가리키는 하얀 삼각형 + 왼쪽을 가리키는 하얀 삼각형 + 오른쪽을 가리키는 하얀 삼각형 + 하얀 마름모 + 4분음표 + 8분음표 + 꼬리가 연결된 16분음표 + 여성 기호 + 남성 기호 + 검은색 여는 오목 괄호 + 검은색 닫는 오목 괄호 + 여는 낫표 + 닫는 낫표 + 오른쪽 화살표 + 아래쪽 화살표 + 더하기 빼기 기호 + 리터 + 섭씨 온도 기호 + 화씨 온도 기호 + 거의 같음 + 적분 + 수학용 왼쪽 꺾쇠괄호 + 수학용 오른쪽 꺾쇠괄호 + 우편 마크 + 위쪽을 가리키는 검은 삼각형 + 아래쪽을 가리키는 검은 삼각형 + 검은색 다이아몬드 + 가나 가운뎃점 + 작은 검은색 사각형 + 왼쪽 겹꺾쇠표 + 오른쪽 겹꺾쇠표 + 역느낌표 + 역물음표 + 원화 기호 + 전각 쉼표 + 전각 느낌표 + 고리점 + 전각 물음표 + 가운뎃점 + 오른쪽 큰 따옴표 + 두점 + 전각 콜론 + 전각 세미콜론 + 전각 앤드 부호 + 전각 곡절 악센트 + 전각 물결 표시 + 왼쪽 큰 따옴표 + 전각 왼쪽 소괄호 + 전각 오른쪽 소괄호 + 전각 별표 + 전각 밑줄 + 오른쪽 작은따옴표 + 전각 왼쪽 중괄호 + 전각 오른쪽 중괄호 + 전각 왼쪽 홑꺽쇠표 + 전각 오른쪽 홑꺽쇠표 + 왼쪽 작은 따옴표 + diff --git a/utils/src/main/res/values-ky/strings.xml b/utils/src/main/res/values-ky/strings.xml new file mode 100644 index 0000000..465ad0c --- /dev/null +++ b/utils/src/main/res/values-ky/strings.xml @@ -0,0 +1,50 @@ + + + %1$d – %2$d белги + Белги %1$d + аталышсыз + %1$s көчүрүлдү + баш тамга %1$s + %1$d %2$s + %1$s колдонуу + Жаңы кыска жол орнотуу үчүн баскычтар айкалышын басыңыз. Ал жок дегенде ALT же Control баскычын камтышы керек. + Жаңы тез кирүү баскычын киргизүү үчүн аны %1$s өзгөрткүч баскычы менен чогуу басыңыз. + Дайындалган эмес + Shift + Alt + Ctrl + Издөө + Оңго жебе + Солго жебе + Өйдө жебе + Төмөн жебе + Демейки + Символдор + Сөздөр + Саптар + Абзацтар + Терезелер + Белгиленген жерлер + Аталыштар + Тизмелер + Шилтемелер + Башкаруу элементтери + Өзгөчө мазмун + Аталыштар + Башкаруу элементтери + Шилтемелер + %1$s сүрөт ичиндеги сүрөт + %1$s үстүндө, %2$s астында + %1$s сол жакта, %2$s оң жакта + %1$s оң жакта, %2$s сол жакта + %3$d ичинен %1$d – %2$d нерсе көрсөтүлүүдө. + %2$d ичинен %1$d нерсе көрсөтүлүүдө. + %2$d беттин %1$d-бети + %2$d беттин %1$d-бети + %1$s (%2$s) + Чыгуу + %1$s көрсөтүлүүдө + баскычтоп жашырылган + Экрандагы текстти окуп берүү функциясы күйүк + Экрандагы текстти окуп берүү функциясы өчүк + diff --git a/utils/src/main/res/values-ky/strings_symbols.xml b/utils/src/main/res/values-ky/strings_symbols.xml new file mode 100644 index 0000000..763b3d5 --- /dev/null +++ b/utils/src/main/res/values-ky/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Апостроф + Амперсанд + \"Кичинерээк\" белгиси + Салыштырмалуу чоң белгиси + Жылдызча + Маймылча + Тескери жантык сызык + Маркер + Каре + Цент белгиси + Кош чекит + Үтүр + Автордук укук + Сол ийри кашаа + Оң ийри кашаа + Градустун белгиси + Бөлүү белгиси + Доллар белгиси + Көп чекит + Узун тире + Кыска тире + Евро + Илеп белгиси + Гравис + Тире + Ылдыйкы кош тырмакча + Көбөйтүү белгиси + Жаңы сап + Абзац белгиси + Сол кашаа + Оң кашаа + Пайыз + Чекит + Пи + Фунт + Фунт валютасынын белгиси + Суроо белгиси + Цитата + Катталган соода белгиси + Үтүрлүү чекит + Жантык сызык + Боштук + Сол төрт бурчтуу кашаа + Оң төрт бурчтуу кашаа + Квадраттык тамыр + Соода белгиси + Астын сызуу + Тик сызык + Йен + Белги эмес + Үзүк сызык + Микро белги + Дээрлик барабар + Барабар эмес + Валюта белгиси + Параграф белгиси + Өйдө жебе + Солго жебе + Рупий + Кара ача + Тильда + Барабар белгиси + Вон валютасынын белгиси + Шилтеме белгиси + Ак жылдыз + Кара жылдыз + Ак ача + Ак тегерек + Кара тегерек + Күн символу + Музоо көз + Ак чырым + Ак карга + Ак солго багытталган сөөмөй + Ак оңго багытталган сөөмөй + Сол жарымы кара тегерек + Оң жарымы кара тегерек + Ак чарчы + Кара чарчы + Ак өйдө караган үч бурчтук + Ак төмөн караган үч бурчтук + Ак сол жакты караган үч бурчтук + Ак оң жакты караган үч бурчтук + Ак момун + Чейрек эскертүү + Сегизинчи эскертүү + Өткөрүлгөн он алтынчы эскертүү + Аял символу + Эркек символу + Сол жаккы калың кара кашаа + Оң жаккы кара калың кашаа + Сол жаккы тик бурчтуу кашаа + Оң жаккы тик бурчтуу кашаа + Оң багытка жебе + Төмөн багытка жебе + Кошуу кемитүү белгиси + Литр + Цельсий даражасы + Фаренгейт даражасы + Болжол менен барабар + Ажырагыс + Математикалык сол бурчтук кашаа + Математикалык оң бурчтук кашаа + Почта маркасы + Кара үч бурчтук өйдө көрсөтүүдө + Кара үч бурчтук ылдый көрсөтүүдө + Алмаздан турган кара костюм + Жарым жазылыктагы катакана орто чекити + Кичинекей кара чарчы + Сол жуп бурч кашаасы + Оң жуп бурч кашаасы + Көңтөрүлгөн илеп белгиси + Көңтөрүлгөн суроо белгиси + Вон валютасынын белгиси + Толук жазылыктагы үтүр + Толук жазылыктагы илеп белгиси + Идеографикалык чекит + Толук жазылыктагы суроо белгиси + Ортоңку чекит + Оңду карап турган кош тырмакча + Идеографикалык үтүр + Толук жазылыктагы кош чекит + Толук жазылыктагы үтүрлүү чекит + Толук жазылыктагы амперсанд + Толук жазылыктагы циркумфлекс + Толук жазылыктагы тильда + Солду карап турган кош тырмакча + Толук жазылыктагы сол кашаа + Толук жазылыктагы оң кашаа + Толук жазылыктагы жылдызча + Толук жазылыктагы ылдыйкы сызык + Оңду карап турган жалгыз тырмакча + Толук жазылыктагы сол ийри узун кашаа + Толук жазылыктагы оң ийри узун кашаа + Толук жазылыктагы салыштырмалуу кичине белгиси + Толук жазылыктагы салыштырмалуу чоң белгиси + Солду карап турган жалгыз тырмакча + diff --git a/utils/src/main/res/values-lo/strings.xml b/utils/src/main/res/values-lo/strings.xml new file mode 100644 index 0000000..5eeeb65 --- /dev/null +++ b/utils/src/main/res/values-lo/strings.xml @@ -0,0 +1,50 @@ + + + ຕົວອັກສອນຈາກ %1$d ຫາ %2$d + ຕົວອັກສອນ %1$d + ບໍ່ມີຊື່ + ສຳເນົາແລ້ວ, %1$s + ໂຕ​ພິມ​ໃຫຍ່ %1$s + %1$d %2$s + ກຳ​ລັງ​ໃຊ້ %1$s + ກົດ​ການ​ປະ​ສົມ​ປະ​ສານ​ປຸ່ມ ເພື່ອ​ຕັ້ງ​ທາງ​ລັດ​ໃໝ່. ຢ່າງ​ນ້ອຍ​ມັນ​ຕ້ອງ​ມີ​ປຸ່ມ ALT ຫຼື​ປຸ່ມ​ຄວບ​ຄຸມ. + ກົດປຸ່ມປະສົມດ້ວຍປຸ່ມແກ້ໄຂ %1$s ເພື່ອສ້າງປຸ່ມລັດໃໝ່. + ບໍ່ໄດ້ຖືກຕັ້ງໄວ້ + Shift + Alt + Ctrl + ຊອກຫາ + ລູກສອນຂວາ + ລູກສອນຊ້າຍ + ລູກສອນຂຶ້ນ + ລູກສອນລົງ + ຄ່າເລີ່ມຕົ້ນ + ຕົວ​ອັກ​ສອນ + ຄຳ​ສັບ + ແຖວ + ຫຍໍ້ໜ້າ + ໜ້າຈໍ + ສະຖານທີ່ສຳຄັນ + ຫົວຂໍ້ + ລາຍຊື່ + ລິ້ງ + ການຄວບຄຸມ + ເນື້ອຫາພິເສດ + ຫົວຂໍ້ + ການຄວບຄຸມ + ລິ້ງ + %1$s ຮູບພາບຊ້ອນຮູບພາບ + %1$s ທາງເທິງ, %2$s ທາງລຸ່ມ + %1$s ທາງຊ້າຍ, %2$s ທາງຂວາ + %1$s ທາງຂວາ, %2$s ທາງຊ້າຍ + ກຳ​ລັງ​ສະ​ແດງ​ລາຍ​ການ​ທີ %1$d ຫາ %2$d ຈາກ​ທັງໝົດ %3$d. + ກຳ​ລັງ​ສະ​ແດງ​ລາຍ​ການ​ທີ %1$d ຈາກ​ທັງ​ໝົດ %2$d. + ໜ້າທີ %1$d ຈາກທັງໝົດ %2$d + %1$d ຈາກທັງໝົດ %2$d + %1$s (%2$s) + ອອກ + ກຳລັງສະແດງ %1$s + ເຊື່ອງແປ້ນພິມແລ້ວ + ເປີດການ​ຕອບ​ສະໜອງ​ແບບ​ສຽງ​ເວົ້າແລ້ວ + ປິດການ​ຕອບ​ສະໜອງ​ແບບ​ສຽງ​ເວົ້າແລ້ວ + diff --git a/utils/src/main/res/values-lo/strings_symbols.xml b/utils/src/main/res/values-lo/strings_symbols.xml new file mode 100644 index 0000000..b168501 --- /dev/null +++ b/utils/src/main/res/values-lo/strings_symbols.xml @@ -0,0 +1,140 @@ + + + ເຄື່ອງໝາຍ​ອະໂພສ​ໂທຣຟີ + ເຄື່ອງໝາຍແລະ + ​ເຄື່ອງ​ໝາຍ​ນ້ອຍກວ່າ + ​ເຄື່ອງ​ໝາຍ​​ໃຫຍ່ກວ່າ + ດອກຈັນ + ແອັດ + ສະແລດກັບຫຼັງ + ສັນຍາລັກຫຍໍ້ໜ້າ + ແຄເຣັດ + ເຄື່ອງໝາຍເຊັນ + ​ຈ້ຳ​ສອງ​ເມັດ + ຈຸດ + ລິຂະສິດ + ວົງປີກກາຊ້າຍ + ວົງປີກກາຂວາ + ເຄື່ອງໝາຍອົງສາ + ເຄື່ອງໝາຍຫານ + ເຄື່ອງໝາຍ​ເງິນໂດລາ + ເຄື່ອງໝາຍສາມຈ້ຳ + ຂີດຍາວ Em + ຂີດຍາວ En + ຢູໂຣ + ຫມາຍທ້ວງ + ເຄື່ອງໝາຍ​ເນັ້ນສຽງ + ຂີດ + ວົງຢືມຕ່ຳ​ຊ້ອນກັນ + ໝາຍຄູນ + ເຄື່ອງໝາຍ​ແຖວໃໝ່ + ເຄື່ອງຫມາຍວັກ + ວົງເລັບຊ້າຍ + ວົງເລັບຂວາ + ເປີເຊັນ + ຈ້ຳເມັດ + ​ປີ້ + ປອນ + ເຄື່ອງໝາຍສະກຸນເງິນພາວ + ຫມາຍຖາມ + ວົງຢືມ + ເຄື່ອງຫມາຍ​ການຄ້າທີ່​ຈົດທະບຽນ + ເຄື່ອງ​ໝາຍ​ຈ້ຳ​ຈຸດ + ​ຂີດ​ຊື່ + ຍະຫວ່າງ + ວົງຂໍຊ້າຍ + ວົງຂໍຂວາ + ຮາກຂັ້ນ​ສອງ + ເຄື່ອງຫມາຍການຄ້າ + ​ຂີດ​ກ້ອງ + ​ເສັ້ນ​ລວງຕັ້ງ + ເຢນ + ເຄື່ອງໝາຍຫ້າມ + ເສັ້ນຕັ້ງ​ທີ່ມີຊ່ອງ​ທາງກາງ + ສັນຍາລັກ​ໄມໂຄຣ + ເກືອບເທົ່າກັບ + ບໍ່ເທົ່າກັບ + ເຄື່ອງໝາຍ​ສະກຸນເງິນ + ເຄື່ອງໝາຍໝວດ + ລູກສອນຂຶ້ນ + ລູກສອນຊ້າຍ + ຣູປີ + ຫົວ​ໃຈ​ສີ​ດໍາ + ຫົວ​ຂໍ້ + ໝາຍ​ເທົ່າ​ກັບ + ເຄື່ອງໝາຍສະກຸນເງິນວອນ + ເຄື່ອງ​ໝາຍ​ອ້າງ​ອີງ + ດາວ​ສີ​ຂາວ + ດາວ​ສີ​ດໍາ + ຫົວ​ໃຈ​ສີ​ຂາວ + ວົງ​ມົນ​ສີ​ຂາວ + ວົງ​ມົນ​ສີ​ດຳ + ສັນ​ຍາ​ລັກ​ແສງ​ຕາ​ເວັນ + ຈຸດ​ກາງ​ເປົ້າ​ໝາຍ + ຊຸດ​ຫົວ​ໄມ້​ຕີ​ສີ​ຂາວ + ຊຸດນ້ຳ​ບິກສີ​ຂາວ + ຕົວ​ຊີ້​ໄປ​ດ້ານ​ຊ້າຍ​ສີ​ຂາວ + ຕົວ​ຊີ້​ໄປ​ດ້ານ​ຂວາສີ​ຂາວ + ວົງ​ມົນ​ເຄິ່ງດ້ານ​ຊ້າຍ​ສີ​ດຳ + ວົງ​ມົນ​ເຄິ່ງດ້ານຂວາ​ສີ​ດຳ + ຈະ​ຕຸ​ລັດ​ສີ​ຂາວ + ຈະ​ຕຸ​ລັດ​ສີ​ດໍາ + ສາມ​ຫຼ່ຽມ​ຊີ້​ຂຶ້ນ​ສີ​ຂາວ + ສາມ​ຫຼ່ຽມ​ຊີ້​ລົງ​ລຸ່ມ​ສີ​ຂາວ + ສາມ​ຫຼ່ຽມ​ຊີ້​ໄປ​ທາງ​ຊ້າຍ​ສີ​ຂາວ + ສາມ​ຫຼ່ຽມ​ຊີ້​ໄປ​ທາງ​ຂວາສີ​ຂາວ + ເພັດ​ສີ​ຂາວ + ໂນດ​ໜຶ່ງ​ສ່ວນ​ສີ່ + ບັນ​ທຶກ​ທີ​ແປດ + ບັນ​ທຶກ​ທີ​ສິບ​ຫົກ​ສົ່ງ​ສັນ​ຍານ​ແລ້ວ + ສັນ​ຍາ​ລັກ​ເພດ​ຍິງ + ສັນ​ຍາ​ລັກ​ເພດ​ຊາຍ + ວົງ​ເລັບ​ຊ້​ອນ​ສີ​ດຳ​ດ້ານ​ຊ້າຍ + ວົງ​ເລັບ​ຊ້ອນ​ສີ​ດຳ​ເບື້ອງ​ຂວາ + ວົງ​ເລັບ​ແຈ​ດ້ານ​ຊ້າຍ + ວົງ​ເລັບ​ມຸມ​ຂວາ + ລູກ​ສອນ​ໄປ​ໜ້າ​ເບື້ອງ​ຂວາ + ລູກ​ສອນລົງ + ເຄື່ອງໝາຍບວກ​ລົບ + ລິດ + ອົງ​ສາ​ເຊລ​ຊຽດ + ອົງ​ສາ​ຟາ​ເຣັນ​ຮາຍ + ໂດຍ​ປະ​ມານ​ແລ້ວ​ເທົ່າ​ກັນ + ຈຳ​ນວນ​ເຕັມ + ວົງຂໍຊ້າຍແບບຄະນິດສາດ + ວົງຂໍຂວາແບບຄະນິດສາດ + ​ໝາຍ​ໄປ​ສະ​ນີ + ສາມຫຼ່ຽມສີດຳຊີ້ຂຶ້ນ + ສາມຫຼ່ຽມສີດຳຊີ້ລົງ + ຊຸດເພັດສີດຳ + ຈຸດກາງຄາຕາຄານະເຄິ່ງຄວາມກວ້າງ + ສີ່ຫຼ່ຽມສີດຳຂະໜາດນ້ອຍ + ວົງປີກກາມຸມຄູ່ຊ້າຍ + ວົງປີກກາມຸມຄູ່ຂວາ + ເຄື່ອງໝາຍທ້ວງປີ້ນຫົວ + ເຄື່ອງໝາຍຄຳຖາມປີ້ນຫົວ + ເຄື່ອງໝາຍສະກຸນເງິນວອນ + ຈ້ຳເມັດເຕັມຄວາມກວ້າງ + ເຄື່ອງໝາຍທ້ວງເຕັມຄວາມກວ້າງ + ຈ້ຳເມັດແບບຈີນ-ຍີ່ປຸ່ນ + ເຄື່ອງໝາຍຄຳຖາມເຕັມຄວາມກວ້າງ + ຈຸດທາງກາງ + ເຄື່ອງໝາຍວົງຢືມຄູ່ຂວາ + ເຄື່ອງໝາຍຈຸດແບບຈີນ-ຍີ່ປຸ່ນ + ສອງຈໍ້າເຕັມຄວາມກວ້າງ + ຈໍ້ຳຈຸດເຕັມຄວາມກວ້າງ + ເຄື່ອງໝາຍແລະເຕັມຄວາມກວ້າງ + ເຊີຄຳເຟລັກເຕັມຄວາມກວ້າງ + ທິນເດີເຕັມຄວາມກວ້າງ + ເຄື່ອງໝາຍວົງຢືມຄູ່ຊ້າຍ + ວົງເລັບຊ້າຍເຕັມຄວາມກວ້າງ + ວົງເລັບຂວາເຕັມຄວາມກວ້າງ + ດອກຈັນເຕັມຄວາມກວ້າງ + ຂີດກ້ອງເຕັມຄວາມກວ້າງ + ເຄື່ອງໝາຍວົງຢືມດ່ຽວຂວາ + ວົງປີກກາຊ້າຍເຕັມຄວາມກວ້າງ + ວົງປີກກາຂວາເຕັມຄວາມກວ້າງ + ເຄື່ອງໝາຍໜ້ອຍກວ່າເຕັມຄວາມກວ້າງ + ເຄື່ອງໝາຍຫຼາຍກວ່າເຕັມຄວາມກວ້າງ + ເຄື່ອງໝາຍວົງຢືມດ່ຽວຊ້າຍ + diff --git a/utils/src/main/res/values-lt/strings.xml b/utils/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..d155e1f --- /dev/null +++ b/utils/src/main/res/values-lt/strings.xml @@ -0,0 +1,50 @@ + + + Simboliai nuo %1$d iki %2$d + %1$d simbolis + be pavadinimo + nukopijuota, %1$s + %1$s didžioji + %1$d %2$s + Naudojamas „%1$s“ + Paspauskite klavišų derinį, kad nustatytumėte naują spartųjį klavišą. Reikia naudoti ALT arba „Control“ klavišą. + Paspauskite klavišų derinį su „%1$s“ modifikavimo klavišu, kad nustatytumėte naują spartųjį klavišą. + Nepriskirta + „Shift“ + Alt + „Ctrl“ + Paieškos klavišas + Rodyklė dešinėn + Rodyklė kairėn + Rodyklė aukštyn + Rodyklė žemyn + Numatytasis + Simboliai + Žodžiai + Eilutės + Paragrafai + Windows + Orientyrai + Antraštės + Sąrašai + Nuorodos + Valdikliai + Specialus turinys + Antraštės + Valdikliai + Nuorodos + „%1$s“ vaizdas vaizde + %1$s viršuje, %2$s apačioje + %1$s kairėje, %2$s dešinėje + %1$s dešinėje, %2$s kairėje + Rodomi elementai: nuo %1$d iki %2$d iš %3$d. + Rodomas elementas: %1$d iš %2$d. + %1$d puslapis iš %2$d + %1$d iš %2$d + „%1$s“ (%2$s) + Išeiti + Rodomas langas „%1$s“ + klaviatūra paslėpta + Ekrano skaitymo balsu funkcija įjungta + Ekrano skaitymo balsu funkcija išjungta + diff --git a/utils/src/main/res/values-lt/strings_symbols.xml b/utils/src/main/res/values-lt/strings_symbols.xml new file mode 100644 index 0000000..16339f6 --- /dev/null +++ b/utils/src/main/res/values-lt/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrofas + Ampersendas + Ženklas „mažiau nei“ + Ženklas „daugiau nei“ + Žvaigždutė + Simbolis „@“ + Kairinis pasvirasis brūkšnys + Ženklelis + Įterpinio ženklas + Cento ženklas + Dvitaškis + Kablelis + Autorių teisės + Kairysis riestinis skliaustas + Dešinysis riestinis skliaustas + Laipsnio ženklas + Dalybos ženklas + Dolerio ženklas + Daugtaškis + Ilgasis brūkšnys + Trumpasis brūkšnys + Euras + Šauktukas + Kairinis kirtis + Brūkšnys + Apatinės dvigubos kabutės + Daugybos ženklas + Nauja eilutė + Pastraipos simbolis + Kairysis skliaustas + Dešinysis skliaustas + Procentas + Taškas + Pi + Svaras + Valiutos (svaro sterlingų) ženklas + Klaustukas + Kabutė + Registruotas prekės ženklas + Kabliataškis + Pasvirasis brūkšnys + Tarpo klavišas + Kairysis laužtinis skliaustas + Dešinysis laužtinis skliaustas + Kvadratinė šaknis + Prekės ženklas + Apatinis brūkšnys + Vertikali linija + Jena + Neigimo ženklas + Vertikalioji brūkšninė juosta + Ženklas „mikro-“ + Beveik lygu + Nelygu + Valiutos ženklas + Paragrafo ženklas + Rodyklė į viršų + Rodyklė kairėn + Rupija + Juoda širdis + Tildė + Lygybės ženklas + Valiutos (vono) ženklas + Nuorodos žyma + Balta žvaigždė + Juoda žvaigždė + Balta širdis + Baltas apskritimas + Juodas apskritimas + Saulės simbolis + Taikinys + Baltas kryžių simbolis + Baltas vynų simbolis + Baltas į kairę nukreiptas rodiklis + Baltas į dešinę nukreiptas rodiklis + Apskritimas, kurio kairė pusė juoda + Apskritimas, kurio dešinė pusė juoda + Baltas kvadratas + Juodas kvadratas + Baltas į viršų nukreiptas trikampis + Baltas žemyn nukreiptas trikampis + Baltas į kairę nukreiptas trikampis + Baltas į dešinę nukreiptas trikampis + Baltas deimantas + Ketvirtinė nata + Aštuntinė nata + Sujungtos šešioliktinės natos + Moteriškos lyties simbolis + Vyriškosios lyties simbolis + Kairysis juodas lęšio formos skliaustas + Dešinysis juodas lęšio formos skliaustas + Kairysis kampinis skliaustas + Dešinysis kampinis skliaustas + Rodyklė į dešinę + Rodyklė žemyn + Plius minus ženklas + Litras + Celsijaus laipsnis + Farenheito laipsnis + Maždaug lygu + Integralas + Matematinis kairysis kampinis skliaustas + Matematinis dešinysis kampinis skliaustas + Pašto ženklas + Aukštyn nukreiptas juodas trikampis + Žemyn nukreiptas juodas trikampis + Juodų deimantų rinkinys + Pusės pločio katakanos taškas per vidurį + Mažas juodas kvadratas + Kairysis dvigubas kampinis skliaustas + Dešinysis dvigubas kampinis skliaustas + Apverstas šauktukas + Apverstas klaustukas + Valiutos (vono) ženklas + Viso pločio kablelis + Viso pločio šauktukas + Ideografinis taškas + Viso pločio klaustukas + Taškas per vidurį + Dešiniosios dvigubos kabutės + Ideografinis kablelis + Viso pločio dvitaškis + Viso pločio kabliataškis + Viso pločio ampersendas + Viso pločio cirkumfleksas + Viso pločio tildė + Kairiosios dvigubos kabutės + Viso pločio kairysis skliaustas + Viso pločio dešinysis skliaustas + Viso pločio žvaigždutė + Viso pločio apatinis brūkšnys + Dešiniosios viengubos kabutės + Viso pločio kairysis riestinis skliaustas + Viso pločio dešinysis riestinis skliaustas + Viso pločio ženklas „mažiau nei“ + Viso pločio ženklas „daugiau nei“ + Kairiosios viengubos kabutės + diff --git a/utils/src/main/res/values-lv/strings.xml b/utils/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..ba05b17 --- /dev/null +++ b/utils/src/main/res/values-lv/strings.xml @@ -0,0 +1,50 @@ + + + Rakstzīmes no %1$d līdz %2$d + Rakstzīme %1$d + bez nosaukuma + nokopēts, %1$s + lielais burts %1$s + %1$d %2$s + Izmantojot %1$s + Nospiediet taustiņu kombināciju, lai iestatītu jaunu īsinājumtaustiņu. Kombinācijā ir jābūt ietvertam vismaz taustiņam ALT vai Control. + Lai iestatītu jaunu īsinājumtaustiņu, nospiediet taustiņu kombināciju kopā ar modificētājtaustiņu %1$s. + Nav piešķirts + Pārslēgšanas taustiņš + Alternēšanas taustiņš + Vadīšanas taustiņš + Meklēšanas taustiņš + Labais bulttaustiņš + Kreisais bulttaustiņš + Augšupvērstais bulttaustiņš + Lejupvērstais bulttaustiņš + Noklusējuma + Rakstzīmes + Vārdi + Rindiņas + Rindkopas + Logi + Orientieri + Virsraksti + Saraksti + Saites + Vadīklas + Īpašs saturs + Virsraksti + Vadīklas + Saites + %1$s: attēls attēlā + %1$s augšā, %2$s apakšā + %1$s pa kreisi, %2$s pa labi + %1$s pa labi, %2$s pa kreisi + Tiek rādīts %1$d.–%2$d. vienums no %3$d. + Tiek rādīts %1$d. vienums no %2$d. + %1$d. lapa no %2$d + %1$d. no %2$d + %1$s (%2$s) + Iziet + Tiek rādīts logs “%1$s” + tastatūra paslēpta + Balss komentāri ir ieslēgti + Balss komentāri ir izslēgti + diff --git a/utils/src/main/res/values-lv/strings_symbols.xml b/utils/src/main/res/values-lv/strings_symbols.xml new file mode 100644 index 0000000..032a138 --- /dev/null +++ b/utils/src/main/res/values-lv/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrofs + & zīme + Zīme “mazāks nekā” + Zīme “lielāks nekā” + Zvaigznīte + Et + Atpakaļvērstā slīpsvītra + Aizzīme + Jumtiņš + Centa zīme + Kols + Komats + Autortiesības + Kreisā figūriekava + Labā figūriekava + Grāda zīme + Dalīšanas zīme + Dolāra zīme + Daudzpunkte + Domuzīme + Vienotājdomuzīme + Eiro + Izsaukuma zīme + Uzsvara zīme + Defise + Apakšējās pēdiņas + Reizināšanas zīme + Jauna rindiņa + Rindkopas atzīme + Kreisā iekava + Labā iekava + Procentu simbols + Punkts + + Numura simbols + Sterliņu mārciņas zīme + Jautājuma zīme + Pēdiņas + Reģistrētas preču zīmes simbols + Semikols + Slīpsvītra + Atstarpe + Kreisā kvadrātiekava + Labā kvadrātiekava + Kvadrātsakne + Preču zīme + Pasvītra + Vertikāla līnija + Jena + Nolieguma zīme + Pārtraukta vertikāla līnija + Mikro zīme + Aptuveni vienāds ar + Nav vienāds ar + Valūtas zīme + Nodaļas zīme + Bultiņa uz augšu + Bultiņa pa kreisi + Rūpija + Melna sirds + Tilde + Vienādības zīme + Vonas zīme + Atsauces zīme + Balta zvaigzne + Melna zvaigzne + Balta sirds + Balts aplis + Melns aplis + Saules simbols + Mērķis + Balts kreicis + Balts pīķis + Balts pa kreisi vērsts rādītājpirksts + Balts pa labi vērsts rādītājpirksts + Aplis ar melnu kreiso pusi + Aplis ar melnu labo pusi + Balts kvadrāts + Melns kvadrāts + Balts augšupvērsts trijstūris + Balts lejupvērsts trijstūris + Balts pa kreisi vērsts trijstūris + Balts pa labi vērsts trijstūris + Balts kāravs + Ceturtdaļnots + Astotdaļnots + Sešpadsmitdaļnotis ar slitu + Sievišķais simbols + Vīrišķais simbols + Melna kreisā lēcveidīgā iekava + Melna labā lēcveidīgā iekava + Kreisā taisnleņķa iekava + Labā taisnleņķa iekava + Augšupvērsta bultiņa + Lejupvērsta bultiņa + Pluszīme/mīnuszīme + Litru simbols + Celsija grādu simbols + Fārenheita grādu simbols + Aptuveni vienāds + Integrālis + Kreisā leņķiekava (matemātika) + Labā leņķiekava (matemātika) + Pasta zīme + Melns trijstūris, kas norāda uz augšu + Melns trijstūris, kas norāda uz leju + Melnu dimantu kopa + Punkts rindiņas vidū pusplatuma katakanas rakstībā + Neliels melns kvadrāts + Kreisā dubultā leņķiekava + Labā dubultā leņķiekava + Apgriezta izsaukuma zīme + Apgriezta jautājuma zīme + Vonas zīme + Pilna platuma komats + Pilna platuma izsaukuma zīme + Ideogrāfisks punkts + Pilna platuma jautājuma zīme + Punkts rindiņas vidū + Labās puses pēdiņas + Ideogrāfisks komats + Pilna platuma kols + Pilna platuma semikols + Pilna platuma rakstzīme “&” + Pilna platuma cirkumflekss + Pilna platuma tilde + Kreisās puses pēdiņas + Pilna platuma kreisā iekava + Pilna platuma labā iekava + Pilna platuma zvaigznīte + Pilna platuma pasvītra + Viena labās puses pēdiņa + Pilna platuma kreisā figūriekava + Pilna platuma labā figūriekava + Pilna platuma zīme “mazāks nekā” + Pilna platuma zīme “lielāks nekā” + Viena kreisās puses pēdiņa + diff --git a/utils/src/main/res/values-mk/strings.xml b/utils/src/main/res/values-mk/strings.xml new file mode 100644 index 0000000..d21c272 --- /dev/null +++ b/utils/src/main/res/values-mk/strings.xml @@ -0,0 +1,50 @@ + + + Знаци %1$d до %2$d + Знакот %1$d + без наслов + копирано, %1$s + голема буква %1$s + %1$d %2$s + Се користи %1$s + Притиснете комбинација на копчиња да поставите нова кратенка. Таа мора да го содржи барем копчето ALT или Control. + Притиснете ја комбинацијата од копчиња со копчето модификатор %1$s за да поставите нова кратенка. + Неназначено + Копче Shift + Копче Alt + Копче Ctrl + Пребарај + Стрелка надесно + Стрелка налево + Стрелка нагоре + Стрелка надолу + Стандардно + Знаци + Зборови + Редови + Пасуси + Прозорци + Обележја + Наслови + Списоци + Линкови + Контроли + Специјална содржина + Наслови + Контроли + Линкови + Слика во слика на %1$s + %1$s горе, %2$s долу + %1$s на левата страна, %2$s на десната страна + %1$s на десната страна, %2$s на левата страна + Се прикажуваат ставките од %1$d до %2$d од %3$d. + Се прикажува ставката %1$d од %2$d. + Страница %1$d од %2$d + %1$d од %2$d + %1$s (%2$s) + Излези + Се прикажува %1$s + тастатурата е сокриена + Говорните повратни информации се вклучени + Говорните повратни инфромации се исклучени + diff --git a/utils/src/main/res/values-mk/strings_symbols.xml b/utils/src/main/res/values-mk/strings_symbols.xml new file mode 100644 index 0000000..7b74690 --- /dev/null +++ b/utils/src/main/res/values-mk/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Апостроф + и + Знак за „Помало од“ + Знак за „Поголемо од“ + Ѕвездичка + на + Обратна коса црта + Знак за подредување + Карета + Знак за цент + Две точки + Запирка + Авторски права + Лева голема заграда + Десна голема заграда + Знак за степен + Знак за делење + Знак за долар + Три точки + Долга црта + Кратка црта + Евро + Извичник + Акцент гравис + Црта + Долни двојни наводници + Знак за множење + Нов ред + Знак за параграф + Лева заграда + Десна заграда + Процент + Точка + Пи + Фунта + Знак за валутата фунта + Прашалник + Цитат + Регистриран заштитен знак + Точка и запирка + Kоса црта + Празно место + Лева средна заграда + Десна средна заграда + Квадратен корен + Заштитен знак + Долна црта + Вертикална линија + Јен + Знак „забрането“ + Прекината вертикална црта + Знак за микро + Речиси еднакво на + Не е еднакво на + Знак за валута + Параграф + Стрелка нагоре + Стрелка налево + Рупија + Црно срце + Тилда + Знак за еднакво + Знак за валутата вон + Знак за референца + Бела ѕвезда + Црна ѕвезда + Бело срце + Бел круг + Црн круг + Соларен симбол + Центар на мета + Бела детелинка + Бел лист + Бел показалец свртен налево + Бел показалец свртен надесно + Круг со црна лева половина + Круг со црна десна половина + Бел квадрат + Црн квадрат + Бел триаголник свртен нагоре + Бел триаголник свртен надолу + Бел триаголник свртен налево + Бел триаголник свртен надесно + Бел дијамант + Четвртина нота + Осма нота + Шестнаесет ноти се споделени преку зрак + Женски симбол + Машки симбол + Лева црна лентикуларна заграда + Десна црна лентикуларна заграда + Заграда со лев агол + Заграда со десен агол + Стрелка надесно + Стрелка надолу + Знак плус и минус + Литар + Целзиусов степен + Фаренхајтови степени + Приближно еднакви + Интеграл + Математичка лева средна заграда + Математичка десна средна заграда + Поштенска ознака + Црн триаголник што покажува нагоре + Црн триаголник што покажува надолу + Црна баклава + Катакана средна точка со половина должина + Мал црн квадрат + Лева двојна средна заграда + Десна двојна средна заграда + Превртен извичник + Превртен прашалник + Знак за валутата вон + Запирка со цела ширина + Извичник со цела ширина + Идеографска точка + Прашалник со цела ширина + Средна точка + Десен наводник + Идеографска запирка + Две точки со цела ширина + Точка и запирка со цела ширина + Знак за сврзникот „и“ со цела ширина + Циркумфлекс со цела ширина + Тилда со цела ширина + Лев наводник + Лева заграда со цела ширина + Десна заграда со цела ширина + Ѕвездичка со цела ширина + Долна црта со цела ширина + Десен полунаводник + Лева голема заграда со цела ширина + Десна голема заграда со цела ширина + Знак за „помало од“ со цела ширина + Знак за „поголемо од“ со цела ширина + Лев полунаводник + diff --git a/utils/src/main/res/values-ml/strings.xml b/utils/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..e6995a0 --- /dev/null +++ b/utils/src/main/res/values-ml/strings.xml @@ -0,0 +1,50 @@ + + + %1$d മുതൽ %2$d വരെയുള്ള പ്രതീകങ്ങൾ + പ്രതീകം %1$d + പേരില്ലാത്തത് + പകർത്തി, %1$s + വലിയക്ഷരം %1$s + %1$d %2$s + %1$s ഉപയോഗിച്ച് + പുതിയ കുറുക്കുവഴി സജ്ജമാക്കാൻ കീ കോമ്പിനേഷൻ അമർത്തുക. ഇതിൽ ALT അല്ലെങ്കിൽ Control കീയെങ്കിലും ഉണ്ടായിരിക്കണം. + പുതിയ കുറുക്കുവഴി സജ്ജമാക്കുന്നതിന് %1$s മോഡിഫയർ കീയ്ക്കൊപ്പം കീ കോമ്പിനേഷൻ അമർത്തുക. + നൽകിയിട്ടില്ല + ഷിഫ്റ്റ് + ആൾട്ട് + കൺട്രോൾ + തിരയൽ + വലത്തേക്കുള്ള അമ്പടയാളം + ഇടത്തേക്കുള്ള അമ്പടയാളം + മുകളിലേക്കുള്ള അമ്പടയാളം + താഴേയ്‌ക്കുള്ള അമ്പടയാളം + സ്ഥിരസ്ഥിതി + പ്രതീകങ്ങള്‍‌ + വാക്കുകൾ + ലൈനുകൾ + ഖണ്ഡികകൾ + വിൻഡോകൾ + ലാൻഡ്‌മാർക്കുകൾ + തലക്കെട്ടുകൾ + ലിസ്റ്റുകൾ + ലിങ്കുകൾ + നിയന്ത്രണങ്ങൾ + പ്രത്യേക ഉള്ളടക്കം + തലക്കെട്ടുകൾ + നിയന്ത്രണങ്ങൾ + ലിങ്കുകൾ + %1$s എന്നതിലെ ചിത്രത്തിനുള്ളിലെ ചിത്രം + %1$s മുകൾഭാഗത്ത്, %2$s താഴ്‌ഭാഗത്ത് + %1$s ഇടതുഭാഗത്ത്, %2$s വലതുഭാഗത്ത് + %1$s വലതുഭാഗത്ത്, %2$s ഇടതുഭാഗത്ത് + %3$d / %1$d - %2$d ഇനങ്ങൾ കാണിക്കുന്നു. + %1$d / %2$d ഇനം കാണിക്കുന്നു. + പേജ് %1$d / %2$d + %1$d / %2$d + %1$s (%2$s) + പുറത്തുകടക്കുക + %1$s കാണിക്കുന്നു + കീബോർഡ് മറച്ചു + സംഭാഷണ ഫീഡ്ബാക്ക് ഓണാണ് + സംഭാഷണ ഫീഡ്ബാക്ക് ഓഫാണ് + diff --git a/utils/src/main/res/values-ml/strings_symbols.xml b/utils/src/main/res/values-ml/strings_symbols.xml new file mode 100644 index 0000000..560cea1 --- /dev/null +++ b/utils/src/main/res/values-ml/strings_symbols.xml @@ -0,0 +1,140 @@ + + + വിശ്ലേഷം + സമുച്ചയം + ലെസ്‌ദാൻ ചിഹ്നം + ഗ്രേറ്റർദാൻ ചിഹ്നം + നക്ഷത്രചിഹ്നം + അറ്റ് + ബാക്ക്സ്ലാഷ് + ബുള്ളറ്റ് + കാരറ്റ് + സെന്റ് ചിഹ്നം + കോളൻ + കോമ + പകർപ്പവകാശം + ഇടത് ചുരുൾ ബ്രാക്കറ്റ് + വലത് ചുരുൾ ബ്രാക്കറ്റ് + ഡിഗ്രി ചിഹ്നം + ഹരണ ചിഹ്നം + ഡോളർ ചിഹ്നം + എല്ലിപ്‌സിസ് + എം ഡാഷ് + എൻ ഡാഷ് + യൂറോ + ആശ്ചര്യചിഹ്നം + ഗ്രേവ് ആക്‌സന്റ് + ഡാഷ് + ചുവടെ വരുന്ന ഇരട്ട ഉദ്ധരണി + ഗുണന ചിഹ്നം + പുതിയ വരി + ഖണ്ഡിക അടയാളം + ഇടത് പരാന്തിസിസ് + വലത് പരാന്തിസിസ് + ശതമാനം + വിരാമം + പൈ + പൗണ്ട് + പൗണ്ട് കറൻസി ചിഹ്നം + ചോദ്യചിഹ്നം + ഉദ്ധരണി + രജിസ്‌റ്റർചെയ്‌ത വ്യാപാരമുദ്ര + അർദ്ധവിരാമം + സ്ലാഷ് + സ്പെയ്സ് + ഇടത് ചതുര ബ്രാക്കറ്റ് + വലത് ചതുര ബ്രാക്കറ്റ് + വർഗ്ഗമൂലം + വ്യാപാരമുദ്ര + അടിവര + ലംബ രേഖ + യെൻ + ചെയ്യരുത് ചിഹ്നം + ബ്രോക്കൺ ബാർ + മൈക്രോ ചിഹ്നം + ഇതിനോട് ഏതാണ്ട് സമമാണ് + സമമല്ല + കറൻസി ചിഹ്നം + അനു‌ച്‌ഛേദകം + മുകളിലേയ്ക്കുള്ള അമ്പടയാളം + ഇടത്തേയ്ക്കുള്ള അമ്പടയാളം + രൂപ + കറുത്ത ഹൃദയം + ടിൽഡ് + സമ ചിഹ്‌നം + വോൺ കറൻസി ചിഹ്നം + റെഫറൻസ് മാർക്ക് + വെള്ളനിറത്തിലുള്ള നക്ഷത്രം + കറുത്ത നക്ഷത്രം + വെള്ളനിറത്തിലുള്ള ഹൃദയം + വെള്ളനിറത്തിലുള്ള വൃത്തം + കറുത്ത വൃത്തം + സൂര്യ ചിഹ്‌നം + ബുൾസ്ഐ + വെള്ളനിറത്തിലുള്ള ക്ലബ് സ്യൂട്ട് + വെള്ള നിറത്തിലുള്ള സ്‌പേഡ് സ്യൂട്ട് + ഇടത്തേക്ക് ചൂണ്ടുന്ന വെള്ളനിറത്തിലുള്ള വിരൽ + വലത്തേക്ക് ചൂണ്ടുന്ന വെള്ളനിറത്തിലുള്ള വിരൽ + ഇടതുവശത്തെ പകുതി ഭാഗം കറുത്ത നിറത്തിലുള്ള വൃത്തം + വലതുവശത്തെ പകുതി ഭാഗം കറുത്ത നിറത്തിലുള്ള വൃത്തം + വെള്ളനിറത്തിലുള്ള ചതുരം + കറുത്ത ചതുരം + മുകളിലേക്ക് പോയിന്റുചെയ്യുന്ന വെള്ളനിറത്തിലുള്ള ത്രികോണം + താഴേക്ക് പോയിന്റുചെയ്യുന്ന വെള്ളനിറത്തിലുള്ള ത്രികോണം + ഇടത്തേക്ക് പോയിന്റുചെയ്യുന്ന വെള്ളനിറത്തിലുള്ള ത്രികോണം + വലത്തേക്ക് പോയിന്റുചെയ്യുന്ന വെള്ളനിറത്തിലുള്ള ത്രികോണം + വെള്ളനിറത്തിലുള്ള ഡയമണ്ട് + ക്വാർട്ടർ നോട്ട് + എട്ടാമത്തെ നോട്ട് + ബീം ചെയ്‌ത പതിനാറാമത്തെ നോട്ടുകൾ + \'സ്‌ത്രീ\' ചിഹ്‌നം + \'പുരുഷൻ\' ചിഹ്‌നം + കറുത്ത ഇടത് ലെന്റികുലാർ ബ്രാക്കറ്റ് + കറുത്ത വലത് ലെന്റികുലാർ ബ്രാക്കറ്റ് + ഇടത് കോൺ ബ്രാക്കറ്റ് + വലതു കോൺ ബ്രാക്കറ്റ് + വലത്തോട്ടുള്ള അമ്പടയാളം + താഴേക്കുള്ള അമ്പടയാളം + അധിക ന്യൂന ചിഹ്‌നം + ലിറ്റർ + സെൽഷ്യസ് ഡിഗ്രി + ഫാരൻഹീറ്റ് ഡിഗ്രി + ഏകദേശം തുല്യമാണ് + ഇന്റഗ്രൽ + ഗണിത ഇടത് ആംഗിൾ ബ്രാക്കറ്റ് + ഗണിത വലത് ആംഗിൾ ബ്രാക്കറ്റ് + തപാൽ ചിഹ്നം + മുകളിലേക്ക് പോയിന്റ് ചെയ്യുന്ന കറുത്ത ത്രികോണം + താഴേക്ക് പോയിന്റ് ചെയ്യുന്ന കറുത്ത ത്രികോണം + കറുത്ത ഡയമണ്ട് സ്യൂട്ട് + പകുതി വിഡ്ത്ത് മധ്യത്തിലെ കറ്റക്കാന ഡോട്ട് + ചെറിയ കറുത്ത സമചതുരം + ഇടത് ഇരട്ട ആംഗിൾ ബ്രായ്ക്കറ്റ് + വലത് ഇരട്ട ആംഗിൾ ബ്രായ്ക്കറ്റ് + തിരിച്ചിട്ട ആശ്‌ചര്യ ചിഹ്നം + തിരിച്ചിട്ട ചോദ്യ ചിഹ്നം + വോൺ കറൻസി ചിഹ്നം + പൂർണ്ണ വിഡ്ത്ത് കോമ + പൂർണ്ണ വിഡ്ത്ത് ആശ്ചര്യചിഹ്നം + ഐഡിയോഗ്രാഫിക് പൂർണ്ണവിരാമം + പൂർണ്ണ വിഡ്ത്ത് ചോദ്യചിഹ്നം + മധ്യത്തിലെ ഡോട്ട് + വലത് വശത്തെ ഇരട്ട ഉദ്ധരണി അടയാളം + ഐഡിയോഗ്രാഫിക് കോമ + പൂർണ്ണ വിഡ്ത്ത് കോളൻ + പൂർണ്ണ വിഡ്ത്ത് അർദ്ധവിരാമം + പൂർണ്ണ വിഡ്ത്ത് ആംപർസാൻഡ് + പൂർണ്ണ വിഡ്ത്ത് സർക്കംഫ്ലെക്‌സ് + പൂർണ്ണ വിഡ്ത്ത് ടിൽഡ് + ഇടത് ഇരട്ട ഉദ്ധരണി ചിഹ്നം + പൂർണ്ണ വിഡ്ത്ത് ഇടത് പരാന്തിസിസ് + പൂർണ്ണ വിഡ്ത്ത് വലത് പരാന്തിസിസ് + പൂർണ്ണ വിഡ്ത്ത് നക്ഷത്രചിഹ്നം + പൂർണ്ണ വിഡ്ത്ത് അണ്ടർസ്കോർ + വലത് ഒറ്റ ഉദ്ധരണി ചിഹ്നം + പൂർണ്ണ വിഡ്ത്ത് ഇടത് ചുരുൾ ബ്രാക്കറ്റ് + പൂർണ്ണ വിഡ്ത്ത് വലത് ചുരുൾ ബ്രാക്കറ്റ് + പൂർണ്ണ വിഡ്ത്ത് ആരോഹണ ചിഹ്നം + പൂർണ്ണ വിഡ്ത്ത് അവരോഹണ ചിഹ്നം + ഇടത് ഒറ്റ ഉദ്ധരണി ചിഹ്നം + diff --git a/utils/src/main/res/values-mn/strings.xml b/utils/src/main/res/values-mn/strings.xml new file mode 100644 index 0000000..355c011 --- /dev/null +++ b/utils/src/main/res/values-mn/strings.xml @@ -0,0 +1,50 @@ + + + %1$d-с %2$d тэмдэгт + Тэмдэгт %1$d + гарчиггүй + хуулсан, %1$s + том %1$s + %1$d %2$s + %1$s-г ашиглаж байна + Шинэ гарны холболт үүсгэхийн тулд товчлууруудын хослол дээр дарна уу. Энэ нь ALT эсвэл Control товчнуудын аль нэгийг заавал агуулсан байх ёстой. + Шинэ товчлол тохируулахын тулд түлхүүрийн хослолыг %1$s засварын түлхүүртэй хамт дарна уу. + Тодорхой бус + Shift + Alt + Ctrl + Хайлт + Баруун тийш заасан сум + Зүүн тийш заасан сум + Дээш заасан сум + Доошоо заасан сум + Үндсэн + Тэмдэгт + Үг + Мөр + Параграф + Цонх + Газрын тэмдэглэгээ + Гарчгууд + Жагсаалтууд + Холбооснууд + Удирдлага + Тусгай агуулга + Гарчиг + Хяналтууд + Холбооснууд + %1$s дэлгэцэн доторх дэлгэц + дээр нь %1$s, доор нь %2$s + зүүн талд %1$s, баруун талд %2$s + баруун талд %1$s, зүүн талд %2$s + Нийт %3$d-н %1$d-с %2$d хүртэлхийг үзүүлж байна. + Нийт %2$d-н %1$d-с үзүүлж байна. + Хуудас %2$d-н %1$d + %2$d-н %1$d + %1$s (%2$s) + Гарах + %1$s-г харуулж байна + гарыг нуусан + Яриагаар өгөх саналыг асаасан + Яриагаар өгөх саналыг унтраасан + diff --git a/utils/src/main/res/values-mn/strings_symbols.xml b/utils/src/main/res/values-mn/strings_symbols.xml new file mode 100644 index 0000000..0792649 --- /dev/null +++ b/utils/src/main/res/values-mn/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Апостроф + Амперсант + Багын тэмдэг + Ихийн тэмдэг + Од + Эт + Хойшоо халуу зураас + Сум + Оруулгын тэмдэг + Центийн тэмдэг + Хоёр цэг + Таслал + Зохиогчийн эрх + Зүүн угалзан хаалт + Баруун угалзан хаалт + Хэмийн тэмдэг + Хуваах тэмдэг + Долларын тэмдэг + Олон цэг + Дундуур зураас + Дундуур зураас + Евро + Анхаарлын тэмдэг + Өргөлтийн тэмдэг + Дундуур зураас + Доод хоёр хашилт + Үржвэрийн тэмдэг + Шинэ мөр + Парагарафын тэмдэг + Зүүн хаалт + Баруун хаалт + Хувь + Цэг + пи + Фунт + Фунтын тэмдэглэгээ + Асуултын тэмдэг + Ишлэх + Бүртгэгдсэн ялгарах тэмдэг + Цэгтэй таслал + Ташуу зураас + Хоосон зай + Зүүн дөрвөлжин хаалт + Баруун дөрвөлжин хаалт + Квадрат язгуур + Ялгарах тэмдэг + Доогуур зураас + Босоо зураас + Иен + Байхгүй тэмдэг + Тасарсан босоо зураас + Микро тэмдэг + Ойролцоо тооны тэмдэг + Тэнцүү биш + Валютын тэмдэг + Гарын үсгийн хэсэг + Дээшээ сум + Зүүн сум + Рупи + Хар өнгөтэй зүрхэн хэлбэртэй дүрс + Долгионтой зураас + Тэнцүү тэмдэг + Воны мөнгөн тэмдэгтийн тэмдэг + Ишлэлийн тэмдэг + Цагаан өнгөтэй од дүрс + Хар өнгөтэй од хэлбэртэй дүрс + Цагаан өнгөтэй зүрхэн хэлбэртэй дүрс + Цагаан өнгөтэй дугуй дүрс + Хар өнгөтэй дугуй дүрс + Нар хэлбэртэй дүрс + Бухын нүд хэлбэртэй дүрс + Цагаан өнгөтэй цэцгэн хэлбэртэй дүрс + Цагаан өнгөтэй гил дүрс + Цагаан өнгөтэй зүүн тийш заасан гарны дүрс + Цагаан өнгөтэй баруун тийш заасан гарны дүрс + Зүүн хагас нь хар өнгөтэй дугуй дүрс + Баруун хагас нь хар өнгөтэй дугуй дүрс + Цагаан өнгөтэй дөврөлжин дүрс + Хар өнгөтэй дөрвөлжин дүрс + Цагаан өнгөтэй дээшээ заасан гурвалжин дүрс + Цагаан өнгөтэй доошоо харсан гурвалжин дүрс + Цагаан өнгөтэй зүүн тийш заасан гурвалжин дүрс + Цагаан өнгөтэй баруун тийш заасан гурвалжин дүрс + Цагаан өнгөтэй ромбо хэлбэртэй дүрс + Дөрөвт нот + Наймт нот + Холбосон арван зургаат нотууд + Эмэгтэй хүнийг илэрхийлдэг бэлгэ тэмдэг + Эрэгтэй хүнийг илэрхийлдэг бэлгэ тэмдэг + Хар өнгөтэй хагас гүдгэр зүүн талын хаалт + Хар өнгөтэй хагас гүдгэр баруун талын хаалт + Зүүн булангийн хаалт + Баруун булангийн хаалт + Баруун тийш чиглэсэн сум + Доошоо чиглэсэн сум + Нэмэх хасах тэмдэг + Литр + Цельсийн хэм + Фаренгейтийн хэм + Ойролцоолон тэнцүүлэх тэнцүү тэмдэгт + Бүхэл тоо + Математикийн зүүн өнцгийн хаалт + Математикийн баруун өнцгийн хаалт + Шуудангийн марк + Дээш зааж буй хар гурвалжин + Доош зааж буй хар гурвалжин + Хар дөрвөлжин + Хагас өргөнтэй катаканагийн дунд цэг + Жижиг хар дөрвөлжин + Зүүн талын давхар өнцөгтэй хаалт + Баруун талын давхар өнцөгтэй хаалт + Урвуу анхаарлын тэмдэг + Урвуу асуултын тэмдэг + Воны мөнгөн тэмдэгтийн тэмдэг + Бүтэн өргөнтэй таслал + Бүтэн өргөнтэй анхаарлын тэмдэг + Дүрс үсгийн цэг + Бүтэн өргөнтэй асуултын тэмдэг + Дунд цэг + Баруун талын давхар хашилтын тэмдэг + Дүрс үсгийн таслал + Бүтэн өргөнтэй давхар цэг + Бүтэн өргөнтэй цэгтэй таслал + Бүтэн өргөнтэй \"Ба\" тэмдэг + Бүтэн өргөнтэй өргөлтийн тэмдэг + Бүтэн өргөнтэй долгионт тэмдэг + Зүүн талын давхар хашилт + Бүтэн өргөнтэй зүүн талын хаалт + Бүтэн өргөнтэй баруун талын хаалт + Бүтэн өргөнтэй од + Бүтэн өргөнтэй доогуур зураас + Баруун талын дан хашилт + Бүтэн өргөнтэй зүүн талын угалзан хаалт + Бүтэн өргөнтэй баруун талын угалзан хаалт + Бүтэн өргөнтэй багын тэмдэг + Бүтэн өргөнтэй ихийн тэмдэг + Зүүн талын дан хашилт + diff --git a/utils/src/main/res/values-mr/strings.xml b/utils/src/main/res/values-mr/strings.xml new file mode 100644 index 0000000..72e8d3a --- /dev/null +++ b/utils/src/main/res/values-mr/strings.xml @@ -0,0 +1,50 @@ + + + %1$d पासून %2$d पर्यंत वर्ण + वर्ण %1$d + अशीर्षकांकित + %1$s, कॉपी केला + कॅपिटल %1$s + %1$d %2$s + %1$s वापरत आहे + नवीन शॉर्टकट सेट करण्‍यासाठी की संयोग दाबा. त्यात कमीतकमी ALT किंवा Control की असणे आवश्‍यक आहे. + नवीन शॉर्टकट सेट करण्‍यासाठी %1$s सुधारणा की सह की संयोग सेट करा. + नियुक्त न केलेले + Shift + Alt + Ctrl + सर्च + उजवा बाण + डावा बाण + वर बाण + खाली बाण + डीफॉल्ट + वर्ण + शब्द + ओळी + परिच्छेद + Windows + खुणा + शीर्षक + सूची + लिंक + नियंत्रणे + विशिष्ट आशय + शीर्षके + नियंत्रणे + लिंक + %1$s चित्रामध्ये चित्र + %1$s शीर्षस्थानी, %2$s तळाशी + %1$s डावीकडे, %2$s उजवीकडे + %1$s उजवीकडे %2$s डावीकडे + %3$d पैकी %1$d ते %2$d आयटम दर्शवित आहोत. + %2$d पैकी %1$d आयटम दर्शवित आहोत. + %2$d पैकी %1$d पृष्ठ + %2$d पैकी %1$d + %1$s (%2$s) + बाहेर पडा + %1$s दाखवत आहे + कीबोर्ड लपवलेला आहे + वाचिक फीडबॅक सुरू आहे + वाचिक फीडबॅक बंद आहे + diff --git a/utils/src/main/res/values-mr/strings_symbols.xml b/utils/src/main/res/values-mr/strings_symbols.xml new file mode 100644 index 0000000..3fe2cfe --- /dev/null +++ b/utils/src/main/res/values-mr/strings_symbols.xml @@ -0,0 +1,140 @@ + + + ॲपॉस्ट्रॉफी + जोडाक्षर + यापेक्षा कमीचे चिन्ह + यापेक्षा जास्तचे चिन्ह + ताराचिन्ह + At + बॅकस्लॅश + बुलेट + कॅरेट + सेंटचे चिन्ह + अपूर्ण विराम + स्वल्पविराम + कॉपीराइट + डावा महिरपी कंस + उजवा महिरपी कंस + अंशाचे चिन्ह + भागिले आयकन + डॉलर आयकन + पदलोप + Em डॅश + En डॅश + युरो + उद्गारचिन्ह आयकन + अनुदात्त स्वर + डॅश + खालील दुहेरी अवतरण + गुणिले आयकन + नवीन रेखा + परिच्छेद खूण + डावा कंस + उजवा कंस + टक्के + पूर्णविरामचिन्ह + पाय + पाउंड + पाउंड या चलनाचे चिन्ह + प्रश्नचिन्ह + अवतरण + नोंदणीकृत ट्रेडमार्क + अर्धविराम + स्लॅश + Space + डावा चौकोनी कंस + उजवा चौकोनी कंस + वर्गमूळ + ट्रेडमार्क + अंडरस्कोअर + उभी रेष + येन + आयकन नाही + ब्रोकन बार + सूक्ष्म आयकन + जवळपस बरोबर + बरोबर नाही + चलन आयकन + अनुभाग आयकन + वरदर्शी बाण + डावीकडचा बाण + रुपया + काळे हृदय + टिल्डे आयकन + समान आयकन + वोन या चलनाचे चिन्ह + संदर्भ आयकन + पांढरा तारा + काळा तारा + पांढरे बदाम + पांढरे मंडळ + काळे मंडळ + सोलर आयकन + बुल्‍सआय + सफेद किलवर पत्ते + पांढरे इस्‍पिक पत्ते + पांढरे डावे निर्देशित करणारी तर्जनी + पांढरे उजवे निर्देशित करणारी तर्जनी + डावे अर्धे काळे सह मंडळ + उजवे अर्धे काळे सह मंडळ + पांढरा चौरस + काळा चौरस + पांढरा वर निर्देशित करणारा त्रिकोण + पांढरा खाली निर्देशित करणारा त्रिकोण + पांढरे डावे निर्देशित करणारा त्रिकोण + पांढरे उजवे निर्देशित करणारा त्रिकोण + पांढरा हीरा + एक चतुर्थांश + आठवी टीप + सोळाव्‍या टिपा बीम केल्या + महिला आयकन + पुरूष आयकन + डावा काळा भिंगासारखा कंस + उजवा काळा भिंगासारखा कंस + डावा कोपरा कंस + उजवा कोपरा कंस + उजवीकडील बाण + खालील बाण + अधिक वजा आयकन + लीटर + सेल्‍सिअस अंश + फॅरेनहाइट अंश + अंदाजे समान + संकलनात्मक + गणितीय डावे कोन कंस + गणितीय उजवे कोन कंस + पोस्टल चिन्ह + वरच्या दिशेला पॉइंट करणारा काळा त्रिकोण + खालच्या दिशेला पॉइंट करणारा काळा त्रिकोण + चौकटचे काळ्या रंगाचे पत्ते + काताकानातील अर्ध्या रुंदीचा मधला बिंदू + लहान काळा चौकोन + डावा दुहेरी कोनदार कंस + उजवा दुहेरी कोनदार कंस + उलटे उद्गारवाचक चिन्ह + उलटे प्रश्नचिन्ह + वोन या चलनाचे चिन्ह + पूर्ण रुंदीचा स्वल्पविराम + पूर्ण रुंदीचे उद्गारवाचक चिन्ह + इडियोग्राफिक पूर्णविराम + पूर्ण रुंदीचे प्रश्न चिन्ह + मधला बिंदू + उजवे दुहेरी अवतरणचिन्ह + इडियोग्राफिक स्वल्पविराम + पूर्ण रुंदीचा अपूर्णविराम + पूर्ण रुंदीचा अर्धविराम + पूर्ण रुंदीचे अँपरसँड + पूर्ण रुंदीचे सर्कमफ्लेक्स + पूर्ण रुंदीचे टिल्डे + डावे दुहेरी अवतरणचिन्ह + पूर्ण रुंदीचा डावा कंस + पूर्ण रुंदीचा उजवा कंस + पूर्ण रुंदीचे ॲस्टेरिस्क + पूर्ण रुंदीचा अंडरस्कोअर + उजवे एकेरी अवतरणचिन्ह + पूर्ण रुंदीचा डावा महिरपी कंस + पूर्ण रुंदीचा उजवा महिरपी कंस + पूर्ण रुंदीचे लघुतर चिन्ह + पूर्ण रुंदीचे गुरुतर चिन्ह + डावे एकेरी अवतरणचिन्ह + diff --git a/utils/src/main/res/values-ms/strings.xml b/utils/src/main/res/values-ms/strings.xml new file mode 100644 index 0000000..5379aee --- /dev/null +++ b/utils/src/main/res/values-ms/strings.xml @@ -0,0 +1,50 @@ + + + Aksara %1$d hingga %2$d + Aksara %1$d + tidak bertajuk + disalin, %1$s + huruf besar %1$s + %1$d %2$s + Menggunakan %1$s + Tekan gabungan kekunci untuk menetapkan pintasan baharu yang mesti mengandungi sekurang-kurangnya kekunci ALT atau Control. + Tekan gabungan kekunci dengan kunci pengubah suai %1$s untuk menetapkan pintasan baharu. + Tidak ditentukan + Shift + Alt + Ctrl + Cari + Anak Panah ke Kanan + Anak Panah ke Kiri + Anak Panah ke Atas + Anak Panah ke Bawah + Lalai + Aksara + Perkataan + Baris + Perenggan + Windows + Tanda tempat + Tajuk + Senarai + Pautan + Kawalan + Kandungan khas + Tajuk + Kawalan + Pautan + Gambar dalam gambar %1$s + %1$s di sebelah atas, %2$s di sebelah bawah + %1$s di sebelah kiri, %2$s di sebelah kanan + %1$s di sebelah kanan, %2$s di sebelah kiri + Memaparkan item %1$d hingga %2$d daripada %3$d. + Memaparkan item %1$d daripada %2$d. + Halaman %1$d daripada %2$d + %1$d daripada %2$d + %1$s (%2$s) + Keluar + Menunjukkan %1$s + papan kekunci disembunyikan + Maklum balas tuturan dihidupkan + Maklum balas tuturan dimatikan + diff --git a/utils/src/main/res/values-ms/strings_symbols.xml b/utils/src/main/res/values-ms/strings_symbols.xml new file mode 100644 index 0000000..143acb7 --- /dev/null +++ b/utils/src/main/res/values-ms/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Koma terbalik + Ampersan + Tanda kurang daripada + Tanda lebih besar daripada + Asterisk + Di + Garis condong ke belakang + Titik tumpu khas + Tanda tinggi + Tanda sen + Titik bertindih + Koma + Hak cipta + Tanda kurung siku kiri + Tanda kurung siku kanan + Tanda darjah + Tanda bahagi + Tanda dolar + Elipsis + Sengkang em + Sengkang en + Euro + Tanda seru + Aksen grava + Sengkang + Tanda petikan berganda rendah + Tanda darab + Baris baharu + Tanda perenggan + Tanda kurung kiri + Tanda kurung kanan + Peratus + Tempoh + Pi + Paun + Tanda mata wang paun + Tanda soal + Petikan + Tanda dagangan berdaftar + Koma bernoktah + Garis condong + Ruang + Tanda kurung sudut kiri + Tanda kurung sudut kanan + Punca kuasa dua + Tanda dagangan + Garis bawah + Garis menegak + Yen + Tanda bukan + Bar patah + Tanda mikro + Hampir sama dengan + Tidak sama dengan + Tanda mata wang + Tanda bahagian + Anak panah ke atas + Anak panah ke kiri + Rupee + Lekuk Hitam + Tilde + Tanda sama + Tanda mata wang Won + Tanda Rujukan + Bintang putih + Bintang hitam + Lekuk Putih + Bulatan putih + Bulatan hitam + Simbol suria + Pusat sasaran + Set kelawar putih + Set sped putih + Indeks menuding ke kiri putih + Indeks menuding ke kanan putih + Bulatan dengan separuh kirinya hitam + Bulatan dengan separuh kanannya hitam + Segi empat putih + Empat segi hitam + Segi tiga menuding ke atas putih + Segi tiga putih ke bawah putih + Segi tiga menuding ke kiri putih + Segi tiga menuding ke kanan putih + Daiman putih + Not Suku + Not Kelapan + Not keenam belas dipancarkan + Simbol perempuan + Simbol lelaki + Tanda Kurung Kekanta Hitam Kiri + Tanda Kurung Kekanta Hitam Kanan + Tanda Kurung Bucu Kiri + Tanda Kurung Bucu Kanan + Anak Panah Arah Kanan + Anak Panah ke Bawah + Tanda campur tolak + Liter + Darjah celsius + Darjah fahrenheit + Lebih kurang sama + Kamiran + Tanda kurung sudut kiri matematik + Tanda kurung sudut kanan matematik + Tanda pos + Segi tiga hitam menuding ke atas + Segi tiga hitam menuding ke bawah + Sut berlian hitam + Titik tengah Katakana separuh lebar + Empat segi hitam kecil + Tanda kurung siku berkembar kiri + Tanda kurung siku berkembar kanan + Tanda seru terbalik + Tanda soal terbalik + Tanda mata wang Won + Koma lebar penuh + Tanda seru lebar penuh + Noktah ideografi + Tanda soal lebar penuh + Titik tengah + Tanda petikan berganda kanan + Koma ideografi + Tanda titik bertindih lebar penuh + Koma bertitik lebar penuh + Ampersan lebar penuh + Sirkumfleks lebar penuh + Tilde lebar penuh + Tanda petikan berganda kiri + Tanda kurung kiri lebar penuh + Tanda kurung kanan lebar penuh + Asterisk lebar penuh + Garis bawah lebar penuh + Tanda petikan tunggal kanan + Tanda kurung kurawal kiri lebar penuh + Tanda kurung kurawal kanan lebar penuh + Tanda kurang daripada lebar penuh + Tanda lebih besar daripada lebar penuh + Tanda petikan tunggal kiri + diff --git a/utils/src/main/res/values-my/strings.xml b/utils/src/main/res/values-my/strings.xml new file mode 100644 index 0000000..e03a1ec --- /dev/null +++ b/utils/src/main/res/values-my/strings.xml @@ -0,0 +1,50 @@ + + + စကားလုံး %1$d ကနေ %2$d + စာလုံး %1$d + ခေါင်းစဉ်မသိ + %1$s ကို ကူးပြီးပါပြီ။ + စာလုံးကြီး %1$s + %1$d%2$s + %1$s ကို သုံးနေ + အတိုကောက်အသစ် တစ်ခုပြုလုပ်ရန် ခလုတ်ပေါင်းစပ်ခြင်းကို နှိပ်ပါ။ ၎င်းတွင် ALT သို့မဟုတ် Control ခလုတ်ပါရပါမည်။ + ဖြတ်လမ်းအသစ်သတ်မှတ်ရန် %1$s ခလုတ်နှင့် တွဲနှိပ်ပါ။ + တာဝန် ပေးမထား + Shift + Alt + Ctrl + ရှာဖွေပါ + ညာညွှန်မြှား + ဘယ်ညွှန်မြှား + အပေါ်ညွှန်မြှား + အောက်ညွှန်မြှား + မူရင်း + အက္ခရာများ + စကားလုံးများ + စာကြောင်းများ + စာပိုဒ်များ + ဝင်းဒိုးများ + အထင်ကရနေရာများ + ခေါင်းစီးများ + စာရင်းများ + လင့်ခ်များ + ထိန်းချုပ်မှုများ + အထူးအကြောင်းအရာများ + ခေါင်းစီးများ + ထိန်းချုပ်မှုများ + လင့်ခ်များ + %1$s နှစ်ခုထပ်၍ကြည့်ခြင်း + %1$s က အပေါ်၊ %2$s က အောက် + %1$s က ဘယ်ဘက်၊ %2$s က ညာဘက် + %1$s က ညာဘက်၊ %2$s က ဘယ်ဘက် + ပြထားသည့် %1$d နှင့် %2$d မှာ %3$d ထဲမှဖြစ်၏။ + ပြထားသည့် %1$d မှာ %2$d ထဲမှ ဖြစ်၏။ + စာမျက်နှာ %2$d ထဲက %1$d + %2$d ထဲက %1$d + %1$s (%2$s) + ထွက်ရန် + %1$s ကို ပြနေသည် + ကီးဘုတ်ကို ဝှက်ထားသည် + အသံထွက် အကြံပြုချက်ကို ဖွင့်ထားသည် + အသံထွက် အကြံပြုချက်ကို ပိတ်ထားသည် + diff --git a/utils/src/main/res/values-my/strings_symbols.xml b/utils/src/main/res/values-my/strings_symbols.xml new file mode 100644 index 0000000..a71c611 --- /dev/null +++ b/utils/src/main/res/values-my/strings_symbols.xml @@ -0,0 +1,140 @@ + + + အပေါ်စတော်ဖီ + အန်ပါဆန် သင်္ကေတ + ပိုငယ်သည့် သင်္ကေတ + ပိုကြီးသည့် သင်္ကေတ + ခရေပွင့် သင်္ကေတ + အက်(တ်) သင်္ကတ + ဘယ်ဘက် မျဉ်းစောင်း + ကျည်ဖူးသင်္ကေတ + ကာရက် + ဆင့် သင်္ကေတ + ကိုလန် + ကော်မာ + မူပိုင်ခွင့် + ဘယ်ဘက် တွန့်ကွင်း + ညာဘက် တွန့်ကွင်း + ဒီဂရီ သင်္ကေတ + အစား လက္ခဏာ + ဒေါ်လာ သင်္ကေတ + အစက်သုံးစက် + အမ်(မ်) ဒက်ရှ် + အန်(န်) ဒက်ရှ် + ယူရို + အာမေဍိတ် သင်္ကေတ + ဂရေ့ဗ် အက်ဆန့် + ဒက်ရှ် + အောက်ဘက်ရှိ ဒါဘယ်လ်ကုဒ် + အမြှောက် လက္ခဏာ + လိုင်း အသစ် + စာပိုဒ် သင်္ကေတ + ကွင်းစ + ကွင်းပိတ် + ရာခိုင်နှုန်း + အစက် + ပိုင် + ပေါင် + စတာလင်ပေါင် ငွေကြေး သင်္ကေတ + မေးခွန်း သင်္ကေတ + ကုဒ် ခလုတ် + မှတ်ပုံတင်ပြီး တံဆိပ် + စီမီးကိုလန် + ညာဘက်မျဉ်းစောင်း + စပေ့စ်ကီး + ဘယ်ဘက်ရှိ လေးထောင့်ကွင်း + ညာဘက်ရှိ လေးထောင့်ကွင်း + နှစ်ထပ်ကိန်းရင်း + ကုန်တံဆိပ် + အန်ဒါးစကိုး + ဒေါင်လိုက်မျဉ်း + ယန်း + သင်္ကေတ မဟုတ် + ဘရုတ်ကန်း ဘား + မိုက်ခရို သင်္ကေတ + ညီမျှလုနီး သင်္ကေတ + မညီမျှခြင်း သင်္ကေတ + ငွေကြေး သင်္ကေတ + ဆက်ရှင် သင်္ကေတ + အပေါ်ပြ မြား + ဘယ်ပြ မြား + ရူပီ + နှလုံး အမည်း + ခလုတ် (~) + ညီမျှခြင်း သင်္ကေတ + ကိုရီးယားဝမ် ငွေကြေး သင်္ကေတ + ရည်ညွှန်း အမှတ် + ကြယ်ဖြူ + ကြယ် အမည်း + နှလုံးဖြူ + စက်ဝိုင်း အဖြူ + စက်ဝိုင်း အမည်း + နေ သင်္ကေတ + Bullseye + club suit အဖြူ + spade suit အဖြူ + ဘယ်သို့ ညွှန်ပြနေသည့် ညွှန်းကိန်းဖြူ + ညာသို့ ညွှန်ပြနေသည့် ညွှန်းကိန်းဖြူ + ဘယ်တစ်ဝက် မည်းနေသည့် စက်ဝိုင်း + ညာတစ်ဝက် မည်းနေသည့် စက်ဝိုင်း + စတုရန်း အဖြူ + စတုရန်း အမည် + အပေါ်သို့ ညွှန်ပြနေသည့ဘ် တြိဂံဖြူ + အောက်သို့ ညွှန်ပြနေသည့် တြိဂံဖြူ + ဘယ်သို့ ညွှန်ပြနေသည့် တြိဂံဖြူ + ညာသို့ ညွှန်ပြနေသည့် တြိဂံဖြူ + စိန်အဖြူ + ကွာတာ မှတ်ချက် + အဌမ မှတ်စု + အလင်းထိုးထားသည့် ဆယ်ခြောက်ခုမြောက် မှတ်စုများ + မ သင်္ကေတ + ကျား သင်္ကေတ + ပဲစေ့ပုံစံ အဖွင့်ကွင်းစ အမည်း + ပဲစေ့ပုံစံ ကွင်းပိတ် အမည်း + လေးထောင့် ကွင်းစ + လေးထောင့် ကွင်းပိတ် + ညာသို့ မြား + အောက်သို့ မြား + အပေါင်း အနှုတ် သင်္ကေတ + လီတာ + စင်တီဂရိတ် ဒီဂရီ + ဖာရင်ဟိုက် ဒီဂရီ + အကြမ်းအားဖြင့် ညီမျှ + ပေါင်းစည်းထား + သင်္ချာသုံး ထောင့်ကွင်းအဖွင့် + သင်္ချာသုံး ထောင့်ကွင်းအပိတ် + စာပို့အမှတ်အသား + အနက်ရောင်အပေါ်ညွှန်မြား + အနက်ရောင်အောက်ညွှန်မြား + အနက်ရောင်စိန်ပွင့်များ + ဗြက်တစ်ဝက် ခါတာ့ခါ့နာ့ အလယ်အစက် + အနက်ရောင်စတုရန်းအသေး + ထောင့်ကွင်းအဖွင့်နှစ်ခု + ထောင့်ကွင်းအပိတ်နှစ်ခု + ပြောင်းပြန် အာမေဍိတ်အမှတ်အသား + ပြောင်းပြန် မေးခွန်းအမှတ်အသား + ကိုရီးယားဝမ် ငွေကြေး သင်္ကေတ + ဗြက်အပြည့် ကော်မာ + ဗြက်အပြည့် အာမေဍိတ်အမှတ်အသား + အရုပ်စာ ပုဒ်ရပ် + ဗြက်အပြည့် မေးခွန်းအမှတ်အသား + အလယ် အစက် + နှစ်ထပ် မျက်တောင်အပိတ် အမှတ်အသား + အရုပ်စာ ကော်မာ + ဗြက်အပြည့် ကိုလန် + ဗြက်အပြည့် စီမီးကိုလန် + ဗြက်အပြည့် \'နှင့်\' သင်္ကေတ + ဗြက်အပြည့် သရသံသင်္ကေတ + ဗြက်အပြည့် နှာသံပြအမှတ်အသား + နှစ်ထပ် မျက်တောင်အဖွင့် အမှတ်အသား + ဗြက်အပြည့် ကွင်းစ + ဗြက်အပြည့် ကွင်းပိတ် + ဗြက်အပြည့် ခရေပွင့် + ဗြက်အပြည့် အောက်မျဉ်း + တစ်ခုတည်း မျက်တောင်အပိတ် အမှတ်အသား + ဗြက်အပြည့် တွန့်ကွင်းအဖွင့် + ဗြက်အပြည့် တွန့်ကွင်းအပိတ် + ဗြက်အပြည့် ပိုနည်းသည့်သင်္ကေတ + ဗြက်အပြည့် ပိုများသည့်သင်္ကေတ + တစ်ခုတည်း မျက်တောင်အဖွင့် အမှတ်အသား + diff --git a/utils/src/main/res/values-nb/strings.xml b/utils/src/main/res/values-nb/strings.xml new file mode 100644 index 0000000..68c9643 --- /dev/null +++ b/utils/src/main/res/values-nb/strings.xml @@ -0,0 +1,50 @@ + + + Tegn %1$d til %2$d + Tegn %1$d + uten navn + kopiert, %1$s + stor %1$s + %1$d %2$s + Bruker %1$s + Trykk inn tastekombinasjonen for å angi en ny hurtigtast. Den må inneholde minst ALT- eller Control-tasten. + Trykk inn tastekombinasjonen med den utløsende modifikatortasten %1$s for å angi en ny hurtigtast. + Ikke tildelt + Shift + Alt + Ctrl + Søk + Høyrepil + Venstrepil + Pil opp + Pil ned + Standard + Tegn + Ord + Linjer + Avsnitt + Vinduer + Landemerker + Overskrifter + Lister + Linker + Kontroller + Spesielt innhold + Overskrifter + Kontroller + Linker + %1$s bilde-i-bilde + %1$s øverst, %2$s nederst + %1$s til venstre, %2$s til høyre + %1$s til høyre, %2$s til venstre + Viser element %1$d til %2$d av %3$d. + Viser element %1$d av %2$d. + Side %1$d av %2$d + %1$d av %2$d + %1$s (%2$s) + Lukk + Viser %1$s + tastaturet er skjult + Taletilbakemelding er på + Taletilbakemelding er av + diff --git a/utils/src/main/res/values-nb/strings_symbols.xml b/utils/src/main/res/values-nb/strings_symbols.xml new file mode 100644 index 0000000..8d47fe4 --- /dev/null +++ b/utils/src/main/res/values-nb/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrofe + Og-tegn + Mindre-enn-tegn + Større-enn-tegn + Stjerne + Krøllalfa + Omvendt skråstrek + Punkttegn + Cirkumfleks + Cent-tegn + Kolon + Komma + Opphavsrettssymbolet + Venstre sløyfeparentes + Høyre sløyfeparentes + Gradetegn + Delingstegn + Dollartegn + Ellipse + Lang tankestrek + Kort tankestrek + Euro + Utropstegn + Gravisaksent + Tankestrek + Lavt dobbelt anførselstegn + Multiplikasjonstegn + Ny linje + Avsnittsmerke + Venstreparentes + Høyreparentes + Prosent + Punktum + Pi + Nummertegn + Pund-valutategn + Spørsmålstegn + Anførselstegn + Registrert varemerke + Semikolon + Skråstrek + Mellomrom + Venstre hakeparentes + Høyre hakeparentes + Kvadratrot + Varemerke + Understrek + Vertikal linje + Yen + Ikke-tegn + Brutt vertikalstrek + Mikrotegn + Nesten lik + Ikke lik + Valutategn + Seksjonstegn + Pil opp + Venstrepil + Rupi + Svart hjerte + Tilde + Likhetstegn + Won-valutategn + Referansemerke + Hvit stjerne + Svart stjerne + Hvitt hjerte + Hvit sirkel + Svart sirkel + Solsymbol + Blink + Hvit kløver + Hvit spar + Hvitt venstrepekende indekssymbol + Hvitt høyrepekende indekssymbol + Sirkel med venstre halvdel i svart + Sirkel med høyre halvdel i svart + Hvitt kvadrat + Svart kvadrat + Hvit oppoverpekende trekant + Hvit nedpekende trekant + Hvit venstrepekende triangel + Hvit høyrepekende trekant + Hvit ruter + Firedelsnote + Åttedelsnote + Sekstendelsnoter som er slått sammen med notebjelker + Hunnsymbol + Hannsymbol + Svart linseformet venstre hakeparentes + Svart linseformet høyre hakeparentes + Venstre hjørnehakeparentes + Høyre hjørnehakeparentes + Pil høyre + Pil ned + Pluss/minus-tegn + Liter + Grad celsius + Grad Fahrenheit + Omtrent like + Integral + Matematisk venstre vinkelparentes + Matematisk høyre vinkelparentes + Postmerke + Svart trekant som peker opp + Svart trekant som peker ned + Svart ruter + Halvbreddes halvhøyt punktum for katakana + Lite, svart kvadrat + Venstre dobbel vinkelparentes + Høyre dobbel vinkelparentes + Omvendt utropstegn + Omvendt spørsmålstegn + Won-valutategn + Komma – full bredde + Utropstegn – full bredde + Ideografisk punktum + Spørsmålstegn – full bredde + Halvhøyt punktum + Dobbelt anførselstegn, høyre + Ideografisk komma + Kolon – full bredde + Semikolon – full bredde + Ampersand – full bredde + Cirkumfleks – full bredde + Tildetegn – full bredde + Dobbelt anførselstegn, venstre + Venstre parentes – full bredde + Høyre parentes – full bredde + Asterisk – full bredde + Understrek – full bredde + Enkelt anførselstegn, høyre + Venstre krøllparentes – full bredde + Høyre krøllparentes – full bredde + Mindre-enn-tegn – full bredde + Større-enn-tegn – full bredde + Enkelt anførselstegn, venstre + diff --git a/utils/src/main/res/values-ne/strings.xml b/utils/src/main/res/values-ne/strings.xml new file mode 100644 index 0000000..ccc5c92 --- /dev/null +++ b/utils/src/main/res/values-ne/strings.xml @@ -0,0 +1,50 @@ + + + %2$d देखि %1$d सम्मका वर्णहरू + वर्ण %1$d + शीर्षक नभएको + प्रतिलिपि बनाइयो, %1$s + ठुलो %1$s + %1$d %2$s + प्रयोग गर्दै %1$s + नयाँ सर्टकट सेट गर्न कुञ्जी संयोजन थिच्नुहोस्। यसमा कम्तीमा ALT वा नियन्त्रण कुञ्जी हुनु पर्छ। + नयाँ सर्टकट सेट गर्नाका लागि %1$s परिमार्जक कुञ्जी सँगसँगै कुनै अर्को कुञ्जीलाई थिच्नुहोस्। + काम नतोकिएको + Shift + Alt + Ctrl + खोज + दायाँ फर्केको तीर + बायाँ फर्केको तीर + माथि फर्केको तीर + तल फर्केको तीर + डिफल्ट + वर्णहरू + शब्दहरु + हरपहरू + अनुच्छेदहरु + विन्डोहरू + ऐतिहासिक स्थलहरू + शीर्षकहरू + सूचीहरू + लिंकहरू + नियन्त्रणहरू + विशेष सामग्री + शीर्षकहरू + नियन्त्रणहरू + लिंकहरू + %1$s फोटो भित्रको फोटो + टुप्पोमा %1$s, फेदमा %2$s + बायॉंतिर %1$s, दायॉंतिर %2$s + दायॉंतिर %1$s, बायॉंतिर %2$s + %3$d को %2$d लाई.%1$d आइटम देखाउँदै। + %2$d को %1$d आइटम देखाउँदै। + %2$d मध्ये पृष्ठ %1$d + %2$d मध्ये %1$d + %1$s (%2$s) + बाहिर + %1$s देखाइँदै + किबोर्ड लुकाइएको छ + बोलेर प्रतिक्रिया दिने सुविधा अन छ + बोलेर प्रतिक्रिया दिने सुविधा अफ छ + diff --git a/utils/src/main/res/values-ne/strings_symbols.xml b/utils/src/main/res/values-ne/strings_symbols.xml new file mode 100644 index 0000000..8cadb9c --- /dev/null +++ b/utils/src/main/res/values-ne/strings_symbols.xml @@ -0,0 +1,140 @@ + + + सम्बोधन चिन्ह + एम्परसेन्ड + \"भन्दा कम\" जनाउने चिन्ह + \"भन्दा ठुलो\" जनाउने चिन्ह + ताराङ्कन + मा + ब्याकस्ल्यास + बुलेट + क्यारेट + \"सेन्ट\' जनाउने चिन्ह + विराम + अल्पविराम + प्रतिलिपि अधिकार + बायाँ घुम्रेको कोष्ठ + दायाँ घुम्रेको कोष्ठ + डिग्री जनाउने चिन्ह + विभाजन चिन्ह + डलर चिन्ह + पदलोपचिन्ह + इएम ड्यास + इएन ड्यास + युरो + विस्मयाधिबोधक चिन्ह + ग्रेभ एक्सेन्ट + ड्यास + तल्लो दोहोरो उद्दरण चिन्ह + गुणन चिन्ह + नयाँ रेखा + अनुच्छेद चिन्ह + बायाँ कोष्ठ + दायाँ कोष्ठ + प्रतिशत + पूर्णविराम + पाई + पाउण्ड + पाउन्ड स्टर्लिङ मुद्रा जनाउने चिन्ह + प्रश्न चिन्ह + उद्धरण + पन्जीकृत ट्रेडमार्क + सेमिकोलोन + स्ल्यास + स्पेस + बायाँ वर्ग कोष्ठ + दायाँ वर्ग कोष्ठ + वर्गमूल + ट्रेडमार्क + अन्डरस्कोर + ठाडो रेखा + येन + चिन्ह छैन + भाँचिएको बार + माइक्रो चिन्ह + \"लगभग बराबर\" जनाउने चिन्ह + \"बराबर छैन\" भनी जनाउने चिन्ह + मुद्रा चिन्ह + खण्ड चिन्ह + माथि तीर + बायाँ तीर + रुपैयाँ + ब्ल्याक हर्ट + टिल्डे + समान चिन्ह + कोरियाली वन मुद्रा जनाउने चिन्ह + सन्दर्भ चिन्ह + सेतो तारा + ब्ल्याक स्टार + ह्वाइट हर्ट + सेतो सर्कल + ब्ल्याक सर्कल + सोलार प्रतीक + बुल्सआइ + सेतो क्लब सूट + सेतो स्पेड सूट + सेतो बायाँ दर्साइएको सूचाङ्क + सेतो दायाँ दर्साइएको सूचाङ्क + बायाँ आधा कालो सँग वृत्त + दायाँ आधा कालो सँग वृत्त + सेतो वर्ग + ब्ल्याक स्क्वायर + सेतो माथि दर्साइएको त्रिकोण + सेतो तल त्रिकोण दर्साइएको + सेतो बायाँ दर्साइएको त्रिकोण + सेतो दायाँ दर्साइएको त्रिकोण + सेतो हीरा + क्वार्टर टिप्पणी + आठौं टिप्पणी + बिम गरिएको सोह्रौं टिप्पणीहरू + महिला प्रतीक + पुरुष प्रतीक + बायाँ कालो लेन्टिकुलर कोष्ठक + दायाँ कालो लेन्टिकुलर कोष्ठक + बायाँ कुनाको कोष्ठक + दायाँ कुनाको कोष्ठक + दाहिने तर्फको तीर + तल्लो तर्फको तीर + प्लस माइनस चिन्ह + लिटर + सेल्सियस डिग्री + फरेनहाइट डिग्री + लगभग बराबर + अभिन्न + गणितीय बायाँ कोणको कोष्ठ + गणितीय दायाँ कोणको कोष्ठ + हुलाक चिन्ह + माथितिर फर्केको कालो रङको त्रिभुज + तलतिर फर्केको कालो रङको त्रिभुज + कालो रङको इँट + काटाकाना लिपिको आधा चौडाइ भएको मिडल डट + कालो रङको सानो वर्गाकार + बायाँतिरको दोहोरो कोण कोष्ठक + दायाँतिरको दोहोरो कोण कोष्ठक + उल्टो विस्मयादिबोधक चिन्ह + उल्टो प्रश्नवाचक चिन्ह + कोरियाली वन मुद्रा जनाउने चिन्ह + पूर्ण चौडाइको अल्पविराम + पूर्ण चौडाइको विस्मयादिबोधक चिन्ह + इडयोग्राफिक फुल स्टप + पूर्ण चौडाइको प्रश्न चिन्ह + मिडल डट + दायाँतिरको दोहोरो उद्धरण चिन्ह + इडयोग्राफिक अल्पविराम + पूर्ण चौडाइको अपूर्ण विराम + पूर्ण चौडाइको अर्धविराम + पूर्ण चौडाइको एम्परसेन्ड + पूर्ण चौडाइको सर्कम्फ्लेक्स + पूर्ण चौडाइको टिल्डे + बायाँतिरको दोहोरो उद्धरण चिन्ह + पूर्ण चौडाइको बायाँ कोष्ठक + पूर्ण चौडाइको दायाँ कोष्ठक + पूर्ण चौडाइको एस्टरिस्क + पूर्ण चौडाइको अन्डरस्कोर + दायाँतिरको एकल उद्धरण चिन्ह + पूर्ण चौडाइको बायाँ मझौला कोष्ठक + पूर्ण चौडाइको दायाँ मझौला कोष्ठक + पूर्ण चौडाइको \'भन्दा कम\' चिन्ह + पूर्ण चौडाइको \'भन्दा बढी\' चिन्ह + बायाँतिरको एकल उद्धरण चिन्ह + diff --git a/utils/src/main/res/values-night-v29/colors.xml b/utils/src/main/res/values-night-v29/colors.xml new file mode 100644 index 0000000..4e427a6 --- /dev/null +++ b/utils/src/main/res/values-night-v29/colors.xml @@ -0,0 +1,30 @@ + + + + + + @color/google_blue300 + @color/google_blue100 + + @color/material_grey_800 + @color/google_white + + + @color/google_black + @color/google_grey200 + + diff --git a/utils/src/main/res/values-night-v31/colors.xml b/utils/src/main/res/values-night-v31/colors.xml new file mode 100644 index 0000000..963383e --- /dev/null +++ b/utils/src/main/res/values-night-v31/colors.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/utils/src/main/res/values-nl/strings.xml b/utils/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..ba3433a --- /dev/null +++ b/utils/src/main/res/values-nl/strings.xml @@ -0,0 +1,50 @@ + + + Tekens %1$d tot %2$d + Teken %1$d + naamloos + gekopieerd, %1$s + hoofdletter %1$s + %1$d %2$s + %1$s gebruiken + Druk op een toetsencombinatie om een nieuwe sneltoets in te stellen. Deze sneltoets moet in elk geval de Alt- of Ctrl-toets bevatten. + Druk op de toetscombinatie met de functietoets %1$s om een nieuwe sneltoets in te stellen. + Niet toegewezen + Shift + Alt + Ctrl + Zoeken + Pijl-rechts + Pijl-links + Pijl-omhoog + Pijl-omlaag + Standaard + Tekens + Woorden + Regels + Alinea\'s + Vensters + Herkenningspunten + Koppen + Lijsten + Links + Bedieningselementen + Speciale content + Koppen + Bedieningselementen + Links + Scherm-in-scherm voor %1$s + %1$s bovenaan, %2$s onderaan + %1$s aan de linkerkant, %2$s aan de rechterkant + %1$s aan de rechterkant, %2$s aan de linkerkant + Items %1$d tot en met %2$d van %3$d worden getoond. + Item %1$d van %2$d wordt getoond. + Pagina %1$d van %2$d + %1$d van %2$d + %1$s (%2$s) + Sluiten + %1$s wordt getoond + toetsenbord verborgen + Gesproken feedback staat aan + Gesproken feedback staat uit + diff --git a/utils/src/main/res/values-nl/strings_symbols.xml b/utils/src/main/res/values-nl/strings_symbols.xml new file mode 100644 index 0000000..2e14b39 --- /dev/null +++ b/utils/src/main/res/values-nl/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Ampersand + Minder dan-teken + Groter dan-teken + Sterretje + Apenstaartje + Backslash + Opsommingsteken + Inlasteken + Cent-teken + Dubbele punt + Komma + Auteursrecht + Accolade links + Accolade rechts + Graden-teken + Deelteken + Dollarteken + Weglatingsteken + Em-streepje + En-streepje + Euro + Uitroepteken + Accent grave + Streepje + Lage dubbele aanhalingstekens + Vermenigvuldigingsteken + Nieuwe regel + Alineateken + Haakje openen + Haakje sluiten + Procent + Punt + Pi + Hekje + Pond-valutasymbool + Vraagteken + Aanhalingsteken + Gedeponeerd handelsmerk + Puntkomma + Slash + Spatiebalk + Vierkante haak links + Vierkante haak rechts + Vierkantswortel + Handelsmerk + Onderstrepen + Verticale lijn + Yen + Is niet-teken + Onderbroken balk + Micro-teken + Bijna gelijk aan + Niet gelijk aan + Valutasymbool + Paragraafteken + Pijl omhoog + Pijl naar links + Roepie + Zwart hart + Tilde + Gelijkteken + Won-valutasymbool + Referentieteken + Witte ster + Zwarte ster + Witte harten + Witte stip + Zwarte stip + Zon-symbool + Roos + Witte klaveren + Witte schoppen + Witte wijsvinger naar links wijzend + Witte wijsvinger naar rechts wijzend + Cirkel met linkerhelft zwart + Cirkel met rechterhelft zwart + Wit vierkant + Zwart vierkant + Witte driehoek omhoog wijzend + Witte driehoek omlaag wijzend + Witte driehoek naar links wijzend + Witte driehoek naar rechts wijzend + Witte ruiten + Kwart-noot + Achtste-noot + Verbonden zestiende-noten + Vrouw-symbool + Man-symbool + Links lensvorming zwart haakje + Rechts lensvorming zwart haakje + Linkerhoek van haak + Rechterhoek van haak + Pijl naar rechts + Pijl omlaag + Plusminus-teken + Liter + Celsius-gradenteken + Fahrenheit-gradenteken + Is ongeveer gelijk aan + Integraal + Wiskundige punthaak links + Wiskundige punthaak rechts + Post-teken + Zwarte driehoek die omhoog wijst + Zwarte driehoek die omlaag wijst + Zwarte ruit + Katakana middenpunt op halve breedte + Klein zwart vierkant + Dubbele punthaak links + Dubbele punthaak rechts + Omgekeerd uitroepteken + Omgekeerd vraagteken + Won-valutasymbool + Komma op volledige breedte + Uitroepteken op volledige breedte + Ideografische punt + Vraagteken op volledige breedte + Middenpunt + Dubbel aanhalingsteken rechts + Ideografische komma + Dubbelepunt op volledige breedte + Puntkomma op volledige breedte + Ampersand op volledige breedte + Circumflex op volledige breedte + Tilde op volledige breedte + Dubbel aanhalingsteken links + Haakje links op volledige breedte + Haakje rechts op volledige breedte + Asterisk op volledige breedte + Underscore op volledige breedte + Enkel aanhalingsteken rechts + Accolade links op volledige breedte + Accolade rechts op volledige breedte + Kleiner dan-teken op volledige breedte + Groter dan-teken op volledige breedte + Enkel aanhalingsteken links + diff --git a/utils/src/main/res/values-no/strings.xml b/utils/src/main/res/values-no/strings.xml new file mode 100644 index 0000000..68c9643 --- /dev/null +++ b/utils/src/main/res/values-no/strings.xml @@ -0,0 +1,50 @@ + + + Tegn %1$d til %2$d + Tegn %1$d + uten navn + kopiert, %1$s + stor %1$s + %1$d %2$s + Bruker %1$s + Trykk inn tastekombinasjonen for å angi en ny hurtigtast. Den må inneholde minst ALT- eller Control-tasten. + Trykk inn tastekombinasjonen med den utløsende modifikatortasten %1$s for å angi en ny hurtigtast. + Ikke tildelt + Shift + Alt + Ctrl + Søk + Høyrepil + Venstrepil + Pil opp + Pil ned + Standard + Tegn + Ord + Linjer + Avsnitt + Vinduer + Landemerker + Overskrifter + Lister + Linker + Kontroller + Spesielt innhold + Overskrifter + Kontroller + Linker + %1$s bilde-i-bilde + %1$s øverst, %2$s nederst + %1$s til venstre, %2$s til høyre + %1$s til høyre, %2$s til venstre + Viser element %1$d til %2$d av %3$d. + Viser element %1$d av %2$d. + Side %1$d av %2$d + %1$d av %2$d + %1$s (%2$s) + Lukk + Viser %1$s + tastaturet er skjult + Taletilbakemelding er på + Taletilbakemelding er av + diff --git a/utils/src/main/res/values-no/strings_symbols.xml b/utils/src/main/res/values-no/strings_symbols.xml new file mode 100644 index 0000000..8d47fe4 --- /dev/null +++ b/utils/src/main/res/values-no/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrofe + Og-tegn + Mindre-enn-tegn + Større-enn-tegn + Stjerne + Krøllalfa + Omvendt skråstrek + Punkttegn + Cirkumfleks + Cent-tegn + Kolon + Komma + Opphavsrettssymbolet + Venstre sløyfeparentes + Høyre sløyfeparentes + Gradetegn + Delingstegn + Dollartegn + Ellipse + Lang tankestrek + Kort tankestrek + Euro + Utropstegn + Gravisaksent + Tankestrek + Lavt dobbelt anførselstegn + Multiplikasjonstegn + Ny linje + Avsnittsmerke + Venstreparentes + Høyreparentes + Prosent + Punktum + Pi + Nummertegn + Pund-valutategn + Spørsmålstegn + Anførselstegn + Registrert varemerke + Semikolon + Skråstrek + Mellomrom + Venstre hakeparentes + Høyre hakeparentes + Kvadratrot + Varemerke + Understrek + Vertikal linje + Yen + Ikke-tegn + Brutt vertikalstrek + Mikrotegn + Nesten lik + Ikke lik + Valutategn + Seksjonstegn + Pil opp + Venstrepil + Rupi + Svart hjerte + Tilde + Likhetstegn + Won-valutategn + Referansemerke + Hvit stjerne + Svart stjerne + Hvitt hjerte + Hvit sirkel + Svart sirkel + Solsymbol + Blink + Hvit kløver + Hvit spar + Hvitt venstrepekende indekssymbol + Hvitt høyrepekende indekssymbol + Sirkel med venstre halvdel i svart + Sirkel med høyre halvdel i svart + Hvitt kvadrat + Svart kvadrat + Hvit oppoverpekende trekant + Hvit nedpekende trekant + Hvit venstrepekende triangel + Hvit høyrepekende trekant + Hvit ruter + Firedelsnote + Åttedelsnote + Sekstendelsnoter som er slått sammen med notebjelker + Hunnsymbol + Hannsymbol + Svart linseformet venstre hakeparentes + Svart linseformet høyre hakeparentes + Venstre hjørnehakeparentes + Høyre hjørnehakeparentes + Pil høyre + Pil ned + Pluss/minus-tegn + Liter + Grad celsius + Grad Fahrenheit + Omtrent like + Integral + Matematisk venstre vinkelparentes + Matematisk høyre vinkelparentes + Postmerke + Svart trekant som peker opp + Svart trekant som peker ned + Svart ruter + Halvbreddes halvhøyt punktum for katakana + Lite, svart kvadrat + Venstre dobbel vinkelparentes + Høyre dobbel vinkelparentes + Omvendt utropstegn + Omvendt spørsmålstegn + Won-valutategn + Komma – full bredde + Utropstegn – full bredde + Ideografisk punktum + Spørsmålstegn – full bredde + Halvhøyt punktum + Dobbelt anførselstegn, høyre + Ideografisk komma + Kolon – full bredde + Semikolon – full bredde + Ampersand – full bredde + Cirkumfleks – full bredde + Tildetegn – full bredde + Dobbelt anførselstegn, venstre + Venstre parentes – full bredde + Høyre parentes – full bredde + Asterisk – full bredde + Understrek – full bredde + Enkelt anførselstegn, høyre + Venstre krøllparentes – full bredde + Høyre krøllparentes – full bredde + Mindre-enn-tegn – full bredde + Større-enn-tegn – full bredde + Enkelt anførselstegn, venstre + diff --git a/utils/src/main/res/values-or/strings.xml b/utils/src/main/res/values-or/strings.xml new file mode 100644 index 0000000..8f1bf91 --- /dev/null +++ b/utils/src/main/res/values-or/strings.xml @@ -0,0 +1,50 @@ + + + %1$d ରୁ %2$d ବର୍ଣ୍ଣ + %1$dଟି ବର୍ଣ୍ଣ + ବେନାମୀ + କପୀ ହେଲା, %1$s + %1$s ବଡ଼ ଅକ୍ଷର + %1$d %2$s + %1$s ବ୍ୟବହାର କରି + ନୂତନ ଶର୍ଟକଟ୍‌ ସେଟ୍‌ କରିବାକୁ କୀ’ କମ୍ୱିନେଶନ୍ ଦବାନ୍ତୁ। ଏଥିରେ ଯେପରି ନିଶ୍ଚିତ ଭାବେ ALT କିମ୍ୱା କଣ୍ଟ୍ରୋଲ୍ କୀ’ ଅନ୍ତର୍ଭୁକ୍ତ ହୋଇଥିବ। + ନୂଆ ଶର୍ଟକଟ୍‌ ସେଟ୍‌ କରିବାକୁ %1$s ପରିବର୍ତକ ସହ କୀ ମିଶ୍ରଣ ଦବାନ୍ତୁ। + ଆସାଇନ୍‌ କରାଯାଇନାହିଁ + Shift + Alt + Ctrl + ସନ୍ଧାନ + ଡାହାଣପଟକୁ ତୀର + ବାମପଟକୁ ତୀର + ଉପର ଆଡ଼କୁ ତୀର + ତଳ ଆଡ଼କୁ ତୀର + ଡିଫଲ୍ଟ + ଅକ୍ଷର + ଶବ୍ଦ + ଧାଡ଼ି + ଅନୁଚ୍ଛେଦ + ୱିଣ୍ଡୋଗୁଡ଼ିକ + ଲ୍ୟାଣ୍ଡମାର୍କଗୁଡ଼ିକ + ହେଡିଂ + ତାଲିକା + ଲିଙ୍କ + ନିୟନ୍ତ୍ରଣ + ବିଶେଷ କଣ୍ଟେଣ୍ଟ + ହେଡିଂ + ନିୟନ୍ତ୍ରଣ + ଲିଙ୍କ + %1$s ପିକଚର୍-ଇନ୍-ପିକଚର୍ + ଶୀର୍ଷରେ %1$s, ନିମ୍ନରେ %2$s + ବାମରେ %1$s, ଡାହାଣରେ %2$s + ଡାହାଣରେ %1$s, ବାମରେ %2$s + %3$dର %1$d ରୁ %2$d ଆଇଟମ୍‌ ପ୍ରଦର୍ଶିତ ହେଉଛି + %2$d ର %1$d ଆଇଟମ୍‌ ପ୍ରଦର୍ଶିତ ହେଉଛି + ମୋଟ %2$d ରୁ %1$d ନମ୍ବର ପୃଷ୍ଠା + ମୋଟ %2$dରୁ %1$d + %1$s (%2$s) + ପ୍ରସ୍ଥାନ କରନ୍ତୁ + %1$s ଦେଖାଉଛି + କୀବୋର୍ଡ ଲୁଚାଯାଇଛି + କଥିତ ମତାମତ ଚାଲୁ ଅଛି + କଥିତ ମତାମତ ବନ୍ଦ ଅଛି + diff --git a/utils/src/main/res/values-or/strings_symbols.xml b/utils/src/main/res/values-or/strings_symbols.xml new file mode 100644 index 0000000..02a5a7a --- /dev/null +++ b/utils/src/main/res/values-or/strings_symbols.xml @@ -0,0 +1,140 @@ + + + ଆପୋଷ୍ଟ୍ରୋଫେ + ଆମ୍ପର୍ସାଣ୍ଡ + ଅପେକ୍ଷାକୃତ କମର ଚିହ୍ନ + ଅପେକ୍ଷାକୃତ ବଡର ଚିହ୍ନ + ଆଷ୍ଟରିସ୍କ + ରେ + ବ୍ୟାକ୍‌ସ୍ଲାଶ୍‌ + ବୁଲେଟ୍‌ + କ୍ୟାରେଟ୍‌ + ସେଣ୍ଟ୍ ଚିହ୍ନ + କୋଲନ୍‌ + କମା + କପୀରାଇଟ୍‌ + ବାମ କର୍ଲୀ ବ୍ରାକେଟ୍ + ଡାହାଣ କର୍ଲୀ ବ୍ରାକେଟ୍ + ଡିଗ୍ରୀ ଚିହ୍ନ + ବିଭାଜନ ଚିହ୍ନ + ଡଲାର୍‌ ଚିହ୍ନ + ଏଲିପସିସ୍ + ଇଏମ୍‌ ଡ୍ୟାଶ୍ + ଇଏନ୍‌ ଡ୍ୟାଶ୍ + ୟୁରୋ + ବିସ୍ମୟ ସୂଚକ ଚିହ୍ନ + ଗ୍ରେଭ୍‌ ଏକ୍ସେଣ୍ଟ + ଡ୍ୟାଶ୍ + ନିମ୍ନ ଡବଲ୍ କୋଟ୍‌ + ଗୁଣନ ଚିହ୍ନ + ନୂତନ ରେଖା + ପାରାଗ୍ରାଫ୍ ଚିହ୍ନ + ବାମ ପ୍ୟାରେନ୍‌ + ଡାହାଣ ପ୍ୟାରେନ୍‌ + ଶତକଡ଼ା + ସମୟ + ପାଏ + ପାଉଣ୍ଡ + ପାଉଣ୍ଡ ମୁଦ୍ରା ଚିହ୍ନ + ପ୍ରଶ୍ନ ଚିହ୍ନ + କୋଟ୍‌ + ପଞ୍ଜୀକୃତ ଟ୍ରେଡ୍‌ମାର୍କ + ସେମିକୋଲନ୍ + ସ୍ଲାଶ୍‌ + ସ୍ପେସ୍ + ବାମ ବର୍ଗାକାର ବ୍ରାକେଟ୍ + ଡାହାଣ ବର୍ଗାକାର ବ୍ରାକେଟ୍ + ବର୍ଗମୂଳ + ଟ୍ରେଡ୍‌ମାର୍କ + ଅଣ୍ଡରସ୍କୋର୍‌ + ଭର୍ଟିକାଲ୍ ରେଖା + ୟେନ୍‌ + ସାଇନ୍‌ ହୋଇନାହିଁ + ଖଣ୍ଡିତ ବାର୍ + ମାଇକ୍ରୋ ଚିହ୍ନ + ପ୍ରାୟ ସମାନ + ଏହା ସହ ପ୍ରାୟ ସମାନ + ମୁଦ୍ରା ଚିହ୍ନ + ବିଭାଗ ଚିହ୍ନ + ଉପରକୁ ଯାଇଥିବା ତୀର + ବାମ ପାର୍ଶ୍ୱକୁ ଯାଇଥିବା ତୀର + ଟଙ୍କା + ବ୍ଲାକ୍ ହାର୍ଟ + ଟିଲ୍ଡ + ସମାନ ଚିହ୍ନ + ଓ୍ୱନ୍ ମୁଦ୍ରା ଚିହ୍ନ + ସନ୍ଦର୍ଭ ଚିହ୍ନ + ହ୍ୱାଇଟ୍‌ ଷ୍ଟାର୍ + ବ୍ଲାକ୍ ଷ୍ଟାର୍ + ହ୍ୱାଇଟ୍‌ ହାର୍ଟ + ହ୍ୱାଇଟ୍‌ ସର୍କଲ୍ + ବ୍ଲାକ୍ ସର୍କଲ୍‌ + ସୌର ଚିହ୍ନ + ବୁଲ୍ସଆଏ + ହ୍ୱାଇଟ୍‌ କ୍ଲବ୍‌ ସୁଟ୍‌ + ହ୍ୱାଇଟ୍‌ ସ୍ପେଡ୍‌ ସୁଟ୍‌ + ଧଳା ବାମ ପଏଣ୍ଟିଙ୍ଗ ସୂଚୀ + ଧଳା ଡାହାଣ ପଏଣ୍ଟିଙ୍ଗ ସୂଚୀ + ବାମ ପଟେ ଅଧା କଳା ସହିତ ବୃତ୍ତ + ଡାହାଣ ପଟେ ଅଧା କଳା ସହିତ ବୃତ୍ତ + ଧଳା ବର୍ଗକ୍ଷେତ୍ର + କଳା ବର୍ଗକ୍ଷେତ୍ର + ଉପରକୁ ପଏଣ୍ଟ କରୁଥିବା ଧଳା ତ୍ରିଭୁଜ + ତଳକୁ ପଏଣ୍ଟ କରୁଥିବା ଧଳା ତ୍ରିଭୁଜ + ବାମକୁ ପଏଣ୍ଟ କରୁଥିବା ଧଳା ତ୍ରିଭୁଜ + ଡାହାଣକୁ ପଏଣ୍ଟ କରୁଥିବା ଧଳା ତ୍ରିଭୁଜ + ଧଳା ହୀରା + ଏକ-ଚତୁର୍ଥାଂଶ ନୋଟ୍‌ + ଅଷ୍ଟମ ନୋଟ୍‌ + ବିମ୍‌ ଥିବା ଷୋହଳତମ ନୋଟ୍‌ + ନାରୀ ଚିହ୍ନ + ପୁରୁଷ ଚିହ୍ନ + ବାମ କଳା ଲେଣ୍ଟିକୁଲାର ବ୍ରାକେଟ୍ + ଡାହାଣ କଳା ଲେଣ୍ଟିକୁଲାର ବ୍ରାକେଟ୍ + ବାମ କୋଣ ବ୍ରାକେଟ୍ + ଡାହାଣ କୋଣ ବ୍ରାକେଟ୍‍ + ଡାହାଣକୁ ଯାଇଥିବା ତୀର + ତଳକୁ ଯାଇଥିବା ତୀର + ଯୋଗ ବିଯୋଗ ଚିହ୍ନ + ଲିଟର୍‌ + ସେଲସିଅସ୍‌ ଡିଗ୍ରୀ + ଫାରେନହାଇଟ୍‌ ଡିଗ୍ରୀ + ପ୍ରାୟତଃ ସମାନ + ସମ ଆକଳନ + ଗାଣିତିକ ବାମ କୋଣ ବନ୍ଧନୀ + ଗାଣିତିକ ଡାହାଣ କୋଣ ବନ୍ଧନୀ + ଡାକ ଚିହ୍ନ + ଉପର ଆଡ଼କୁ ଇଙ୍ଗିତ କରୁଥିବା କଳା ତ୍ରିଭୁଜ + ତଳ ଆଡ଼କୁ ଇଙ୍ଗିତ କରୁଥିବା କଳା ତ୍ରିଭୁଜ + ହୀରାରେ ତିଆରି କଳା ସୁଟ୍ + ଅର୍ଦ୍ଧପ୍ରସ୍ଥ କଟକଣାର ମଧ୍ୟ ବିନ୍ଦୁ + ଛୋଟ କଳା ବର୍ଗକ୍ଷେତ୍ର + ବାମ ଦୁଇ କୋଣ ବନ୍ଧନୀ + ଡାହାଣ ଦୁଇ କୋଣ ବନ୍ଧନୀ + ଓଲଟା ବିସ୍ମୟସୂଚକ ଚିହ୍ନ + ଓଲଟା ପ୍ରଶ୍ନବାଚକ ଚିହ୍ନ + ଓ୍ୱନ୍ ମୁଦ୍ରା ଚିହ୍ନ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା କମା + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ବିସ୍ମୟସୂଚକ ଚିହ୍ନ + ସାଙ୍କେତିକ ପୂର୍ଣ୍ଣଚ୍ଛେଦ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ପ୍ରଶ୍ନବାଚକ ଚିହ୍ନ + ମଧ୍ୟ ବିନ୍ଦୁ + ଡାହାଣପଟର ଦୁଇ ଉଦ୍ଧୃତ ଚିହ୍ନ + ସାଙ୍କେତିକ କମା + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା କୋଲୋନ୍ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ସେମିକୋଲୋନ୍ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ଆମ୍ପରସ୍ୟାଣ୍ଡ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ସର୍କମଫ୍ଲେକ୍ସ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ଟିଲଡେ + ବାମପଟର ଦୁଇ ଉଦ୍ଧୃତ ଚିହ୍ନ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ବାମ ଚନ୍ଦ୍ରବନ୍ଧନୀ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ଡାହାଣ ଚନ୍ଦ୍ରବନ୍ଧନୀ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ତାରାକୃତି ଚିହ୍ନ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ଅଣ୍ଡରସ୍କୋର୍ + ଡାହାଣପଟର ଏକ ଉଦ୍ଧୃତ ଚିହ୍ନ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ବାମ କୁଟୀଳ ବନ୍ଧନୀ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ଡାହାଣ କୁଟୀଳ ବନ୍ଧନୀ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ଲେସ୍ ଦାନ୍ ଚିହ୍ନ + ପୂର୍ଣ୍ଣ-ଚଉଡ଼ା ଥିବା ଗ୍ରେଟର୍ ଦାନ୍ ଚିହ୍ନ + ବାମପଟର ଏକ ଉଦ୍ଧୃତ ଚିହ୍ନ + diff --git a/utils/src/main/res/values-pa/strings.xml b/utils/src/main/res/values-pa/strings.xml new file mode 100644 index 0000000..87ce1c1 --- /dev/null +++ b/utils/src/main/res/values-pa/strings.xml @@ -0,0 +1,50 @@ + + + ਅੱਖਰ %1$d ਤੋਂ %2$d + ਚਿੰਨ੍ਹ %1$d + ਬਿਨਾਂ ਸਿਰਲੇਖ + ਕਾਪੀ ਕੀਤੀ ਗਈ, %1$s + ਕੈਪੀਟਲ %1$s + %1$d %2$s + %1$s ਵਰਤ ਰਿਹਾ ਹੈ + ਨਵਾਂ ਸ਼ਾਰਟਕੱਟ ਸੈੱਟ ਕਰਨ ਲਈ ਕੁੰਜੀ ਸੁਮੇਲ ਦਬਾਓ। ਇਸ ਵਿੱਚ ਘੱਟੋ-ਘੱਟ ALT ਜਾਂ Control ਕੁੰਜੀ ਹੋਣੀ ਚਾਹੀਦੀ ਹੈ। + ਨਵਾਂ ਸ਼ਾਰਟਕੱਟ ਸੈੱਟ ਕਰਨ ਲਈ %1$s ਸੋਧਕ ਕੁੰਜੀ ਨਾਲ ਕੁੰਜੀ ਸੁਮੇਲ ਦਬਾਓ। + ਜ਼ਿੰਮੇ ਤੋਂ ਹਟਾਇਆ ਗਿਆ + ਸ਼ਿਫਟ ਕੁੰਜੀ + Alt + Ctrl + ਖੋਜੋ + ਤੀਰ ਸੱਜੇ + ਤੀਰ ਖੱਬੇ + ਤੀਰ ਉੱਪਰ + ਤੀਰ ਥੱਲੇ + ਪੂਰਵ-ਨਿਰਧਾਰਿਤ + ਅੱਖਰ-ਚਿੰਨ੍ਹ + ਸ਼ਬਦ + ਲਾਈਨਾਂ + ਪੈਰਾਗ੍ਰਾਫ + Windows + ਭੂਮੀ ਚਿੰਨ੍ਹ + ਸਿਰਲੇਖ + ਸੂਚੀਆਂ + ਲਿੰਕ + ਕੰਟਰੋਲ + ਖ਼ਾਸ ਸਮੱਗਰੀ + ਸਿਰਲੇਖ + ਕੰਟਰੋਲ + ਲਿੰਕ + %1$s ਤਸਵੀਰ-ਵਿੱਚ-ਤਸਵੀਰ + %1$s ਉੱਪਰ ਹੈ, %2$s ਹੇਠਾਂ ਹੈ + %1$s ਖੱਬੇ ਪਾਸੇ ਹੈ, %2$s ਸੱਜੇ ਪਾਸੇ ਹੈ + %1$s ਸੱਜੇ ਪਾਸੇ ਹੈ, %2$s ਖੱਬੇ ਪਾਸੇ ਹੈ + %3$d ਵਿੱਚੋਂ %1$d ਤੋਂ %2$d ਤੱਕ ਆਈਟਮਾਂ ਦਿਖਾ ਰਿਹਾ ਹੈ + %2$d ਵਿੱਚੋਂ %1$d ਆਈਟਮ ਦਿਖਾ ਰਿਹਾ ਹੈ। + %2$d ਵਿੱਚੋਂ %1$d ਪੰਨਾ + %2$d ਵਿੱਚੋਂ %1$d + %1$s (%2$s) + ਬਾਹਰ ਜਾਓ + %1$s ਨੂੰ ਦਿਖਾਇਆ ਜਾ ਰਿਹਾ ਹੈ + ਕੀ-ਬੋਰਡ ਲੁਕਾਇਆ ਗਿਆ + ਬੋਲੀ ਪ੍ਰਤੀਕਰਮ ਚਾਲੂ ਹੈ + ਬੋਲੀ ਪ੍ਰਤੀਕਰਮ ਬੰਦ ਹੈ + diff --git a/utils/src/main/res/values-pa/strings_symbols.xml b/utils/src/main/res/values-pa/strings_symbols.xml new file mode 100644 index 0000000..959308e --- /dev/null +++ b/utils/src/main/res/values-pa/strings_symbols.xml @@ -0,0 +1,140 @@ + + + ਅੱਖਰ ਲੋਪ ਚਿੰਨ੍ਹ + ਅਤੇ ਦਾ ਚਿੰਨ੍ਹ + ਮੁਕਾਬਲਤਨ ਘੱਟ ਦਾ ਚਿੰਨ੍ਹ + ਮੁਕਾਬਲਤਨ ਵੱਧ ਦਾ ਚਿੰਨ੍ਹ + ਤਾਰਾ ਚਿੰਨ੍ਹ + ਵਿਖੇ + ਬੈਕਸਲੈਸ਼ + ਬੁਲਿਟ + ਕੈਰੇਟ + ਸੈਂਟ ਦਾ ਚਿੰਨ੍ਹ + ਕੋਲਨ + ਕਾਮਾ + ਕਾਪੀਰਾਈਟ + ਖੱਬੀ ਕੁੰਡਲਦਾਰ ਬਰੈਕਟ + ਸੱਜੀ ਕੁੰਡਲਦਾਰ ਬਰੈਕਟ + ਡਿਗਰੀ ਦਾ ਚਿੰਨ੍ਹ + ਭਾਗ ਦਾ ਚਿੰਨ੍ਹ + ਡਾਲਰ ਚਿੰਨ੍ਹ + ਪਦ-ਲੋਪ ਚਿੰਨ੍ਹ + Em ਡੈਸ਼ + En ਡੈਸ਼ + ਯੂਰੋ + ਵਿਸਮਿਕ ਚਿੰਨ੍ਹ + ਗਰੇਵ ਧੁਨੀ ਚਿੰਨ੍ਹ + ਡੈਸ਼ + ਲੋ ਡਬਲ ਉਕਤੀ + ਗੁਣਾ ਦਾ ਚਿੰਨ੍ਹ + ਨਵੀਂ ਲਾਈਨ + ਪੈਰਾਗ੍ਰਾਫ ਚਿੰਨ੍ਹ + ਖੱਬਾ ਪੈਰਨ + ਸੱਜਾ ਪੈਰਨ + ਪ੍ਰਤੀਸ਼ਤ + ਮਿਆਦ + Pi + ਪੌਂਡ + ਪੌਂਡ ਮੁਦਰਾ ਦਾ ਚਿੰਨ੍ਹ + ਪ੍ਰਸ਼ਨ ਚਿੰਨ੍ਹ + ਉਕਤੀ + ਰਜਿਸਟਰਡ ਟ੍ਰੇਡਮਾਰਕ + ਅਰਧਵਿਰਾਮ + ਸਲੈਸ਼ + ਸਪੇਸ + ਖੱਬਾ ਵਰਗ ਬਰੈਕਟ + ਸੱਜਾ ਵਰਗ ਬਰੈਕਟ + ਵਰਗ ਮੂਲ + ਟ੍ਰੇਡਮਾਰਕ + ਹੇਠਾਂ-ਲਕੀਰ (ਅੰਡਰਸਕੋਰ) + ਖੜ੍ਹਵੀਂ ਰੇਖਾ + ਜਪਾਨੀ ਸਿੱਕੇ + ਚਿੰਨ੍ਹ ਨਹੀਂ + ਬ੍ਰੋਕਨ ਬਾਰ + ਮਾਈਕ੍ਰੋ ਚਿੰਨ੍ਹ + ਲਗਭਗ ਇਸ ਦੇ ਬਰਾਬਰ + ਇਸ ਦੇ ਬਰਾਬਰ ਨਹੀਂ ਹੈ + ਮੁਦਰਾ ਚਿੰਨ੍ਹ + ਸੈਕਸ਼ਨ ਚਿੰਨ੍ਹ + ਉੱਪਰ ਵੱਲ ਤੀਰ ਦਾ ਨਿਸ਼ਾਨ + ਖੱਬੇ ਪਾਸੇ ਵੱਲ ਤੀਰ ਦਾ ਨਿਸ਼ਾਨ + ਰੁਪਏ + ਕਾਲਾ ਦਿਲ + ਲਹਿਰੀਆ ਡੈਸ਼ + ਬਰਾਬਰ ਦਾ ਚਿੰਨ੍ਹ + ਵੌਨ ਮੁਦਰਾ ਦਾ ਚਿੰਨ੍ਹ + ਹਵਾਲਾ ਚਿੰਨ੍ਹ + ਸਫੈਦ ਤਾਰਾ + ਕਾਲਾ ਤਾਰਾ + ਸਫੈਦ ਦਿਲ + ਸਫੈਦ ਗੋਲਾ + ਕਾਲਾ ਗੋਲਾ + ਸੋਲਰ ਚਿੰਨ੍ਹ + ਛੋਟੀ ਗੋਲ ਖਿੜਕੀ + ਸਫੈਦ ਕਲਬ ਸੂਟ + ਸਫੈਦ ਸਪੇਡ ਸੂਟ + ਸਫੈਦ ਖੱਬੇ ਪਾਸੇ ਸੰਕੇਤ ਦੇਣ ਵਾਲੀ ਕ੍ਰਮ-ਸੂਚੀ + ਸਫੈਦ ਸੱਜੇ ਪਾਸੇ ਸੰਕੇਤ ਦੇਣ ਵਾਲੀ ਕ੍ਰਮ-ਸੂਚੀ + ਖੱਬਾ ਅੱਧੇ ਕਾਲੇ ਵਾਲਾ ਗੋਲਾ + ਸੱਜਾ ਅੱਧੇ ਕਾਲੇ ਵਾਲਾ ਗੋਲਾ + ਸਫੈਦ ਵਰਗ + ਕਾਲਾ ਵਰਗ + ਸਫੈਦ ਉੱਪਰ ਸੰਕੇਤ ਦੇਣ ਵਾਲਾ ਤਿਕੋਨ + ਸਫੈਦ ਹੇਠਾਂ ਸੰਕੇਤ ਦੇਣ ਵਾਲਾ ਤਿਕੋਨ + ਸਫੈਦ ਖੱਬੇ ਪਾਸੇ ਸੰਕੇਤ ਦੇਣ ਵਾਲਾ ਤਿਕੋਨ + ਸਫੈਦ ਸੱਜੇ ਪਾਸੇ ਸੰਕੇਤ ਦੇਣ ਵਾਲਾ ਤਿਕੋਨ + ਸਫੈਦ ਹੀਰਾ + ਤਿਮਾਹੀ ਨੋਟ + ਅੱਠਵਾਂ ਨੋਟ + ਬੀਮ ਕੀਤੇ ਸੋਲ੍ਹਵੇਂ ਨੋਟਸ + ਇਸਤਰੀ ਚਿੰਨ੍ਹ + ਪੁਰਸ਼ ਚਿੰਨ੍ਹ + ਖੱਬੀ ਕਾਲੀ ਲੈਂਟੀਕਿਊਲਰ ਬਰੈਕਟ + ਸੱਜੀ ਕਾਲੀ ਲੈਂਟੀਕਿਊਲਰ ਬਰੈਕਟ + ਖੱਬਾ ਕੋਨਾ ਬਰੈਕਟ + ਸੱਜਾ ਕੋਨਾ ਬਰੈਕਟ + ਸੱਜੇ ਪਾਸੇ ਵੱਲ ਤੀਰ ਦਾ ਨਿਸ਼ਾਨ + ਹੇਠਾਂ ਵੱਲ ਤੀਰ ਦਾ ਨਿਸ਼ਾਨ + ਜੋੜ ਘਟਾ ਦਾ ਚਿੰਨ੍ਹ + ਲਿਟਰ + ਸੈਲਸੀਅਸ ਡਿਗਰੀ + ਫਾਰੇਨਹੀਟ ਡਿਗਰੀ + ਲਗਭਗ ਬਰਾਬਰ + ਪੂਰਨ ਅੰਕ + ਗਣਿਤਕ ਖੱਬੀ ਬਰੈਕਟ + ਗਣਿਤਕ ਸੱਜੀ ਬਰੈਕਟ + ਡਾਕ ਚਿੰਨ੍ਹ + ਉੱਪਰ ਵੱਲ ਇਸ਼ਾਰਾ ਕਰ ਰਿਹਾ ਕਾਲਾ ਤਿਕੋਣ + ਹੇਠਾਂ ਵੱਲ ਇਸ਼ਾਰਾ ਕਰ ਰਿਹਾ ਕਾਲਾ ਤਿਕੋਣ + ਹੀਰੇ ਦੇ ਆਕਾਰ ਦਾ ਕਾਲਾ ਨਿਸ਼ਾਨ + ਹਾਫ਼ਵਿੱਥ ਕਾਟਾਕਾਨਾ ਵਿਚਕਾਰਲੀ ਬਿੰਦੀ + ਛੋਟਾ ਕਾਲਾ ਵਰਗ + ਖੱਬੇ ਪਾਸੇ ਮੂੰਹ ਕੀਤੇ ਦੂਹਰੇ ਕੋਣ ਵਾਲੀ ਬਰੈਕਟ + ਸੱਜੇ ਪਾਸੇ ਮੂੰਹ ਕੀਤੇ ਦੂਹਰੇ ਕੋਣ ਵਾਲੀ ਬਰੈਕਟ + ਉਲਟਾ ਵਿਸਮਿਕ ਚਿੰਨ੍ਹ + ਉਲਟਾ ਪ੍ਰਸ਼ਨ ਚਿੰਨ੍ਹ + ਵੌਨ ਮੁਦਰਾ ਦਾ ਚਿੰਨ੍ਹ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲਾ ਕਾਮਾ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲਾ ਵਿਸਮਿਕ ਚਿੰਨ੍ਹ + ਸੰਕੇਤਕ ਪੂਰਨ ਵਿਰਾਮ ਚਿੰਨ੍ਹ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲਾ ਪ੍ਰਸ਼ਨ ਚਿੰਨ੍ਹ + ਵਿਚਕਾਰਲੀ ਬਿੰਦੀ + ਸੱਜੇ ਪਾਸੇ ਦੂਹਰਾ ਉਕਤੀ ਚਿੰਨ੍ਹ + ਸੰਕੇਤਕ ਕਾਮਾ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲਾ ਕੋਲਨ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲਾ ਅਰਧਵਿਰਾਮ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲਾ ਅਤੇ ਦਾ ਚਿੰਨ੍ਹ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲਾ ਦੀਰਘਤਾ ਸੂਚੀ ਚਿੰਨ੍ਹ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲੀ ਲਹਿਰੀਆ ਡੈਸ਼ + ਖੱਬਾ ਦੂਹਰਾ ਉਕਤੀ ਚਿੰਨ੍ਹ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲੀ ਖੱਬੇ ਪਾਸੇ ਦੀ ਬਰੈਕਟ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲੀ ਸੱਜੇ ਪਾਸੇ ਦੀ ਬਰੈਕਟ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲਾ ਤਾਰਾ ਚਿੰਨ੍ਹ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲਾ ਅੰਡਰਸਕੋਰ + ਸੱਜੇ ਪਾਸੇ ਦਾ ਇਕਹਿਰਾ ਉਕਤੀ ਚਿੰਨ੍ਹ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲੀ ਖੱਬੀ ਕੁੰਡਲਦਾਰ ਬਰੈਕਟ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲੀ ਸੱਜੀ ਕੁੰਡਲਦਾਰ ਬਰੈਕਟ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲਾ ਮੁਕਾਬਲਤਨ ਘੱਟ ਚਿੰਨ੍ਹ + ਪੂਰੀ ਚੌੜਾਈ ਵਾਲਾ ਮੁਕਾਬਲਤਨ ਵੱਧ ਚਿੰਨ੍ਹ + ਖੱਬੇ ਪਾਸੇ ਦਾ ਇਕਹਿਰਾ ਉਕਤੀ ਚਿੰਨ੍ਹ + diff --git a/utils/src/main/res/values-pl/strings.xml b/utils/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..21fedb5 --- /dev/null +++ b/utils/src/main/res/values-pl/strings.xml @@ -0,0 +1,50 @@ + + + Znaki od %1$d do %2$d + Znak %1$d + bez tytułu + tekst skopiowany, %1$s + wielka litera %1$s + %1$d %2$s + Używam %1$s + Naciśnij kombinację klawiszy, by ustawić nowy skrót. Musi ona zawierać co najmniej klawisz ALT lub Ctrl. + Aby ustawić nowy skrót, naciśnij kombinację klawiszy z wciśniętym klawiszem %1$s. + Nieprzypisano + Shift + Alt + Ctrl + Szukaj + Strzałka w prawo + Strzałka w lewo + Strzałka w górę + Strzałka w dół + Domyślny + Znaki + Słowa + Wiersze + Akapity + Okna + Punkty orientacyjne + Nagłówki + Listy + Linki + Elementy sterujące + Zawartość specjalna + Nagłówki + Elementy sterujące + Linki + %1$s – obraz w obrazie + %1$s na górze, %2$s na dole + %1$s po lewej, %2$s po prawej + %1$s po prawej, %2$s po lewej + Wyświetlam elementy od %1$d do %2$d z %3$d. + Wyświetlam element %1$d z %2$d. + Strona %1$d z %2$d + %1$d z %2$d + %1$s (%2$s) + Zamknij + Pokazuję okno %1$s + klawiatura ukryta + Komunikaty głosowe są włączone + Komunikaty głosowe są wyłączone + diff --git a/utils/src/main/res/values-pl/strings_symbols.xml b/utils/src/main/res/values-pl/strings_symbols.xml new file mode 100644 index 0000000..93fcc4d --- /dev/null +++ b/utils/src/main/res/values-pl/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Ampersand + Znak mniejszości + Znak większości + Gwiazdka + Małpa + Odwrócony ukośnik + Punktor + Daszek + Znak centa + Dwukropek + Przecinek + Znak praw autorskich + Lewy nawias klamrowy + Prawy nawias klamrowy + Znak stopni + Znak dzielenia + Znak dolara + Wielokropek + Pauza + Półpauza + Euro + Wykrzyknik + Akcent słaby + Kreska + Cudzysłów podwójny dolny + Znak mnożenia + Nowy wiersz + Znak akapitu + Lewy nawias + Prawy nawias + Procent + Kropka + Pi + Krzyżyk + Znak waluty funt + Pytajnik + Cudzysłów + Zastrzeżony znak towarowy + Średnik + Ukośnik + Spacja + Lewy nawias kwadratowy + Prawy nawias kwadratowy + Pierwiastek kwadratowy + Znak towarowy + Podkreślenie + Linia pionowa + Jen + Znak zaprzeczenia + Przerwana kreska pionowa + Znak mikro + Prawie równe + Nierówne + Znak waluty + Znak paragrafu + Strzałka w górę + Strzałka w lewo + Rupia + Czarny znak kier + Tylda + Znak równości + Znak waluty won + Znak przypisu + Biała gwiazda + Czarna gwiazda + Biały znak kier + Białe koło + Czarne koło + Symbol słońca + Strzał w dziesiątkę + Biały znak trefl + Biały znak pik + Biała dłoń w lewo + Biała dłoń w prawo + Koło z lewą połową czarną + Koło z prawą połową czarną + Biały kwadrat + Czarny kwadrat + Biały trójkąt w górę + Biały trójkąt w dół + Biały trójkąt w lewo + Biały trójkąt w prawo + Biały znak karo + Ćwierćnuta + Nuta ósemkowa + Nuty szesnastkowe połączone + Symbol kobiety + Symbol mężczyzny + Nawias soczewkowy otwierający czarny + Nawias soczewkowy zamykający czarny + Lewy narożnik + Narożnik prawy + Strzałka w prawo + Strzałka w dół + Znak plus-minus + Litr + Stopnie Celsjusza + Stopnie Fahrenheita + Równa się około + Całka + Matematyczny lewy nawias trójkątny + Matematyczny prawy nawias trójkątny + Znaczek pocztowy + Czarny trójkąt skierowany w górę + Czarny trójkąt skierowany w dół + Czarne karo + Kropka środkowa katakana na półfirecie + Mały czarny kwadrat + Podwójny nawias ostrokątny otwierający + Podwójny nawias ostrokątny zamykający + Odwrócony wykrzyknik + Odwrócony pytajnik + Symbol południowokoreańskiego wona + Przecinek o pełnej szerokości + Wykrzyknik o pełnej szerokości + Kropka ideograficzna + Znak zapytania o pełnej szerokości + Kropka środkowa + Podwójny cudzysłów zamykający + Przecinek ideograficzny + Dwukropek o pełnej szerokości + Średnik o pełnej szerokości + Ampersand o pełnej szerokości + Daszek o pełnej szerokości + Tylda o pełnej szerokości + Podwójny cudzysłów otwierający + Nawias otwierający o pełnej szerokości + Nawias zamykający o pełnej szerokości + Gwiazdka o pełnej szerokości + Podkreślenie o pełnej szerokości + Pojedynczy cudzysłów zamykający + Nawias klamrowy o pełnej szerokości otwierający + Nawias klamrowy o pełnej szerokości zamykający + Znak mniejszości o pełnej szerokości + Znak większości o pełnej szerokości + Pojedynczy cudzysłów otwierający + diff --git a/utils/src/main/res/values-pt-rBR/strings.xml b/utils/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..c414ba1 --- /dev/null +++ b/utils/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,50 @@ + + + Caracteres de %1$d a %2$d + Caractere %1$d + sem título + copiado, %1$s + %1$s em letra maiúscula + \u0020%1$d %2$s + Usando %1$s + Pressione a combinação de teclas para definir um novo atalho. Ela deve conter pelo menos a tecla ALT ou Control. + Pressione a combinação de teclas com a tecla modificadora %1$s para definir um novo atalho. + Não atribuído + Shift + Alt + Ctrl + Pesquisa + Seta para a direita + Seta para a esquerda + Seta para cima + Seta para baixo + Padrão + Caracteres + Palavras + Linhas + Parágrafos + Janelas + Pontos de referência + Títulos + Listas + Links + Controles + Conteúdo especial + Títulos + Controles + Links + Picture-in-picture de %1$s + %1$s na parte superior, %2$s na parte inferior + %1$s à esquerda, %2$s à direita + %1$s à direita, %2$s à esquerda + Mostrando itens %1$d a %2$d de %3$d. + Mostrando item %1$d de %2$d. + Página %1$d de %2$d + %1$d de %2$d + %1$s (%2$s) + Sair + Exibindo %1$s + teclado oculto + O feedback falado está ativado + O feedback falado está desativado + diff --git a/utils/src/main/res/values-pt-rBR/strings_symbols.xml b/utils/src/main/res/values-pt-rBR/strings_symbols.xml new file mode 100644 index 0000000..af6a655 --- /dev/null +++ b/utils/src/main/res/values-pt-rBR/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apóstrofo + E comercial + Sinal menor que + Sinal maior que + Asterisco + Arroba + Barra invertida + Marcador + Circunflexo + Símbolo de centavos + Dois pontos + Vírgula + Direitos autorais + Chave esquerda + Chave direita + Símbolo de grau + Sinal de divisão + Cifrão + Reticências + Travessão eme + Travessão ene + Euro + Ponto de exclamação + Acento grave + Travessão + Aspa dupla inferior + Sinal de multiplicação + Nova linha + Marca de parágrafo + Parêntese esquerdo + Parêntese direito + Porcentagem + Ponto + Pi + Cerquilha + Símbolo da libra esterlina + Ponto de interrogação + Aspas + Marca registrada + Ponto e vírgula + Barra + Espaço + Colchete esquerdo + Colchete direito + Raiz quadrada + Marca comercial + Sublinhado + Barra vertical + Iene + Negação + Barra vertical interrompida + Micro + Quase igual a + Diferente de + Moeda + Parágrafo + Seta para cima + Seta para a esquerda + Rúpia + Coração preto + Til + Sinal de igual + Símbolo do won + Marca de referência + Estrela branca + Estrela preta + Coração branco + Círculo branco + Círculo preto + Símbolo do sol + Espiral + Naipe branco de paus + Naipe branco de espadas + Símbolo branco apontando para a esquerda + Símbolo branco apontando para a direita + Círculo com metade preta à esquerda + Círculo com metade preta à direita + Quadrado branco + Quadrado preto + Triângulo branco apontando para cima + Triângulo branco apontando para baixo + Triângulo branco apontando para a esquerda + Triângulo branco apontando para a direita + Diamante branco + Semínima + Colcheia + Semicolcheias agrupadas + Símbolo feminino + Símbolo masculino + Colchete lenticular preto esquerdo + Colchete lenticular branco direito + Colchete de canto esquerdo + Colchete de canto direito + Seta para a direita + Seta para baixo + Sinal de mais ou menos + Litro + Grau Celsius + Grau Fahrenheit + Aproximadamente igual + Integral + Sinal matemático de menor + Sinal matemático de maior + Marca postal + Triângulo preto apontando para cima + Triângulo preto apontando para baixo + Naipe preto de ouros + Ponto médio do katakana com meia largura + Pequeno quadrado preto + Chevron duplo esquerdo + Chevron duplo direito + Ponto de exclamação invertido + Ponto de interrogação invertido + Símbolo do won + Vírgula com largura total + Ponto de exclamação com largura total + Ponto final ideográfico + Ponto de interrogação com largura total + Ponto médio + Aspa dupla direita + Vírgula ideográfica + Dois-pontos com largura total + Ponto e vírgula com largura total + E comercial com largura total + Circunflexo com largura total + Til com largura total + Aspa dupla esquerda + Parêntese à esquerda com largura total + Parêntese à direita com largura total + Asterisco com largura total + Sublinhado com largura total + Aspa simples direita + Chave esquerda com largura total + Chave direita com largura total + Sinal menor que com largura total + Sinal maior que com largura total + Aspa simples esquerda + diff --git a/utils/src/main/res/values-pt-rPT/strings.xml b/utils/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..7cd4814 --- /dev/null +++ b/utils/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,50 @@ + + + Carateres de %1$d a %2$d + Caráter %1$d + sem nome + copiado, %1$s + %1$s maiúsculo + %1$d %2$s + A utilizar %1$s + Prima a combinação de teclas para definir o novo atalho. Tem de incluir, pelo menos, a tecla ALT ou Control. + Prima a combinação de teclas com a tecla de modificação %1$s para definir um novo atalho. + Não atribuído + Shift + Alt + Ctrl + Pesquisa + Seta para a direita + Seta para a esquerda + Seta para cima + Seta para baixo + Predefinição + Carateres + Palavras + Linhas + Parágrafos + Janelas + Pontos de referência + Títulos + Listas + Links + Controlos + Conteúdo especial + Títulos + Controlos + Links + A app %1$s está no modo de imagem na imagem + %1$s em cima e %2$s em baixo + %1$s à esquerda e %2$s à direita + %1$s à direita e %2$s à esquerda + A mostrar os itens %1$d a %2$d de %3$d. + A mostrar o item %1$d de %2$d. + Página %1$d de %2$d + %1$d de %2$d + %1$s (%2$s) + Sair + A mostrar %1$s + teclado oculto + O feedback de voz está ativado. + O feedback de voz está desativado. + diff --git a/utils/src/main/res/values-pt-rPT/strings_symbols.xml b/utils/src/main/res/values-pt-rPT/strings_symbols.xml new file mode 100644 index 0000000..9e696b3 --- /dev/null +++ b/utils/src/main/res/values-pt-rPT/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apóstrofo + \"E\" comercial + Sinal de menor do que + Sinal de maior do que + Asterisco + Arroba + Barra invertida + Marca de lista + Acento circunflexo + Sinal de cêntimo + Dois pontos + Vírgula + Direitos de autor + Chaveta esquerda + Chaveta direita + Sinal de grau + Sinal de divisão + Cifrão + Reticências + Travessão + Traço + Euro + Ponto de exclamação + Acento grave + Traço + Aspas duplas baixas + Sinal de multiplicação + Nova linha + Marca de parágrafo + Parêntese esquerdo + Parêntese direito + Percentagem + Ponto final + Pi + Libra + Sinal monetário de libra + Ponto de interrogação + Aspa + Marca comercial registada + Ponto e vírgula + Barra + Espaço + Parêntese reto esquerdo + Parêntese reto direito + Raiz quadrada + Marca comercial + Sublinhado + Barra vertical + Iene + Sinal de negação + Barra interrompida + Sinal de micro + Quase igual a + Não igual a + Sinal monetário + Sinal de secção + Seta para cima + Seta para a esquerda + Rupia + Coração preto + Til + Sinal de igual + Sinal monetário de won + Marca de referência + Estrela branca + Estrela preta + Naipe branco de copas + Círculo branco + Círculo preto + Símbolo solar + Alvo + Naipe branco de paus + Naipe branco de espadas + Mão branca a apontar para a esquerda + Mão branca a apontar para a direita + Círculo com metade esquerda preta + Círculo com metade direita preta + Quadrado branco + Quadrado preto + Triângulo branco a apontar para cima + Triângulo branco a apontar para baixo + Triângulo branco a apontar para a esquerda + Triângulo branco a apontar para a direita + Naipe branco de ouros + Semínima + Colcheia + Semicolcheias agrupadas + Símbolo feminino + Símbolo masculino + Parêntese lenticular esquerdo preto + Parêntese lenticular direito preto + Parêntese esquerdo de canto + Parêntese direito de canto + Seta para a direita + Seta para baixo + Sinal de mais e menos + Litro + Grau Celsius + Grau Fahrenheit + É aproximadamente igual a + Integral + Parêntesis matemático angular esquerdo + Parêntesis matemático angular direito + Símbolo postal + Triângulo preto a apontar para cima + Triângulo preto a apontar para baixo + Naipe de ouros preto + Ponto central katakana de meia largura + Quadrado preto pequeno + Colchete angular duplo esquerdo + Colchete angular duplo direito + Ponto de exclamação invertido + Ponto de interrogação invertido + Sinal monetário de won + Vírgula de largura total + Ponto de exclamação de largura total + Ponto final ideográfico + Ponto de interrogação de largura total + Ponto central + Aspas direitas + Vírgula ideográfica + Dois pontos de largura total + Ponto e vírgula de largura total + \"E\" comercial de largura total + Acento circunflexo de largura total + Til de largura total + Aspas esquerdas + Parêntese esquerdo de largura total + Parêntese direito de largura total + Asterisco de largura total + Sublinhado de largura total + Plica direita + Chaveta esquerda de largura total + Chaveta direita de largura total + Sinal de menor do que de largura total + Sinal de maior do que de largura total + Plica esquerda + diff --git a/utils/src/main/res/values-pt/strings.xml b/utils/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..c414ba1 --- /dev/null +++ b/utils/src/main/res/values-pt/strings.xml @@ -0,0 +1,50 @@ + + + Caracteres de %1$d a %2$d + Caractere %1$d + sem título + copiado, %1$s + %1$s em letra maiúscula + \u0020%1$d %2$s + Usando %1$s + Pressione a combinação de teclas para definir um novo atalho. Ela deve conter pelo menos a tecla ALT ou Control. + Pressione a combinação de teclas com a tecla modificadora %1$s para definir um novo atalho. + Não atribuído + Shift + Alt + Ctrl + Pesquisa + Seta para a direita + Seta para a esquerda + Seta para cima + Seta para baixo + Padrão + Caracteres + Palavras + Linhas + Parágrafos + Janelas + Pontos de referência + Títulos + Listas + Links + Controles + Conteúdo especial + Títulos + Controles + Links + Picture-in-picture de %1$s + %1$s na parte superior, %2$s na parte inferior + %1$s à esquerda, %2$s à direita + %1$s à direita, %2$s à esquerda + Mostrando itens %1$d a %2$d de %3$d. + Mostrando item %1$d de %2$d. + Página %1$d de %2$d + %1$d de %2$d + %1$s (%2$s) + Sair + Exibindo %1$s + teclado oculto + O feedback falado está ativado + O feedback falado está desativado + diff --git a/utils/src/main/res/values-pt/strings_symbols.xml b/utils/src/main/res/values-pt/strings_symbols.xml new file mode 100644 index 0000000..af6a655 --- /dev/null +++ b/utils/src/main/res/values-pt/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apóstrofo + E comercial + Sinal menor que + Sinal maior que + Asterisco + Arroba + Barra invertida + Marcador + Circunflexo + Símbolo de centavos + Dois pontos + Vírgula + Direitos autorais + Chave esquerda + Chave direita + Símbolo de grau + Sinal de divisão + Cifrão + Reticências + Travessão eme + Travessão ene + Euro + Ponto de exclamação + Acento grave + Travessão + Aspa dupla inferior + Sinal de multiplicação + Nova linha + Marca de parágrafo + Parêntese esquerdo + Parêntese direito + Porcentagem + Ponto + Pi + Cerquilha + Símbolo da libra esterlina + Ponto de interrogação + Aspas + Marca registrada + Ponto e vírgula + Barra + Espaço + Colchete esquerdo + Colchete direito + Raiz quadrada + Marca comercial + Sublinhado + Barra vertical + Iene + Negação + Barra vertical interrompida + Micro + Quase igual a + Diferente de + Moeda + Parágrafo + Seta para cima + Seta para a esquerda + Rúpia + Coração preto + Til + Sinal de igual + Símbolo do won + Marca de referência + Estrela branca + Estrela preta + Coração branco + Círculo branco + Círculo preto + Símbolo do sol + Espiral + Naipe branco de paus + Naipe branco de espadas + Símbolo branco apontando para a esquerda + Símbolo branco apontando para a direita + Círculo com metade preta à esquerda + Círculo com metade preta à direita + Quadrado branco + Quadrado preto + Triângulo branco apontando para cima + Triângulo branco apontando para baixo + Triângulo branco apontando para a esquerda + Triângulo branco apontando para a direita + Diamante branco + Semínima + Colcheia + Semicolcheias agrupadas + Símbolo feminino + Símbolo masculino + Colchete lenticular preto esquerdo + Colchete lenticular branco direito + Colchete de canto esquerdo + Colchete de canto direito + Seta para a direita + Seta para baixo + Sinal de mais ou menos + Litro + Grau Celsius + Grau Fahrenheit + Aproximadamente igual + Integral + Sinal matemático de menor + Sinal matemático de maior + Marca postal + Triângulo preto apontando para cima + Triângulo preto apontando para baixo + Naipe preto de ouros + Ponto médio do katakana com meia largura + Pequeno quadrado preto + Chevron duplo esquerdo + Chevron duplo direito + Ponto de exclamação invertido + Ponto de interrogação invertido + Símbolo do won + Vírgula com largura total + Ponto de exclamação com largura total + Ponto final ideográfico + Ponto de interrogação com largura total + Ponto médio + Aspa dupla direita + Vírgula ideográfica + Dois-pontos com largura total + Ponto e vírgula com largura total + E comercial com largura total + Circunflexo com largura total + Til com largura total + Aspa dupla esquerda + Parêntese à esquerda com largura total + Parêntese à direita com largura total + Asterisco com largura total + Sublinhado com largura total + Aspa simples direita + Chave esquerda com largura total + Chave direita com largura total + Sinal menor que com largura total + Sinal maior que com largura total + Aspa simples esquerda + diff --git a/utils/src/main/res/values-ro/strings.xml b/utils/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..d5f7db0 --- /dev/null +++ b/utils/src/main/res/values-ro/strings.xml @@ -0,0 +1,50 @@ + + + Caracterele de la %1$d la %2$d + Caracterul %1$d + fără titlu + copiat, %1$s + %1$s mare + %1$d %2$s + Se utilizează %1$s + Apăsați combinația de taste pentru a seta o nouă comandă rapidă. Trebuie să conțină cel puțin tasta ALT sau CTRL. + Ca să setați o comandă rapidă nouă, apăsați combinația de taste folosind tasta de modificare %1$s. + Neatribuit + Shift + Alt + Ctrl + Căutați + Săgeată la dreapta + Săgeată la stânga + Săgeată în sus + Săgeată în jos + Prestabilit + Caractere + Cuvinte + Rânduri + Paragrafe + Ferestre + Repere + Titluri + Liste + Linkuri + Comenzi + Conținut special + Titluri + Comenzi + Linkuri + %1$s în modul picture-in-picture + %1$s în partea de sus, %2$s în partea de jos + %1$s în stânga, %2$s în dreapta + %1$s în dreapta, %2$s în stânga + Se afișează articolele %1$d – %2$d din %3$d. + Se afișează articolul %1$d din %2$d. + Pagina %1$d din %2$d + %1$d din %2$d + %1$s (%2$s) + Ieșiți + Se afișează %1$s + tastatura este ascunsă + Feedbackul rostit este activat + Feedbackul rostit este dezactivat + diff --git a/utils/src/main/res/values-ro/strings_symbols.xml b/utils/src/main/res/values-ro/strings_symbols.xml new file mode 100644 index 0000000..30650b8 --- /dev/null +++ b/utils/src/main/res/values-ro/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Ampersand + Semnul mai mic decât + Semnul mai mare decât + Asterisc + A rond + Bară oblică inversă + Marcator + Simbol de inserție + Simbolul pentru cent + Două puncte + Virgulă + Copyright + Acoladă stânga + Acoladă dreapta + Semnul pentru grad + Semnul împărțirii + Dolar + Puncte de suspensie + Cratimă lungă + Cratimă scurtă + Euro + Semn de exclamație + Accent grav + Cratimă + Ghilimele duble de deschidere + Semnul înmulțirii + Rând nou + Semn pentru paragraf + Paranteză stânga + Paranteză dreapta + Procent + Punct + Pi + Diez + Semnul de monedă pentru liră + Semnul întrebării + Semnul citării + Marcă comercială înregistrată + Punct și virgulă + Bară oblică + Spațiu + Paranteză pătrată stânga + Paranteză pătrată dreapta + Rădăcină pătrată + Marcă comercială + Caracter de subliniere + Bară verticală + Yen + Semn pentru negație logică + Bară verticală întreruptă + Semnul miu + Aproape egal cu + Nu este egal cu + Semn pentru monedă + Semnul pentru secțiune + Săgeată în sus + Săgeată spre stânga + Rupie + Inimă neagră + Tildă + Semn egal + Semnul de monedă pentru Won + Semn de referință + Stea albă + Stea neagră + Inimă albă + Cerc alb + Cerc negru + Simbol solar + Țintă + Treflă albă + Pică albă + Arătător alb orientat spre stânga + Arătător alb orientat spre dreapta + Cerc cu jumătatea stângă neagră + Cerc cu jumătatea dreaptă neagră + Pătrat alb + Pătrat negru + Triunghi alb orientat în sus + Triunghi alb orientat în jos + Triunghi alb orientat spre stânga + Triunghi alb orientat spre dreapta + Romb alb + Notă muzicală pătrime + Notă muzicală optime + Note muzicale șaisprezecimi grupate + Simbol feminin + Simbol masculin + Paranteză neagră ovală de deschidere + Paranteză neagră ovală de închidere + Paranteză unghiulară de deschidere + Paranteză unghiulară de închidere + Săgeată spre dreapta + Săgeată în jos + Semn plus minus + Litru + Grad Celsius + Grad Fahrenheit + Aproximativ egal cu + Integrală + Paranteză matematică unghiulară stânga + Paranteză matematică unghiulară dreapta + Marcă poștală + Triunghi negru care indică în sus + Triunghi negru care indică în jos + Carouri negre + Punct median Katakana cu lățimea pe jumătate + Pătrat negru mic + Paranteză unghiulară dublă stânga + Paranteză unghiulară dublă dreapta + Semnul exclamării inversat + Semnul întrebării inversat + Semnul de monedă pentru Won + Virgulă cu lățimea întreagă + Semnul exclamării cu lățimea întreagă + Punct ideografic + Semnul întrebării cu lățimea întreagă + Interpunct + Ghilimele duble la dreapta + Virgulă ideografică + Două puncte cu lățimea întreagă + Punct și virgulă cu lățimea întreagă + Ampersand cu lățimea întreagă + Accent circumflex cu lățimea întreagă + Tildă cu lățimea întreagă + Ghilimele duble la stânga + Paranteze de deschidere cu lățimea întreagă + Paranteze de închidere cu lățimea întreagă + Asterisc cu lățimea întreagă + Caracter de subliniere cu lățimea întreagă + Ghilimele simple la dreapta + Acoladă de deschidere, de lățime întreagă + Acoladă de închidere, de lățime întreagă + Semn mai mic decât lățimea întreagă + Semn mai mare decât lățimea întreagă + Ghilimele simple la stânga + diff --git a/utils/src/main/res/values-round/dimens.xml b/utils/src/main/res/values-round/dimens.xml new file mode 100644 index 0000000..473dcff --- /dev/null +++ b/utils/src/main/res/values-round/dimens.xml @@ -0,0 +1,20 @@ + + + + + 25dp + \ No newline at end of file diff --git a/utils/src/main/res/values-round/styles.xml b/utils/src/main/res/values-round/styles.xml new file mode 100644 index 0000000..8f10f3f --- /dev/null +++ b/utils/src/main/res/values-round/styles.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/utils/src/main/res/values-ru/strings.xml b/utils/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..16b109e --- /dev/null +++ b/utils/src/main/res/values-ru/strings.xml @@ -0,0 +1,50 @@ + + + Символы %1$d‒%2$d + Символ \"%1$d\" + без названия + Скопирован текст \"%1$s\" + прописная буква %1$s. + Символов %2$s: %1$d. + Используется %1$s. + Чтобы установить сочетание клавиш для команды, нажмите его. В нем должна быть клавиша ALT или CONTROL. + Чтобы задать сочетание клавиш, нажмите его вместе с клавишей-модификатором %1$s. + Не назначено + Shift + Alt + Ctrl + Поиск + Стрелка вправо + Стрелка влево + Стрелка вверх + Стрелка вниз + По умолчанию + Символы + Слова + Строки + Абзацы + Окна + Отметки + Заголовки + Списки + Ссылки + Элементы управления + Специализированный контент + Заголовки + Элементы управления + Ссылки + %1$s: режим \"Картинка в картинке\" + %1$s вверху, %2$s внизу + %1$s слева, %2$s справа + %1$s справа, %2$s слева + Показаны элементы с %1$d по %2$d, всего элементов: %3$d. + Показан элемент %1$d, всего элементов: %2$d. + Страница %1$d из %2$d + %1$d из %2$d + %1$s (%2$s) + Выйти + Открыта клавиатура \"%1$s\". + Клавиатура скрыта. + Озвучивание текста на экране включено + Озвучивание текста на экране выключено + diff --git a/utils/src/main/res/values-ru/strings_symbols.xml b/utils/src/main/res/values-ru/strings_symbols.xml new file mode 100644 index 0000000..8b19f96 --- /dev/null +++ b/utils/src/main/res/values-ru/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Апостроф. + Амперсанд. + Знак \"меньше\" + Знак \"больше\" + Звездочка. + Коммерческое at + Обратная косая черта + Маркер списка + Знак вставки. + Знак цента + Двоеточие. + Запятая + Знак авторского права. + Открывающая фигурная скобка. + Закрывающая фигурная скобка. + Знак градуса + Знак деления + Знак доллара. + Многоточие. + Длинное тире. + Короткое тире. + Знак евро. + Восклицательный знак. + Гравис. + Дефис. + Нижние двойные кавычки. + Знак умножения. + Перевод строки. + Знак абзаца + Открывающая круглая скобка. + Закрывающая круглая скобка. + Процент + Точка + Число пи. + Решетка. + Знак фунта стерлингов + Вопросительный знак. + Кавычка + Зарегистрированный товарный знак. + Точка с запятой + Косая черта. + Пробел. + Открывающая квадратная скобка. + Закрывающая квадратная скобка. + Квадратный корень. + Товарный знак. + Знак подчеркивания. + Вертикальная черта + Знак иены. + Знак отрицания + Вертикальный пунктир. + Знак микро + Почти равно + Не равно + Знак валюты. + Знак параграфа + Стрелка вверх. + Стрелка влево + Знак рупии. + Черное сердце + Тильда + Знак равенства + Знак южнокорейской воны + Знак сноски + Белая звезда + Черная звезда + Белое сердце + Белый круг + Черный круг + Солярный символ + Мишень + Белые трефы + Белые пики + Белая рука с указательным пальцем влево + Белая рука с указательным пальцем вправо + Круг с черной левой половиной + Круг с черной правой половиной + Белый квадрат + Черный квадрат + Белый треугольник с вершиной вверх + Белый треугольник с вершиной вниз + Белый треугольник с вершиной влево + Белый треугольник с вершиной вправо + Белый ромб + Четвертная нота + Восьмая нота + Шестнадцатые ноты с вязкой + Женский символ + Мужской символ + Открывающая черная двояковыпуклая скобка + Закрывающая черная двояковыпуклая скобка + Открывающая угловая скобка + Закрывающая угловая скобка + Стрелка вправо + Стрелка вниз + Плюс-минус + Литр + Градус Цельсия + Градус Фаренгейта + Знак приблизительного равенства + Интеграл + Угловая открывающая скобка + Угловая закрывающая скобка + Почтовый знак + Черный треугольник вершиной вверх + Черный треугольник вершиной вниз + Черный ромб + Точка катаканы половинной ширины + Маленький черный квадрат + Открывающая угловая скобка + Закрывающая угловая скобка + Перевернутый восклицательный знак + Перевернутый вопросительный знак + Знак южнокорейской воны + Запятая в полную ширину + Восклицательный знак в полную ширину + Идеографическая точка + Вопросительный знак в полную ширину + Интерпункт + Закрывающая двойная кавычка + Идеографическая запятая + Двоеточие в полную ширину + Точка с запятой в полную ширину + Амперсанд в полную ширину + Циркумфлекс в полную ширину + Тильда в полную ширину + Открывающая двойная кавычка + Открывающая скобка в полную ширину + Закрывающая скобка в полную ширину + Знак сноски в полную ширину + Символ подчеркивания в полную ширину + Закрывающая одинарная кавычка + Открывающая фигурная скобка в полную ширину + Закрывающая фигурная скобка в полную ширину + Знак меньше в полную ширину + Знак больше в полную ширину + Открывающая одинарная кавычка + diff --git a/utils/src/main/res/values-si/strings.xml b/utils/src/main/res/values-si/strings.xml new file mode 100644 index 0000000..5327d9d --- /dev/null +++ b/utils/src/main/res/values-si/strings.xml @@ -0,0 +1,50 @@ + + + %1$d සිට %2$d දක්වා අනුලකුණු + අනුලකුණ %1$d + නම් කර නැත + පිටපත් කරන ලදී, %1$s + මහකුර %1$s + %1$d %2$s + %1$s භාවිතයේය + නව කෙටිමග සැකසීමට යතුරු එකතුව ඔබන්න. එහි අවම වශයෙන් ALT යතුර හෝ Control යතුර අඩංගු විය යුතුය. + නව කෙටි මග සැකසීමට යතුරු සංයෝජනය %1$s විකරණකාරක යතුර සමගින් ඔබන්න. + නොපවරන ලද + Shift + Alt + Ctrl + සෙවීම + ඊතලය දකුණට + ඊතලය වමට + ඊතලය ඉහළට + ඊතලය පහළට + පෙරනිමි + අනුලකුණු + වචන + රේඛා + ඡේද + කවුළු + බිම් සලකුණු + සිරස්තල + ලැයිස්තු + සබැඳි + පාලක + විශේෂ අන්තර්ගතය + සිරස්තල + පාලක + සබැඳි + %1$s පින්තූරය තුළ පින්තූරය + ඉහළ %1$s, පහළ %2$s + වමේ %1$s, දකුණේ %2$s + දකුණේ %1$s, වමේ %2$s + %3$d හි %1$d සිට %2$d දක්වා අයිතම දැක්වේ. + %1$d හි %2$d අයිතමය දක්වයි. + %2$dකින් %1$dවන පිටුව + %2$dකින් %1$d + %1$s (%2$s) + පිටවන්න + %1$s පෙන්වමින් + යතුරු පුවරුව සඟවා ඇත + කථා කරන ප්‍රතිපෝෂණය ක්‍රියාත්මකයි + කථා කරන ප්‍රතිපෝෂණය ක්‍රියාවිරහිතයි + diff --git a/utils/src/main/res/values-si/strings_symbols.xml b/utils/src/main/res/values-si/strings_symbols.xml new file mode 100644 index 0000000..fd9d364 --- /dev/null +++ b/utils/src/main/res/values-si/strings_symbols.xml @@ -0,0 +1,140 @@ + + + අපෝස්ට්‍රොෆි + ඇම්පර්සෑන්ඩ් + වඩා කුඩා ලකුණ + වඩා විශාල ලකුණ + තරු ලකුණ + ඇට් + පසු ඇල ඉර + බුලටය + කාකපාදය + ශත ලකුණ + දෙතිත + කොමාව + ප්‍රකාශන හිමිකම: + වම් සඟල වරහන + දකුණු සඟල වරහන + අංශක ලකුණ + බෙදීමේ ලකුණ + ඩොලර් ලකුණ + ඉලිප්සිස් + දිගු ඉර + කෙටි ඉර + යුරෝ + විස්මයාර්ථ ලකුණ + අවච ස්වරය + ඉර + පහළ යුගල උද්ධෘතය + ගුණිත ලකුණ + නව පේළිය + නව ඡේදාරම්භ ලකුණ + වම් සුළු වරහන + දකුණු සුළු වරහන + ප්‍රතිශතය + නැවතීමේ ලකුණ + පයි + පවුම + පවුම් ව්‍යවහාර මුදල් ලකුණ + ප්‍රශ්නයාර්ථ ලකුණ + උද්ධරණය + ලියාපදිංචි කළ වෙළඳ ලකුණ + තිත් කොමාව + ඇල ඉර + Space + වම් කොටු වරහන + දකුණු කොටු වරහන + වර්ග මූලය + වෙළඳ ලකුණ + යටි ඉර + සිරස් රේඛාව + යෙන් + අසමාන ලකුණ + කඩ ඉර + මයික්‍රො ලකුණ + බොහෝ දුරට සමාන + සමාන නොවන + වලංගු මුදල් ලකුණ + ඡේදන ලකුණ + උඩුකුරු ඊතලය + වම් ඊතලය + රුපියල + කලු හදවත + නාසික්‍ය ලකුණ + සමාන ලකුණ + වොන් ව්‍යවහාර මුදල් ලකුණ + යොමු ලකුණ + සුදු තරුව + කලු තරුව + සුදු හදවත + සුදු කවය + කලු කවය + සූර්ය සංකේතය + බුල්ස්අයි + වයිට් ක්ලබ් සූට් + වයිට් ස්පේඩ් සූට් + සුදු වමට යොමු වූ දර්ශකය + සුදු දකුණට යොමු වූ ත්‍රිකෝණය + වම් අර්ධය කලු කවය + දකුණු අර්ධය කලු කවය + සුදු චතුරස්‍රය + කලු චතුරස්‍රය + සුදු ඉහළට යොමු වූ ත්‍රිකෝණය + සුදු පහළට යොමු වූ ත්‍රිකෝණය + සුදු වමට යොමු වූ ත්‍රිකෝණය + සුදු දකුණට යොමු වූ ත්‍රිකෝණය + සුදු දියමන්තිය + ක්වාටර් නෝට් + එයිත් නෝට් + බීම්ඩ් සික්ස්ටීන්ත් නෝට්ස් + කාන්තා ලකුණ + පිරිමි ලකුණ + වම් කලු කවාකාර වරහන + දකුණු කලු කවාකාර වරහන + වම් කොණ් වරහන + දකුණු කොණ් වරහන + දකුණු දෙසට වන ඊතලය + පහළ ඊතලය + ධන සෘණ ලකුණ + ලීටර් + සෙල්සියස් අංශක + ෆැරන්හයිට් අංශක + ආසන්නව සමාන + පූර්ණ සංඛ්‍යාව + ගණිතමය වම් කෝණ වරහන + ගණිතමය දකුණු කෝණ වරහන + තැපැල් ලකුණ + ඉහළට යොමු වූ කළු ත්‍රිකෝණය + පහළට යොමු වූ කළු ත්‍රිකෝණය + ඩයමන්ඩ්වල කළු කට්ටලය + අර්ධ පළල කටකනා මැද තිත + කුඩා කළු කොටුව + වම් ද්විත්ව කෝණ වරහන + දකුණු ද්විත්ව කෝණ වරහන + යටිකුරු කළ විස්මයාර්ථ ලකුණ + යටිකුරු කළ ප්‍රශ්නාර්ථ ලකුණ + වොන් ව්‍යවහාර මුදල් ලකුණ + සම්පූර්ණ පළල කොමාව + සම්පූර්ණ පළල විස්මයාර්ථ ලකුණ + ඉඩියොග්‍රැෆික විරාම ලකුණ + සම්පූර්ණ පළල ප්‍රශ්නාර්ථ ලකුණ + මැද තිත + දකුණු ද්විත්ව උද්ධෘත ලකුණ + ඉඩියොග්‍රැෆික කොමාව + සම්පූර්ණ පළල දෙතිත + සම්පූර්ණ පළල තිත් කොමාව + සම්පූර්ණ පළල ඇම්පර්සෑන්ඩ් + සම්පූර්ණ පළල කාකපාදය + සම්පූර්ණ පළල නාසික්‍ය ලකුණ + වම් ද්විත්ව උද්ධෘත ලකුණ + සම්පූර්ණ පළල වම් වරහන + සම්පූර්ණ පළල දකුණු වරහන + සම්පූර්ණ පළල තරු ලකුණ + සම්පූර්ණ පළල පහත් ඉර + දකුණු තනි උද්ධෘත ලකුණ + පූර්ණ පළල වම් සඟල වරහන + පූර්ණ පළල් දකුණු සඟල වරහන + පූර්ණ පළල වඩා කුඩා ලකුණ + පූර්ණ පළල වඩා විශාල ලකුණ + වම් තනි උද්ධෘත ලකුණ + diff --git a/utils/src/main/res/values-sk/strings.xml b/utils/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..9ce3a66 --- /dev/null +++ b/utils/src/main/res/values-sk/strings.xml @@ -0,0 +1,50 @@ + + + Znaky %1$d až %2$d + Znak %1$d + bez názvu + skopírované, %1$s + veľké %1$s + %1$d %2$s + Používa sa %1$s + Stlačením kombinácie klávesov nastavíte novú skratku. Musí obsahovať kláves ALT alebo CTRL. + Ak chcete nastaviť novú skratku, stlačte kombináciu klávesov s modifikačným klávesom %1$s. + Nepriradené + Shift + Alt + Ctrl + Hľadať + Šípka doprava + Šípka doľava + Šípka nahor + Šípka nadol + Predvolené + Znaky + Slová + Riadky + Odseky + Okná + Orientačné body + Nadpisy + Zoznamy + Odkazy + Ovládanie + Špeciálny obsah + Nadpisy + Ovládanie + Odkazy + %1$s, obraz v obraze + %1$s hore, %2$s dole + %1$s vľavo, %2$s vpravo + %1$s vpravo, %2$s vľavo + Zobrazujú sa položky %1$d až %2$d z %3$d + Zobrazuje sa položka %1$d z %2$d + Stránka %1$d z %2$d + %1$d z %2$d + %1$s (%2$s) + Ukončiť + Zobrazuje sa %1$s + klávesnica je skrytá + Hovorená spätná väzba je zapnutá + Hovorená spätná väzba je vypnutá + diff --git a/utils/src/main/res/values-sk/strings_symbols.xml b/utils/src/main/res/values-sk/strings_symbols.xml new file mode 100644 index 0000000..4169366 --- /dev/null +++ b/utils/src/main/res/values-sk/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Ampersand + Znak menej ako + Znak viac ako + Hviezdička + Zavináč + Obrátená lomka + Odrážka + Strieška + Znak centa + Dvojbodka + Čiarka + Kopirajt + Ľavá zložená zátvorka + Pravá zložená zátvorka + Znak stupňa + Znak delenia + Dolár + Tri bodky + Dlhá pomlčka + Stredná pomlčka + Euro + Výkričník + Gravis + Pomlčka + Dolné úvodzovky + Znak násobenia + Nový riadok + Znak odseku + Ľavá zátvorka + Pravá zátvorka + Percento + Bodka + + Krížik + Znak meny (libra) + Otáznik + Úvodzovky + Registrovaná ochranná známka + Bodkočiarka + Lomka + Medzerník + Ľavá hranatá zátvorka + Pravá hranatá zátvorka + Odmocnina + Ochranná známka + Podčiarkovník + Zvislá čiara + Jen + Znak logického záporu + Prerušená zvislá čiara + Znak mikro + Približne sa rovná + Nerovná sa + Znak meny + Paragraf + Šípka hore + Šípka doľava + Rupia + Čierne srdce + Tilda + Rovná sa + Znak meny (won) + Znak odkazu + Biela hviezda + Čierna hviezda + Biele srdce + Biely kruh + Čierny kruh + Bodka v kruhu + Dva sústredené kruhy + Biely kríž + Biela pika + Biely symbol ukazováka doľava + Biely symbol ukazováka doprava + Kruh s čiernou ľavou polovicou + Kruh s čiernou pravou polovicou + Biely štvorec + Čierny štvorec + Biely trojuholník špičkou nahor + Biely trojuholník špičkou dole + Biely trojuholník špičkou doľava + Biely trojuholník špičkou doprava + Biele káro + Štvrtinová nota + Osminová nota + Spojené šestnástinové noty + Symbol ženy + Symbol muža + Ľavá čierna šošovkovitá zátvorka + Pravá čierna šošovkovitá zátvorka + Ľavá rohová zátvorka + Pravá rohová zátvorka + Šípka doprava + Šípka dole + Znamienko plus mínus + Liter + Stupeň Celzia + Stupeň Fahrenheita + Približne sa rovná + Integrál + Matematická ľavá lomená zátvorka + Matematická pravá lomená zátvorka + Poštová pečiatka + Čierny trojuholník smerujúci nahor + Čierny trojuholník smerujúci nadol + Čierne kára + Stredová bodka Katakana s polovičnou hrúbkou + Malý čierny štvorec + Ľavá dvojitá lomená zátvorka + Pravá dvojitá lomená zátvorka + Obrátený výkričník + Obrátený otáznik + Znak meny (won) + Čiarka + Výkričník + Ideografická bodka + Otáznik + Stredová bodka + Pravá dvojitá úvodzovka + Ideografická čiarka + Dvojbodka + Bodkočiarka + Ampersand + Vokáň + Vlnovka + Ľavá dvojitá úvodzovka + Ľavá zátvorka + Pravá zátvorka + Hviezdička + Podčiarkovník + Pravá jednoduchá úvodzovka + Ľavá zložená zátvorka + Pravá zložená zátvorka + Znak menej ako + Znak viac ako + Ľavá jednoduchá úvodzovka + diff --git a/utils/src/main/res/values-sl/strings.xml b/utils/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..0fe55ad --- /dev/null +++ b/utils/src/main/res/values-sl/strings.xml @@ -0,0 +1,50 @@ + + + Znaki od %1$d do %2$d + Znak %1$d + brez naslova + kopirano, %1$s + velika črka %1$s + %1$d %2$s + Uporaba mehanizma %1$s + Za nastavitev nove bližnjice pritisnite kombinacijo tipk. Vključevati mora vsaj tipko ALT ali Control. + Pritisnite kombinacijo tipk z modifikatorsko tipko %1$s, če želite nastaviti novo bližnjico. + Nedodeljeno + Shift + Alt + Ctrl + Iskanje + Puščica desno + Puščica levo + Puščica gor + Puščica dol + Privzeto + Znaki + Besede + Vrstice + Odstavki + Okna + Posamezni sklopi + Naslovi + Seznami + Povezave + Kontrolniki + Posebna vsebina + Naslovi + Kontrolniki + Povezave + Slika v sliki za %1$s + %1$s na vrhu, %2$s na dnu + %1$s na levi, %2$s na desni + %1$s na desni, %2$s na levi + Prikaz elementov %1$d−%2$d od skupno %3$d. + Prikaz elementa %1$d od %2$d. + Stran %1$d od %2$d + %1$d od %2$d + %1$s (%2$s) + Zapri + Tipkovnica %1$s je prikazana + Tipkovnica je skrita + Glasovni odziv je vklopljen + Glasovni odziv je izklopljen + diff --git a/utils/src/main/res/values-sl/strings_symbols.xml b/utils/src/main/res/values-sl/strings_symbols.xml new file mode 100644 index 0000000..6442397 --- /dev/null +++ b/utils/src/main/res/values-sl/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Opuščaj + Znak in + Znak »manjše kot« + Znak »večje kot« + Zvezdica + Afna + Poševnica nazaj + Oznaka + Strešica + Znak za cent + Dvopičje + Vejica + Avtorske pravice + Levi zaviti oklepaj + Desni zaviti oklepaj + znak za stopinje + Znak za deljenje + Dolarski znak + Tri pike + Dolgi pomišljaj + Pomišljaj + Evro + Klicaj + Krativec + Pomišljaj + Spodnji dvojni narekovaj + Znak za množenje + Nova vrstica + Oznaka za odstavek + Oklepaj + Zaklepaj + Odstotek + Obdobje + Pi + Funt + Znak za valuto funt + Vprašaj + Narekovaj + Zaščitena blagovna znamka + Podpičje + Poševnica + Preslednica + Levi oglati oklepaj + Desni oglati oklepaj + Kvadratni koren + Blagovna znamka + Podčrtaj + Navpična črta + Jen + Znak »ne« + Presledna navpičnica + Znak »mikro« + Skoraj enako + Ni enako + Znak za valuto + Znak za člen + Puščica gor + Puščica levo + Rupija + Črno srce + Tilda + Enačaj + Znak za valuto von + Oznaka sklica + Bela zvezda + Črna zvezda + Belo srce + Bel krog + Črn krog + Simbol za sonce + Tarča + Bel križ + Bel pik + Bel kazalec, usmerjen levo + Bel kazalec, usmerjen desno + Krog s črno levo polovico + Krog s črno desno polovico + Bel kvadrat + Črn kvadrat + Bel trikotnik, usmerjen navzgor + Bel trikotnik, usmerjen navzdol + Bel trikotnik, usmerjen levo + Bel trikotnik, usmerjen desno + Bel romb + Četrtinka + Osminka + Šestnajstinke na prečki + Znak za ženski spol + Simbol za moški spol + Črn lečasti oklepaj + Črn lečasti zaklepaj + Kotni oklepaj + Kotni zaklepaj + Puščica desno + Puščica navzdol + Znak minus/plus + Liter + Stopinja Celzija + Stopinja Fahrenheita + Približno enako + Integral + Matematični levi kotni oklepaj + Matematični desni kotni oklepaj + Navpičnica + Navzgor obrnjen črn trikotnik + Navzdol obrnjen črn trikotnik + Črni karo + Sredinska pika polovične širine v katakani + Črn kvadratek + Dvojni lomljeni oklepaj + Dvojni lomljeni zaklepaj + Obrnjeni klicaj + Obrnjeni vprašaj + Znak za valuto von + Vejica polne širine + Klicaj polne širine + Ideografska pika + Vprašaj polne širine + Sredinska pika + Desni dvojni zgornji narekovaj + Ideografska vejica + Dvopičje polne širine + Podpičje polne širine + Znak »in« polne širine + Strešica polne širine + Tilda polne širine + Levi dvojni zgornji narekovaj + Oklepaj polne širine + Zaklepaj polne širine + Zvezdica polne širine + Podčrtaj polne širine + Desni enojni zgornji narekovaj + Zaviti oklepaj polne širine + Zaviti zaklepaj polne širine + Znak »manjše kot« polne širine + Znak »večje kot« polne širine + Levi enojni zgornji narekovaj + diff --git a/utils/src/main/res/values-sq/strings.xml b/utils/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000..475f37c --- /dev/null +++ b/utils/src/main/res/values-sq/strings.xml @@ -0,0 +1,50 @@ + + + Karakteret nga %1$d deri në %2$d + Karakteri %1$d + pa titull + u kopjua, %1$s + %1$s e madhe + %1$d %2$s + Po përdor %1$s + Shtyp kombinimin e tasteve për të caktuar një shkurtore të re. Ajo duhet të përmbajë të paktën tastin ALT ose tastin e kontrollit CTRL. + Shtyp kombinimin e tasteve me tastin e modifikimit %1$s për të caktuar një shkurtore të re. + I pacaktuar + Shift + Alt + Ctrl + Kërko + Shigjeta djathtas + Shigjeta majtas + Shigjeta lart + Shigjeta poshtë + E parazgjedhur + Karakteret + Fjalët + Vijat + Paragrafët + Dritaret + Pikat e referimit + Titujt + Listat + Lidhjet + Kontrollet + Përmbajtjet e veçanta + Titujt + Kontrollet + Lidhjet + Figura brenda figurës e %1$s + %1$s sipër, %2$s poshtë + %1$s në të majtë, %2$s në të djathtë + %1$s në të djathtë, %2$s në të majtë + Po shfaq artikujt %1$d deri te %2$d nga gjithsej %3$d. + Po shfaq artikullin %1$d nga gjithsej %2$d. + Faqja %1$d nga %2$d + %1$d nga %2$d + %1$s (%2$s) + Dil + Po shfaq %1$s + tastiera është e fshehur + Komentet me zë janë aktive + Komentet me zë janë joaktive + diff --git a/utils/src/main/res/values-sq/strings_symbols.xml b/utils/src/main/res/values-sq/strings_symbols.xml new file mode 100644 index 0000000..815cefd --- /dev/null +++ b/utils/src/main/res/values-sq/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Edhe + Shenja \"më e vogël se\" + Shenja \"më e madhe se\" + Yllth + Shenja e mail-it \"et\" + Vijë e pjerrët + Pikë liste + Kursor teksti + Shenja e centëve + Dy pika + Presje + Të drejtat e autorit + Kllapa e valëzuar e majtë + Kllapa e valëzuar e djathtë + Shenja e gradëve + Shenja e pjesëtimit + Shenja e dollarit + Elipsë + Vijëz e zgjatur + Vijëz + Euro + Pikëçuditje + Theks i rëndë + Vizë + Thonjëza të dyfishta poshtë + Shenja e shumëzimit + Rresht i ri + Shenja e paragrafit + Kllapa e majtë + Kllapa e djathtë + Përqindje + Pikë + Pi + Paund + Shenja e monedhës paund + Pikëpyetje + Citim + Markë tregtare e regjistruar + Pikëpresje + Vizë e pjerrët + Hapësirë + Kllapa katrore e majtë + Kllapa katrore e djathtë + Rrënja katrore + Marka tregtare + Vija e poshtme + Vijë vertikale + Jen + Shenja e mohimit + Vizë vertikale e thyer + Shenja mikro + Thuajse baras me + Jo baras me + Shenja e monedhës + Shenja e seksionit + Shigjetë lart + Shigjetë majtas + Rupi + Zemër e zezë + Tildë + Shenja e barazimit + Shenja e monedhës uon + Shenja e referencës + Yll i bardhë + Yll i zi + Zemër e bardhë + Rreth i bardhë + Rreth i zi + Simboli i diellit + Objektivi + Lulja spathi e bardhë + Lulja maç e bardhë + Tregues i bardhë me drejtim majtas + Tregues i bardhë me drejtim djathtas + Rrethi me gjysmën e majtë të zezë + Rrethi me gjysmën e djathtë të zezë + Katror i bardhë + Katror i zi + Trekëndësh i bardhë me drejtim lart + Trekëndësh i bardhë me drejtim poshtë + Trekëndësh i bardhë me drejtim majtas + Trekëndësh i bardhë me drejtim djathtas + Diamant i bardhë + Nota muzikore 1/4 + Nota 1/8 + Shënimet e gjashtëmbëdhjeta të dërguara me rreze + Simboli i femrës + Simboli i mashkullit + Kllapa e majtë si lente e zezë + Kllapa e djathtë si lente e zezë + Kllapa këndore e majtë + Kllapa këndore e djathtë + Shigjetë djathtas + Shigjetë poshtë + Shenja plus-minus + Litër + Gradë celsius + Gradë farenhait + Afërsisht të barabartë + Integral + Kllapa matematikore këndore majtas + Kllapa matematikore këndore djathtas + Shenja postare + Trekëndësh i zi i drejtuar lart + Trekëndësh i zi i drejtuar poshtë + Diamant i zi + Pikë në mes katakana me gjysmë gjerësi + Katror i vogël i zi + Kllapë këndore e dyfishtë majtas + Kllapë këndore e dyfishtë djathtas + Pikëçuditje e kthyer përmbys + Thonjëz përmbys + Shenja e monedhës uon + Presje me gjerësi të plotë + Pikëçuditje me gjerësi të plotë + Pikë ideografike + Pikëpyetje me gjerësi të plotë + Pikë në mes + Thonjëz dyshe e djathtë + Presje ideografike + Dy pika me gjerësi të plotë + Pikëpresje me gjerësi të plotë + Shenja \"dhe\" me gjerësi të plotë + Theks lakor me gjerësi të plotë + Tildë me gjerësi të plotë + Shenjë e dyfishtë citimi majtas + Kllapë e majtë me gjerësi të plotë + Kllapë e djathtë me gjerësi të plotë + Yllth me gjerësi të plotë + Vijë e poshtme me gjerësi të plotë + Thonjëz njëshe për djathtas + Kllapë e valëzuar e majtë me gjerësi të plotë + Kllapë gjarpërushe e djathtë me gjerësi të plotë + Shenja \"më e vogël\" me gjerësi të plotë + Shenja \"më e madhe\" me gjerësi të plotë + Shenjë e vetme citimi majtas + diff --git a/utils/src/main/res/values-sr/strings.xml b/utils/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..d9a1d43 --- /dev/null +++ b/utils/src/main/res/values-sr/strings.xml @@ -0,0 +1,50 @@ + + + Знакови %1$d до %2$d + %1$d. знак + ненасловљено + копирано, %1$s + велико %1$s + %1$d %2$s + Користи се %1$s + Притисните комбинацију тастера да бисте подесили нову пречицу. Она мора да садржи бар тастере Alt или Ctrl. + Притисните комбинацију тастера са модификујућим тастером %1$s да бисте подесили нову пречицу. + Недодељено + Shift + Alt + Ctrl + Претрага + Стрелица удесно + Стрелица улево + Стрелица нагоре + Стрелица надоле + Подразумевано + Знакови + Речи + Редови + Пасуси + Прозори + Обележја + Наслови + Листе + Линкови + Контроле + Специјалан садржај + Наслови + Контроле + Линкови + Слика у слици за апликацију %1$s + %1$s ће бити у врху, а %2$s ће бити у дну + %1$s ће бити лево, %2$s а ће бити десно + %1$s ће бити десно, а %2$s ће бити лево + Приказују се ставке %1$d до %2$d од %3$d. + Приказује се ставка %1$d од %2$d. + Страница %1$d од %2$d + %1$d од %2$d + %1$s (%2$s) + Затвори + Приказује се %1$s + тастатура је сакривена + Говорне повратне информације су укључене + Говорне повратне информације су искључене + diff --git a/utils/src/main/res/values-sr/strings_symbols.xml b/utils/src/main/res/values-sr/strings_symbols.xml new file mode 100644 index 0000000..46d2aa9 --- /dev/null +++ b/utils/src/main/res/values-sr/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Апостроф + Амперсанд + Знак за мање + Знак за Веће од + Звездица + Мајмунче + Обрнута коса црта + Набрајање + Карет + знак за цент + Две тачке + Зарез + Ауторска права + Отворена витичаста заграда + Затворена витичаста заграда + Знак за степен + Знак за дељење + Знак за долар + Три тачке + Дужа повлака + Краћа повлака + Евро + Знак узвика + Тешки акценат + Црта + Доњи двоструки наводници + Знак за множење + Нови ред + Ознака пасуса + Отворена заграда + Затворена заграда + Проценат + Тачка + Пи + Фунта + Знак валуте за фунту + Знак питања + Наводник + Регистровани жиг + Тачка и зарез + Коса црта + Размак + Отворена угласта заграда + Затворена угласта заграда + Квадратни корен + Жиг + Доња црта + Вертикална линија + Јен + Знак Није + Испрекидана усправна црта + Знак микро + Приближно једнако + Није једнако + Знак валуте + Знак параграфа + Стрелица нагоре + Стрелица улево + Рупија + Црно срце + Тилда + Знак једнакости + Знак валуте за вон + Знак за фусноту + Бела звездица + Црна звездица + Бело срце + Бели круг + Црни круг + Симбол сунца + Мета + Бели симбол треф + Бели симбол пик + Бели кажипрст окренут улево + Бели кажипрст окренут удесно + Круг са црном левом половином + Круг са црном десном половином + Бели квадрат + Црни квадрат + Бели троугао усмерен нагоре + Бели троугао усмерен надоле + Бели троугао усмерен улево + Бели троугао усмерен удесно + Бели симбол каро + Четвртина ноте + Осмина ноте + Две шеснаестине ноте + Женски симбол + Мушки симбол + Лева црна лентикуларна заграда + Десна црна лентикуларна заграда + Лева угласта заграда + Десна угаона заграда + Стрелица удесно + Стрелица надоле + Знак плус минус + Мало слово л + Степен Целзијуса + Степен Фаренхајта + Знак за приближан износ + Интеграл + Математичка лева угласта заграда + Математичка десна угласта заграда + Поштанска ознака + Црни троугао са врхом нагоре + Црни троугао са врхом надоле + Црни ромбови + Полуширока катакана средња тачка + Мали црни квадрат + Отворена двострука угаона заграда + Затворена двострука угаона заграда + Обрнути знак узвика + Обрнути знак питања + Знак валуте за вон + Зарез пуне ширине + Знак узвика пуне ширине + Идеографска тачка + Знак питања пуне ширине + Средња тачка + Затворени наводник + Идеографски зарез + Две тачке пуне ширине + Тачка-зарез пуне ширине + Амперсанд пуне ширине + Циркумфлекс пуне ширине + Знак тилда пуне ширине + Отворени наводник + Отворена заграда пуне ширине + Затворена заграда пуне ширине + Звездица пуне ширине + Доња црта пуне ширине + Затворени полунаводник + Отворена витичаста заграда пуне ширине + Затворена витичаста заграда пуне ширине + Знак за мање пуне ширине + Знак за више пуне ширине + Отворени полунаводник + diff --git a/utils/src/main/res/values-sv/strings.xml b/utils/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..8010a04 --- /dev/null +++ b/utils/src/main/res/values-sv/strings.xml @@ -0,0 +1,50 @@ + + + Tecken från %1$d till %2$d + Tecken %1$d + namnlös + kopierade, %1$s + versalt %1$s + %1$d %2$s + %1$s används + Ange ett nytt kortkommando genom att trycka på en tangentkombination. Alt eller Ctrl måste vara en av tangenterna. + Ange ett nytt kortkommando genom att trycka på en tangentkombination med specialtangenten %1$s. + Ej tilldelade + Skift + Alt + Ctrl + Sök + Högerpil + Vänsterpil + Uppåtpil + Nedåtpil + Standard + Tecken + Ord + Rader + Stycken + Fönster + Landmärken + Rubriker + Listor + Länkar + Kontroller + Specialinnehåll + Rubriker + Kontroller + Länkar + Bild-i-bild för %1$s + %1$s upptill, %2$s nedtill + %1$s till vänster, %2$s till höger + %1$s till höger, %2$s till vänster + Visar objekt %1$d till %2$d av %3$d. + Visar objekt %1$d av %2$d. + Sida %1$d av %2$d + %1$d av %2$d + %1$s (%2$s) + Stäng + %1$s visas + tangentbordet har dolts + Talad feedback har aktiverats + Talad feedback har inaktiverats + diff --git a/utils/src/main/res/values-sv/strings_symbols.xml b/utils/src/main/res/values-sv/strings_symbols.xml new file mode 100644 index 0000000..12ae76b --- /dev/null +++ b/utils/src/main/res/values-sv/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Apostrof + Et-tecken + Mindre än-tecken + Större än-tecken + Asterisk + Snabel-a + Omvänt snedstreck + Punktlista + Cirkumflex + Cent-tecken + Kolon + Komma + Copyright-symbol + Vänster klammerparentes + Höger klammerparentes + Gradtecken + Divisionstecken + Dollartecken + Ellips + Långt tankstreck + Tankstreck + Euro + Utropstecken + Grav accent + Bindestreck + Nedre dubbla citattecken + Multiplikationstecken + Ny rad + Stycketecken + Vänsterparentes + Högerparentes + Procent + Punkt + Pi + Fyrkant + Valutatecken för pund + Frågetecken + Citattecken + Registrerat varumärke + Semikolon + Snedstreck + Blanksteg + Vänster hakparentes + Höger hakparentes + Kvadratrot + Varumärke + Understreck + Lodrät linje + Yen + Inte-tecken + Broken bar + Mikrotecken + Nästan lika med + Inte lika med + Valutatecken + Paragraftecken + Uppåtpil + Vänsterpil + Rupie + Svart hjärta + Tilde + Lika med-tecken + Valutatecken för won + Referensmarkering + Vit stjärna + Svart stjärna + Vitt hjärtertecken + Vit cirkel + Svart cirkel + Solsymbol + Prick + Vitt klövertecken + Vitt spadertecken + Vitt pekfinger som pekar åt vänster + Vitt pekfinger som pekar åt höger + Cirkel med svart vänsterhalva + Cirkel med svart högerhalva + Vit kvadrat + Svart kvadrat + Vit triangel som pekar uppåt + Vit triangel som pekar nedåt + Vit triangel som pekar åt vänster + Vit triangel som pekar åt höger + Vitt rutertecken + Fjärdedelsnot + Åttondelsnot + Hopbundna sextondelsnoter + Kvinnosymbol + Manssymbol + Linsformad svart vänsterparentes + Linsformad svart högerparentes + Vänster hakparentes + Höger hakparentes + Högerpil + Nedåtpil + Plus/minus-tecken + Liter + Celsius-tecken + Fahrenheit-tecken + Ungefär lika med + Integral + Vänster vinkelparentes + Höger vinkelparentes + Postmärke + Svart triangel som pekar uppåt + Svart triangel som pekar nedåt + Svarta ruter + Halvhög punkt för katakana, halv bredd + Liten svart kvadrat + Dubbel vinkelparentes, vänster + Dubbel vinkelparentes, höger + Upp och nedvänt utropstecken + Upp och nedvänt frågetecken + Valutatecken för won + Kommatecken, full bredd + Utropstecken, full bredd + Ideografisk punkt + Frågetecken, full bredd + Halvhög punkt + Dubbla citattecken, höger + Ideografiskt kommatecken + Kolon, full bredd + Semikolon, full bredd + Et-tecken, full bredd + Cirkumflex, full bredd + Tilde, full bredd + Dubbla citattecken, vänster + Vänsterparentes, full bredd + Högerparentes, full bredd + Asterisk, full bredd + Understreck, full bredd + Enkelt citattecken, höger + Vänster klammerparentes, full bredd + Höger klammerparentes, full bredd + Mindre än-tecken, full bredd + Större än-tecken, full bredd + Enkelt citattecken, vänster + diff --git a/utils/src/main/res/values-sw/strings.xml b/utils/src/main/res/values-sw/strings.xml new file mode 100644 index 0000000..4ec71ac --- /dev/null +++ b/utils/src/main/res/values-sw/strings.xml @@ -0,0 +1,50 @@ + + + Herufi %1$d hadi %2$d + Herufi %1$d + Jina halijawekwa + %1$s, yamenakiliwa + herufi kubwa %1$s + %1$d %2$s + Inatumia %1$s + Bonyeza mchanganyiko wa vitufe ili uweke njia mpya ya mkato. Ni lazima iwe na angalau kitufe cha ALT au Control. + Bonyeza mchanganyiko wa vitufe pamoja na kitufe cha kurekebisha cha %1$s ili uweke njia mpya ya mkato. + Haijapewa jukumu + Shift + Alt + Ctrl + Tafuta + Kishale cha Kulia + Kishale cha Kushoto + Kishale cha Juu + Kishale cha Chini + Chaguomsingi + Herufi + Maneno + Mistari + Aya + Madirisha + Maeneo maarufu + Vichwa + Orodha + Viungo + Vidhibiti + Maudhui maalum + Vichwa + Vidhibiti + Viungo + %1$s picha ndani ya picha + %1$s sehemu ya juu, %2$s sehemu ya chini + %1$s upande wa kushoto, %2$s upande wa kulia + %1$s upande wa kulia, %2$s upande wa kushoto + Inaonyesha vipengee %1$d hadi %2$d kati ya %3$d. + Inaonyesha kipengee cha %1$d kati ya %2$d + Ukurasa wa %1$d kati ya %2$d + %1$d kati ya %2$d + %1$s (%2$s) + Funga + Inaonyesha %1$s + kibodi imefichwa + Kipengele cha maelezo yanayotamkwa kimewashwa + Kipengele cha maelezo yanayotamkwa kimezimwa + diff --git a/utils/src/main/res/values-sw/strings_symbols.xml b/utils/src/main/res/values-sw/strings_symbols.xml new file mode 100644 index 0000000..1fba6be --- /dev/null +++ b/utils/src/main/res/values-sw/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Alama ya nukuu + Ishara ya \'na\' + Alama ya \'chini ya\' + Alama ya \'zaidi ya\' + Kinyota + Kwa + Mkwajunyuma + Kitone + Kareti + Alama ya senti + Nukta mbili + Koma + Hakimiliki + Mabano ya kushoto yaliyopindwa + Mabano ya kulia yaliyopindwa + Alama ya digrii + Alama ya kugawa + Ishara ya dola + Ritifaa + Kistari cha Em + Kistari cha En + Yuro + Alama hisi + Alama ya kiinitoni + Dashi + Nukuu ya chini maradufu + Alama ya kuzidisha + Mstari mpya + Alama ya aya + Bano la kushoto + Bano la kulia + Asilimia + Muda + Pi + Ratili + Alama ya sarafu ya pauni + Alama ya kuuliza + Nukuu + Alama ya biashara iliyosajiliwa + nukta mkato + Mkwaju + Nafasi + Mabano ya mraba ya kushoto + Mabano ya mraba ya kulia + Kipeo cha pili + Chapa ya Biashara + Mstari chini + Mstari wima + Yeni + Ishara ya siyo + Upau uliovunjika + Ishara ndogo + Inakaribia kuwa sawa na + Si sawa na + Ishara ya sarafu + Ishara ya sehemu + Kishale cha upande wa juu + Kishale cha upande wa kushoto + Rupia + Roho Nyeusi + Kiwimbi + Ishara ya sawa + Alama ya sarafu ya Won + Alama ya Rejeleo + Nyota nyeupe + Nyota nyeusi + Roho Nyeupe + Mduara mweupe + Mduara mweusi + Ishara ya jua + Alama ya kulenga katikati + Karata nyeupe za maua + Karata za shupaza nyeupe + Kidole cha kwanza cheupe kinachoelekeza kushoto + Kidole cha kwanza cheupe kinachoelekeza kulia + Mduara wenye nusu nyeusi ya kushoto + Mduara wenye nusu nyeusi ya kulia + Mraba mweupe + Mraba mweusi + Pembetatu nyeupe inayoelekeza juu + Pembetatu nyeupe inayoelekeza chini + Pembetatu nyeupe inayoelekeza kushoto + Pembetatu nyeupe inayoelekeza kulia + Almasi nyeupe + Alama ya Muziki ya Robo + Ishara ya muziki ya Nane + Ishara za muziki za kumi na sita + Ishara ya kike + Ishara ya kiume + Mabano Meusi Yaliyopinda kama lenzi ya Kushoto + Mabano Meusi Yaliyopinda kama lenzi ya Kulia + Mabano ya Kona ya Kushoto + Mabano ya Kona ya Kulia + Kishale kinachoelekeza Kulia + Kishale kinachoelekeza Chini + Ishara ya kuongeza na kutoa + Lita + Selsiasi digrii + Farenhaiti digrii + Takriban sawa + Muhimu + Mabano ya kihisabati ya sehemu ya kushoto + Mabano ya kihisabati ya sehemu ya kulia + Alama ya posta + Pembe tatu nyeusi inayoelekeza juu + Pembe tatu nyeusi inayoelekeza chini + Seti ya uru nyeusi + Nukta ya katikati ya upana nusu ya Kikatakana + Mraba mdogo mweusi + Mabano ya pembe mbili ya kushoto + Mabano ya pembe mbili ya kulia + Alama hisi iliyogeuzwa juu chini + Kiulizi kilichogeuzwa juu chini + Alama ya sarafu ya Won + Koma ya upana kamili + Alama hisi ya upana kamili + Kitone cha idiografia + Alama ya kiulizi ya upana kamili + Nukta ya kati + Alama mbili za kunukuu za kulia + Koma ya idiografia + Koloni ya upana kamili + Semikoloni ya upana kamili + Ishara ya \'na\' ya upana kamili + Alama ya mpindo ya upana kamili + Alama ya kiwimbi ya upana kamili + Alama mbili za kunukuu za kushoto + Mabano ya upana kamili ya kushoto + Mabano ya upana kamili ya kulia + Alama za kinyota za upana kamili + Mstari chini wa upana kamili + Alama moja ya kunukuu ya kulia + Bano lililopinda kushoto lenye upana kamili + Bano lililopinda kulia lenye upana kamili + Alama ya \'chini ya\' ya upana kamili + Alama ya \'zaidi ya\' ya upana kamili + Alama moja ya kunukuu ya kushoto + diff --git a/utils/src/main/res/values-ta/strings.xml b/utils/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..a9ed358 --- /dev/null +++ b/utils/src/main/res/values-ta/strings.xml @@ -0,0 +1,50 @@ + + + %1$d இலிருந்து %2$d வரையிலான எழுத்துக்குறிகள் + எழுத்துக்குறி %1$d + பெயரிடப்படாதது + %1$s நகலெடுக்கப்பட்டது + பேரெழுத்து %1$s + %1$d %2$s + %1$sஐப் பயன்படுத்துகிறது + புதிய ஷார்ட்கட்டை அமைக்க விசைச் சேர்க்கையை அழுத்தவும். குறைந்தது ALT அல்லது Control விசை கண்டிப்பாக இதில் இருக்க வேண்டும். + புதிய ஷார்ட்கட்டை அமைக்க, %1$s, மாற்றி விசை ஆகிய இரண்டையும் சேர்த்து அழுத்தவும். + ஒதுக்கப்படவில்லை + Shift + Alt + Ctrl + தேடல் விசை + வலது அம்புக்குறி + இடது அம்புக்குறி + மேல் அம்புக்குறி + கீழ் அம்புக்குறி + இயல்பு + எழுத்துக்குறிகள் + சொற்கள் + வரிகள் + பத்திகள் + சாளரங்கள் + அடையாளங்கள் + தலைப்புகள் + பட்டியல்கள் + இணைப்புகள் + கட்டுப்பாடுகள் + சிறப்பு உள்ளடக்கம் + தலைப்புகள் + கட்டுப்பாடுகள் + இணைப்புகள் + %1$s பிக்ச்சர்-இன்-பிக்ச்சர் + மேலே %1$s, கீழே %2$s + இடப்புறத்தில் %1$s, வலப்புறத்தில் %2$s + வலப்புறத்தில் %1$s, இடப்புறத்தில் %2$s + %3$d இல் %1$d முதல் %2$d வரையிலான உருப்படிகளைக் காட்டுகிறது. + %2$d இல் %1$d உருப்படியைக் காட்டுகிறது. + %2$d இல் பக்கம் %1$d + %2$d இல் %1$d + %1$s (%2$s) + வெளியேறு + %1$s ஐக் காட்டுகிறது + கீபோர்டு மறைக்கப்பட்டது + பேச்சுவடிவக் கருத்து ஆன் செய்யப்பட்டுள்ளது + பேச்சுவடிவக் கருத்து ஆஃப் செய்யப்பட்டுள்ளது + diff --git a/utils/src/main/res/values-ta/strings_symbols.xml b/utils/src/main/res/values-ta/strings_symbols.xml new file mode 100644 index 0000000..4ff8246 --- /dev/null +++ b/utils/src/main/res/values-ta/strings_symbols.xml @@ -0,0 +1,140 @@ + + + தனி மேற்கோள் குறி + உம்மைக்குறி + சிறிய மதிப்பின் குறி + பெரிய மதிப்பின் குறி + உடுக்குறி + வீதக்குறி + பின்கோடு + பொட்டுக்குறி + கூரைக்குறி + செண்ட் குறி + முக்காற்புள்ளி + காற்புள்ளி + பதிப்புரிமைக்குறி + இடது நெளி அடைப்புக்குறி + வலது நெளி அடைப்புக் குறி + டிகிரி குறி + வகுத்தல் குறி + டாலர் குறி + முப்புள்ளி + இணைப்புக்கோடு + இணைப்புச் சிறுகோடு + யூரோ + ஆச்சரியக்குறி + கூரழுத்தக்குறி + சிறுகோடு + கீழ் இரட்டை மேற்கோள் குறி + பெருக்கல் குறி + புதிய வரி குறி + பத்திக்குறி + இடது அடைப்புக்குறி + வலது அடைப்புக்குறி + சதவீதக்குறி + முற்றுப்புள்ளி + பை + பவுண்ட் குறி + பவுண்டு நாணயச் சின்னம் + கேள்விக்குறி + மேற்கோள் குறி + பதிவுசெய்யப்பட்ட வர்த்தகமுத்திரை குறி + அரைப்புள்ளி + சாய்க்கோடு + இடைவெளி + இடதுபுற சதுர அடைப்புக்குறி + வலதுபுற சதுர அடைப்புக்குறி + வர்க்க மூலக்குறி + வர்த்தகமுத்திரை குறி + அடிக்கோடு + செங்குத்துக் கோடு + யென் + குறி இல்லை + உடைந்த குத்துக் கோடு + மைக்ரோ குறி + கிட்டத்தட்ட சமம் + சமம் இல்லை + ரூபாய் குறி + பிரிவுக் குறி + மேல்நோக்கிய அம்புக்குறி + இடதுபுற அம்புக்குறி + ரூபாய் + கருப்பு இதயம் + அலைகுறி + சமக்குறி + வொன் நாணயச் சின்னம் + குறிப்புச் சின்னம் + வெள்ளை நட்சத்திரம் + கருப்பு நட்சத்திரம் + வெள்ளை இதயம் + வெள்ளை வட்டம் + கருப்பு வட்டம் + சோலார் சின்னம் + புல்ஸ் ஐ + வெள்ளை கிளப் சூட் + வெள்ளை ஸ்பேடு சூட் + இடது பக்கம் காட்டும் வெள்ளை நிற ஆள்காட்டி விரல் + வலது பக்கம் காட்டும் வெள்ளை நிற ஆள்காட்டி விரல் + இடது பக்கம் பாதியளவு கருப்பு நிறம் கொண்ட வட்டம் + வலது பக்கம் பாதியளவு கருப்பு நிறம் கொண்ட வட்டம் + வெள்ளை சதுரம் + கருப்பு சதுரம் + மேல்நோக்கிய வெள்ளை முக்கோணம் + கீழ் நோக்கிய வெள்ளை முக்கோணம் + இடது பக்கம் நோக்கிய வெள்ளை முக்கோணம் + வலது பக்கம் நோக்கிய வெள்ளை முக்கோணம் + வெள்ளை வைரம் + குவாட்டர் நோட் + எட்டாவது நோட் + பீம்டு பதினாறாவது நோட்ஸ் + பெண் சின்னம் + ஆண் சின்னம் + கருப்புநிறத்தில் வளைந்த இடது அடைப்புக்குறி + கருப்புநிறத்தில் வளைந்த வலது அடைப்புக்குறி + இடது விளிம்பு அடைப்புக்குறி + வலது விளிம்பு அடைப்புக்குறி + வலப்புறம் நோக்கிய அம்பு + கீழ்நோக்கிய அம்பு + கூட்டல் கழித்தல் குறி + லிட்டர் + செல்சியஸ் டிகிரி + ஃபாரன்ஹீட் டிகிரி + ஏறக்குறைய சமம் + தொகையீடு + கணிதரீதியாக இடது அம்புமுனை அடைப்புக்குறி + கணிதரீதியாக வலது அம்புமுனை அடைப்புக்குறி + அஞ்சல் குறி + மேல்நோக்கிக் காட்டும் கருப்பு முக்கோணம் + கீழ்நோக்கிக் காட்டும் கருப்பு முக்கோணம் + வைரங்களின் கருப்புத் தொகுப்பு + அரை அகல Katakana நடுப்புள்ளி + சிறிய கருப்புச் சதுரம் + இடது இரட்டைக் கோண அடைப்புக்குறி + வலது இரட்டைக் கோண அடைப்புக்குறி + தலைகீழ் ஆச்சரியக்குறி + தலைகீழ் கேள்விக்குறி + வொன் நாணயச் சின்னம் + முழு அகலக் காற்புள்ளி + முழு அகல ஆச்சரியக்குறி + இடியோகிராஃபிக் முற்றுப்புள்ளி + முழு அகலக் கேள்விக்குறி + மையப் புள்ளி + வலது இரட்டை மேற்கோள் குறி + இடியோகிராஃபிக் காற்புள்ளி + முழு அகல முக்காற்புள்ளி + முழு அகல அரைப்புள்ளி + முழு அகல ஆம்பர்சண்ட் + முழு அகல சர்கம்ஃப்ளெக்ஸ் + முழு அகல அலைக்குறி + இடது இரட்டை மேற்கோள் குறி + முழு அகல இடது அடைப்புக்குறி + முழு அகல வலது அடைப்புக்குறி + முழு அகல ஆஸ்ட்ரிஸ்க் + முழு அகல அடிக்கோடு + வலது ஒற்றை மேற்கோள் குறி + முழு அகல இடது நெளிவு அடைப்புக்குறி + முழு அகல வலது நெளிவு அடைப்புக்குறி + முழு அகல \'இதைவிடக் குறைவானது\' குறி + முழு அகல \'இதைவிட அதிகமானது\' குறி + இடது ஒற்றை மேற்கோள் குறி + diff --git a/utils/src/main/res/values-te/strings.xml b/utils/src/main/res/values-te/strings.xml new file mode 100644 index 0000000..f3b9f29 --- /dev/null +++ b/utils/src/main/res/values-te/strings.xml @@ -0,0 +1,50 @@ + + + అక్షరాలు %1$d నుండి %2$d వరకు + అక్షరం %1$d + శీర్షిక లేనిది + %1$s, కాపీ చేయబడింది + క్యాపిటల్ %1$s + %1$d %2$s + %1$sని ఉపయోగిస్తోంది + కొత్త షార్ట్‌కట్‌ను సెట్ చేయడానికి కీ కాంబినేషన్‌ను నొక్కండి. అది తప్పనిసరిగా ALT లేదా Control కీని కలిగి ఉండాలి. + కొత్త షార్ట్‌కట్‌ను సెట్ చేయడానికి %1$s మాడిఫైయర్ కీతో కీ కాంబినేషన్‌ను నొక్కండి. + కేటాయించలేదు + షిఫ్ట్ + Alt + Ctrl + సెర్చ్ + కుడివైపు సూచించే బాణం + ఎడమవైపు సూచించే బాణం + పైకి సూచించే బాణం + క్రిందికి సూచించే బాణం + ఆటోమేటిక్ + అక్షరాలు + పదాలు + లైన్‌లు + పేరాలు + విండోలు + ల్యాండ్‌మార్క్‌లు + హెడ్డింగులు + లిస్ట్‌లు + లింక్‌లు + కంట్రోల్‌లు + ప్రత్యేక కంటెంట్ + ముఖ్య శీర్షికలు + కంట్రోల్‌లు + లింక్‌లు + %1$s చిత్రంలో చిత్రం + పైన %1$s, దిగువ %2$s + ఎడమవైపు %1$s, కుడివైపు %2$s + కుడివైపు %1$s, ఎడమవైపు %2$s + %3$dలో %1$d నుండి %2$d అంశాలను చూపుతోంది. + %2$dలో %1$dవ అంశాన్ని చూపుతోంది.. + %2$dలో %1$dవ పేజీ + %2$dలో %1$d + %1$s (%2$s) + నిష్క్రమించు + %1$sను చూపుతోంది + కీబోర్డ్ దాచబడింది + మాటల ప్రతిస్పందన ఆన్ చేయబడింది + మాటల ప్రతిస్పందన ఆఫ్ చేయబడింది + diff --git a/utils/src/main/res/values-te/strings_symbols.xml b/utils/src/main/res/values-te/strings_symbols.xml new file mode 100644 index 0000000..5bd67b7 --- /dev/null +++ b/utils/src/main/res/values-te/strings_symbols.xml @@ -0,0 +1,140 @@ + + + అపోస్ట్రఫీ + యాంపర్‌సెండ్ + తక్కువ సూచిక గుర్తు + ఎక్కువ సూచిక గుర్తు + నక్షత్రం గుర్తు + అట్ + బ్యాక్‌స్లాష్ + బులెట్ + క్యారెట్ + శాతం గుర్తు + కోలన్ + కామా + కాపీరైట్ + ఎడమ ధనుర్బంధ కుండలీకరణం + కుడి ధనుర్బంధ కుండలీకరణం + డిగ్రీ గుర్తు + భాగాహార గుర్తు + డాలర్ గుర్తు + ఎలిప్సిస్ + ఎమ్ డాష్ + ఎన్ డాష్ + యూరో + ఆశ్చర్యార్థకం గుర్తు + అనుదాత్త స్వరం + డాష్ + దిగువ డబుల్ కోట్ + గుణకారం గుర్తు + కొత్త పంక్తి + పేరా గుర్తు + ఎడమ కుండలీకరణం + కుడి కుండలీకరణం + శాతం + వ్యవధి + పై + పౌండ్ + పౌండ్ కరెన్సీ గుర్తు + ప్రశ్న గుర్తు + కోట్ + నమోదిత బిజినెస్ చిహ్నం + సెమీకోలన్ + స్లాష్ + అంతరం + ఎడమ చతురస్ర కుండలీకరణం + కుడి చతురస్ర కుండలీకరణం + వర్గమూలం + బిజినెస్ చిహ్నం + అండర్‌స్కోర్ + నిలువు గీత + యెన్ + కాదు గుర్తు + విభజించబడిన పట్టీ + సూక్ష్మ గుర్తు + దాదాపుగా దీనికి సమానం + దీనికి సమానం కానిది + కరెన్సీ గుర్తు + విభాగం గుర్తు + పైకి బాణం + ఎడమవైపు బాణం + రూపాయి + నలుపు రంగు హృదయం + భేదద్యోతక గుర్తు + సమాన గుర్తు + వోన్ కరెన్సీ గుర్తు + సూచన గుర్తు + తెలుపు రంగు నక్షత్రం + నలుపు రంగు నక్షత్రం + తెలుపు రంగు హృదయం + తెలుపు రంగు వృత్తం + నలుపు రంగు వృత్తం + సౌర గుర్తు + బుల్స్ఐ + తెలుపు రంగు కళావరు గుర్తు + తెలుపు రంగు ఇస్పేటు గుర్తు + ఎడమవైపుకి సూచించబడే తెలుపు రంగు సూచిక + కుడివైపుకి సూచించబడే తెలుపు రంగు సూచిక + ఎడమ సగం నలుపు రంగు గల వృత్తం + కుడి సగం నలుపు రంగు గల వృత్తం + తెలుపు రంగు చతురస్రం + నలుపు రంగు చతురస్రం + పైకి సూచించబడే తెలుపు రంగు త్రిభుజం + క్రిందికి సూచించబడే తెలుపు రంగు త్రిభుజం + ఎడమవైపుకి సూచించబడే తెలుపు రంగు త్రిభుజం + కుడివైపుకి సూచించబడే తెలుపు రంగు త్రిభుజం + తెలుపు రంగు డైమండ్ గుర్తు + మూడవ వంతు స్వరం + ఎనిమిదవ స్వరం + అడ్డగీత గల పదహారవ స్వరం + స్త్రీ చిహ్నం + పురుషుడి చిహ్నం + ఎడమవైపు నలుపు రంగు కుంభద్వయాకార కుండలీకరణం + కుడివైపు నలుపు రంగు కుంభద్వయాకార కుండలీకరణం + ఎడమ మూల కుండలీకరణం + కుడి మూల కుండలీకరణం + కుడివైపు బాణం + క్రిందికి బాణం + కూడిక తీసివేత గుర్తు + లీటర్ + సెల్సియస్ డిగ్రీ + ఫారెన్‌హీట్ డిగ్రీ + సుమారుగా సమానం + పూర్ణాంక ప్రమేయ గుర్తు + గణితానికి సంబంధించిన ఎడమ కోణం బ్రాకెట్ + గణితానికి సంబంధించిన కుడి కోణం బ్రాకెట్ + పోస్టల్ గుర్తు + పైకి చూపిస్తున్న నలుపు రంగు త్రిభుజం + కిందకు చూపిస్తున్న నలుపు రంగు త్రిభుజం + డైమండ్‌లతో ఉన్న నలుపు రంగు సూట్ + సగం వెడల్పు ఉన్న కాటెకానా మధ్య డాట్ + చిన్న నలుపు రంగు చతురస్రం + ఎడమవైపు డబుల్ యాంగిల్ బ్రాకెట్ + కుడివైపు డబుల్ యాంగిల్ బ్రాకెట్ + తలకిందులుగా ఉన్న ఆశ్చర్యార్థక గుర్తు + తలకిందులుగా ఉన్న ప్రశార్థక గుర్తు + వోన్ కరెన్సీ గుర్తు + పూర్తి-వెడల్పు కామా + పూర్తి-వెడల్పు ఆశ్చర్యార్థకం గుర్తు + ఇడియోగ్రాఫిక్ ఫుల్ స్టాప్ + పూర్తి-వెడల్పు ప్రశ్న గుర్తు + మధ్య డాట్ + కుడి వైపు డబుల్ కొటేషన్ గుర్తు + ఇడియోగ్రాఫిక్ కామా + పూర్తి-వెడల్పు ఉన్న కోలన్ + పూర్తి-వెడల్పు ఉన్న సెమీకోలన్ + పూర్తి-వెడల్పు ఉన్న ఆంపర్‌శాండ్ + పూర్తి-వెడల్పు ఉన్న సర్కమ్‌ఫ్లెక్స్ + పూర్తి-వెడల్పు ఉన్న టిల్డ్ + ఎడమ వైపు డబుల్ కొటేషన్ గుర్తు + పూర్తి-వెడల్పు ఉన్న ఎడమవైపు బ్రాకెట్ + పూర్తి-వెడల్పు ఉన్న కుడివైపు బ్రాకెట్ + పూర్తి-వెడల్పు ఉన్న నక్షత్రం గుర్తు + పూర్తి-వెడల్పు ఉన్న అండర్‌స్కోర్ + కుడివైపు సింగిల్ కొటేషన్ గుర్తు + పూర్తి-వెడల్పు ఉన్న ఎడమవైపు మెలికల బ్రాకెట్ + పూర్తి-వెడల్పు ఉన్న కుడివైపు మెలికల బ్రాకెట్ + పూర్తి-వెడల్పు ఉన్న లెస్ దెన్ సంకేతం + పూర్తి-వెడల్పు ఉన్న గ్రేటర్ దెన్ సంకేతం + ఎడమవైపు సింగిల్ కొటేషన్ గుర్తు + diff --git a/utils/src/main/res/values-th/strings.xml b/utils/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..21382db --- /dev/null +++ b/utils/src/main/res/values-th/strings.xml @@ -0,0 +1,50 @@ + + + ตัว %1$d จนถึงตัว %2$d + อักขระตัวที่ %1$d + ไม่มีชื่อ + คัดลอก %1$s แล้ว + ตัวพิมพ์ใหญ่ %1$s + %1$d %2$s + กำลังใช้ %1$s + กดปุ่มที่ใช้ร่วมกันเพื่อตั้งค่าทางลัดใหม่ จะต้องประกอบด้วยปุ่ม ALT หรือ Control เป็นอย่างน้อย + กดแป้นที่กดร่วมกันพร้อมกับ %1$s ซึ่งเป็นคีย์ตัวปรับแต่ง เพื่อตั้งค่าแป้นพิมพ์ลัดใหม่ + ไม่ได้กำหนด + Shift + Alt + Ctrl + ค้นหา + ลูกศรขวา + ลูกศรซ้าย + ลูกศรขึ้น + ลูกศรลง + ค่าเริ่มต้น + อักขระ + คำ + บรรทัด + ย่อหน้า + หน้าต่าง + จุดสังเกต + ส่วนหัว + รายการ + ลิงก์ + ตัวควบคุม + เนื้อหาพิเศษ + ส่วนหัว + ตัวควบคุม + ลิงก์ + %1$s ใช้การแสดงภาพซ้อนภาพ + %1$s อยู่ด้านบน %2$s อยู่ด้านล่าง + %1$s อยู่ด้านซ้าย %2$s อยู่ด้านขวา + %1$s อยู่ด้านขวา %2$s อยู่ด้านซ้าย + แสดงรายการที่ %1$d ถึง %2$d จาก %3$d + แสดงรายการที่ %1$d จาก %2$d + หน้า %1$d จาก %2$d + %1$d จาก %2$d + %1$s (%2$s) + ออก + กำลังแสดง %1$s + ซ่อนแป้นพิมพ์แล้ว + การอธิบายและอ่านออกเสียงเปิดอยู่ + การอธิบายและอ่านออกเสียงปิดอยู่ + diff --git a/utils/src/main/res/values-th/strings_symbols.xml b/utils/src/main/res/values-th/strings_symbols.xml new file mode 100644 index 0000000..f7596fd --- /dev/null +++ b/utils/src/main/res/values-th/strings_symbols.xml @@ -0,0 +1,140 @@ + + + เครื่องหมายอะพอสทรอฟี + แอมเพอร์แซนด์ + เครื่องหมายน้อยกว่า + เครื่องหมายมากกว่า + ดอกจัน + แอท + ทับย้อนกลับ + สัญลักษณ์หัวข้อย่อย + แคเร็ต + สัญลักษณ์เซ็นต์ + ทวิภาค + จุลภาค + ลิขสิทธิ์ + วงเล็บปีกกาเปิด + วงเล็บปีกกาปิด + เครื่องหมายองศา + เครื่องหมายหาร + สัญลักษณ์ดอลลาร์ + จุดไข่ปลา + ยัติภาคขนาด M + ยัติภาคขนาด N + ยูโร + อัศเจรีย์ + เครื่องหมายเน้นเสียง + ยัติภาค + อัญประกาศล่าง + เครื่องหมายคูณ + บรรทัดใหม่ + เครื่องหมายขึ้นต้นย่อหน้า + วงเล็บเปิด + วงเล็บปิด + เปอร์เซ็นต์ + มหัพภาค + พาย + ปอนด์ + สัญลักษณ์สกุลเงินปอนด์ + เครื่องหมายคำถาม + อัญประกาศ + เครื่องหมายการค้าจดทะเบียน + อัฒภาค + ทับ + วรรค + วงเล็บเหลี่ยมเปิด + วงเล็บเหลี่ยมปิด + รากที่ 2 + เครื่องหมายการค้า + ขีดล่าง + ไปป์ + เยน + สัญลักษณ์ห้าม + เส้นตั้งที่มีช่องตรงกลาง + สัญลักษณ์ไมโคร + ประมาณ + ไม่เท่ากับ + สัญลักษณ์สกุลเงิน + สัญลักษณ์ส่วน + ลูกศรชี้ขึ้น + ลูกศรชี้ซ้าย + รูปี + หัวใจสีดำ + ทิลด์ + เครื่องหมายเท่ากับ + สัญลักษณ์สกุลเงินวอน + เครื่องหมายอ้างอิง + ดาวสีขาว + ดาวสีดำ + หัวใจสีขาว + วงกลมสีขาว + วงกลมสีดำ + สัญลักษณ์ดวงอาทิตย์ + จุดกลางเป้า + ชุดดอกจิกสีขาว + ชุดโพดำสีขาว + นิ้วชี้ไปทางซ้ายสีขาว + นิ้วชี้ไปทางขวาสีขาว + วงกลมสีดำครึ่งซ้าย + วงกลมสีดำครึ่งขวา + สี่เหลี่ยมสีขาว + สี่เหลี่ยมสีดำ + สามเหลี่ยมชี้ขึ้นสีขาว + สามเหลี่ยมชี้ลงสีขาว + สามเหลี่ยมชี้ไปทางซ้ายสีขาว + สามเหลี่ยมชี้ไปทางขวาสีขาว + ข้าวหลามตัดสีขาว + โน้ตตัวดำ + โน้ตเขบ็ด 1 ชั้น + โน้ตเขบ็ต 2 ชั้น + สัญลักษณ์เพศหญิง + สัญลักษณ์เพศชาย + วงเล็บเปิดรูปเลนส์สีดำ + วงเล็บปิดรูปเลนส์สีดำ + วงเล็บมุมด้านซ้าย + วงเล็บมุมด้านขวา + ลูกศรชี้ขวา + ลูกศรชี้ลง + เครื่องหมายบวกลบ + ลิตร + องศาเซลเซียส + องศาฟาเรนไฮต์ + ประมาณ + อินทิกรัล + วงเล็บมุมเปิดทางคณิตศาสตร์ + วงเล็บมุมปิดทางคณิตศาสตร์ + สัญลักษณ์ทางไปรษณีย์ + สามเหลี่ยมสีดำชี้ขึ้น + สามเหลี่ยมสีดำชี้ลง + ข้าวหลามตัดสีดำ + จุดกลางของคาตาคานะความกว้างกึ่งหนึ่ง + สี่เหลี่ยมจัตุรัสสีดำขนาดเล็ก + วงเล็บมุมซ้อนเปิด + วงเล็บมุมซ้อนปิด + เครื่องหมายตกใจคว่ำ + เครื่องหมายคำถามคว่ำ + สัญลักษณ์สกุลเงินวอน + คอมมาความกว้างเต็ม + เครื่องหมายตกใจความกว้างเต็ม + จุดแบบภาพแสดงความหมาย + เครื่องหมายคำถามความกว้างเต็ม + จุดกลาง + เครื่องหมายคำพูดปิดคู่ + คอมมาแบบภาพแสดงความหมาย + โคลอนความกว้างเต็ม + เซมิโคลอนความกว้างเต็ม + แอมเพอร์แซนด์ความกว้างเต็ม + เซอร์คัมเฟลกซ์ความกว้างเต็ม + ทิลเดอความกว้างเต็ม + เครื่องหมายคำพูดเปิดคู่ + วงเล็บเปิดความกว้างเต็ม + วงเล็บปิดความกว้างเต็ม + ดอกจันความกว้างเต็ม + ขีดล่างความกว้างเต็ม + เครื่องหมายคำพูดปิดเดี่ยว + วงเล็บปีกกาเปิดความกว้างเต็ม + วงเล็บปีกกาปิดความกว้างเต็ม + เครื่องหมายน้อยกว่าความกว้างเต็ม + เครื่องหมายมากกว่าความกว้างเต็ม + เครื่องหมายคำพูดเปิดเดี่ยว + diff --git a/utils/src/main/res/values-tl/strings.xml b/utils/src/main/res/values-tl/strings.xml new file mode 100644 index 0000000..1a057a6 --- /dev/null +++ b/utils/src/main/res/values-tl/strings.xml @@ -0,0 +1,50 @@ + + + Character %1$d hanggang %2$d + Character %1$d + walang pamagat + nakopya, %1$s + malaking %1$s + %1$d %2$s + Ginagamit ang %1$s + Pindutin ang kumbinasyon ng key upang magtakda ng bagong shortcut. Dapat itong maglaman ng kahit ALT o Control key lang. + Pindutin ang kumbinasyon ng key kasama ang %1$s na modifier key upang magtakda ng bagong shortcut. + Hindi nakatalaga + Shift + Alt + Ctrl + Maghanap + Pakanang Arrow + Pakaliwang Arrow + Pataas na Arrow + Pababang Arrow + Default + Mga Character + Mga Salita + Mga Linya + Mga Talata + Mga Window + Mga Landmark + Mga Heading + Mga Listahan + Mga Link + Mga Kontrol + Espesyal na content + Mga Heading + Mga Kontrol + Mga Link + %1$s na picture in picture + %1$s sa itaas, %2$s sa ibaba + %1$s sa kaliwa, %2$s sa kanan + %1$s sa kanan, %2$s sa kaliwa + Ipinapakita ang mga item mula %1$d hanggang %2$d ng %3$d. + Ipinapakita ang item mula %1$d ng %2$d. + Page %1$d ng %2$d + %1$d ng %2$d + %1$s (%2$s) + Lumabas + Ipinapakita ang %1$s + nakatago ang keyboard + Naka-on ang pasalitang feedback + Naka-off ang pasalitang feedback + diff --git a/utils/src/main/res/values-tl/strings_symbols.xml b/utils/src/main/res/values-tl/strings_symbols.xml new file mode 100644 index 0000000..379bb20 --- /dev/null +++ b/utils/src/main/res/values-tl/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Kudlit + Ampersand + Simbolo ng less than + Simbolo ng greater than + Asterisk + At + Backslash + Bullet + Caret + Simbolo ng sentimo + Tutuldok + Kuwit + Copyright + Kaliwang curly bracket + Kanang curly bracket + Simbolo ng degree + Simbolo ng paghahati + Simbolo ng dolyar + Ellipsis + Em dash + En dash + Euro + Tandang padamdam + Tuldik + Gitling + Mababang panipi + Simbolo ng multiplication + Bagong linya + Marka ng talata + Kaliwang panaklong + Kanang panaklong + Porsyento + Tuldok + Pi + Pound + Simbolo ng currency na pound + Tandang pananong + Panipi + Nakarehistrong trademark + Tuldukuwit + Slash + Espasyo + Kaliwang square bracket + Kanang square bracket + Square root + Trademark + Underscore + Patayong linya + Yen + Simbolo ng not + Broken bar + Simbolo ng micro + Halos katumbas ng + Hindi katumbas ng + Simbolo ng currency + Simbolo ng seksyon + Pataas na arrow + Pakaliwang arrow + Rupee + Itim na puso + Tilde + Sign ng equal + Simbolo ng currency na won + Marka ng Sanggunian + Puting bituin + Itim na bituin + Puting Puso + Puting bilog + Itim na bilog + Simbolo ng solar + Bullseye + Puting club suit + Puting spade suit + Puting index na nakaturo sa kaliwa + Puting index na nakaturo sa kanan + Bilog na may kalahating kaliwang itim + Bilog na may kalahating kanang itim + Puting parisukat + Itim na parisukat + Puting tatsulok na nakaturo sa itaas + Puting tatsulok na nakaturo sa ibaba + Puting tatsulok na nakaturo sa kaliwa + Puting tatsulok na nakaturo sa kanan + Puting diyamante + Quarter Note + Eighth Note + Mga naka-beam na sixteenth note + Simbolo ng babae + Simbolo ng lalaki + Itim na Kaliwang Lenticular Bracket + Itim na Kaliwang Lenticular Bracket + Kaliwang Sulok na Bracket + Kanang Sulok na Bracket + Kanang Arrow + Pababang Arrow + Sign ng plus minus + Liter + Celsius degree + Fahrenheit degree + Tinatayang katumbas ng + Integral + Kaliwang angle bracket na pang-math + Kanang angle bracket na pang-math + Postal mark + Itim na tatsulok na nakaturo sa itaas + Itim na tatsulok na nakaturo sa ibaba + Itim na diamond + Kalahating lapad na Katakana na gitnang tuldok + Maliit na itim na parisukat + Dobleng kaliwang angle bracket + Dobleng kanang angle bracket + Nakabaliktad na tandang padamdam + Nakabaliktad na tandang pananong + Simbolo ng currency na won + Buong lapad na kuwit + Buong lapad na tandang padamdam + Ideographic na tuldok + Buong lapad na tandang pananong + Gitnang tuldok + Dobleng kanang panipi + Ideographic na kuwit + Buong lapad na tutuldok + Buong lapad na tuldukuwit + Buong lapad na ampersand + Buong lapad na circumflex + Buong lapad na tilde + Dobleng kaliwang panipi + Buong lapad na kaliwang parenthesis + Buong lapad na kanang parenthesis + Buong lapad na asterisk + Buong lapad na underscore + Iisang kanang panipi + Buong lapad na kaliwang curly bracket + Buong lapad na kanang curly bracket + Buong lapad na simbolo ng less than + Buong lapad na simbolo ng greater than + Iisang kaliwang panipi + diff --git a/utils/src/main/res/values-tr/strings.xml b/utils/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..22bc769 --- /dev/null +++ b/utils/src/main/res/values-tr/strings.xml @@ -0,0 +1,50 @@ + + + %1$d. ile %2$d. arasındaki karakterler + %1$d. karakter + başlıksız + kopyalandı, %1$s + büyük harf %1$s + %1$d %2$s + %1$s kullanılıyor + Yeni kısayolu ayarlamak için tuş bileşimine basın. En azından ALT veya Control tuşunu içermelidir. + Yeni bir kısayol ayarlamak için %1$s değiştirici tuşu ile birlikte tuş bileşimine basın. + Atanmamış + Üst Karakter + Alt + Ctrl + Arama + Sağ Ok + Sol Ok + Yukarı Ok + Aşağı Ok + Varsayılan + Karakter + Kelime + Satır + Paragraf + Windows + Önemli yerler + Başlıklar + Listeler + Bağlantılar + Denetimler + Özel içerik + Başlıklar + Kontroller + Bağlantılar + %1$s pencere içinde penceresi + %1$s üstte, %2$s altta + %1$s solda, %2$s sağda + %1$s sağda, %2$s solda + %3$d öğeden %1$d-%2$d arasındakiler gösteriliyor. + %2$d öğeden %1$d tanesi gösteriliyor. + Toplam %2$d sayfadan %1$d. + Toplam %2$d sayfadan %1$d. + %1$s (%2$s) + Çıkış + %1$s gösteriliyor + klavye gizli + Sözlü geri bildirim etkin + Sözlü geri bildirim kapalı + diff --git a/utils/src/main/res/values-tr/strings_symbols.xml b/utils/src/main/res/values-tr/strings_symbols.xml new file mode 100644 index 0000000..7b70fc1 --- /dev/null +++ b/utils/src/main/res/values-tr/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Kesme işareti + Ve işareti + Küçüktür işareti + Büyüktür işareti + Yıldız işareti + Kuyruklu a + Ters eğik çizgi + Madde işareti + Düzeltme işareti + Sent simgesi + İki Nokta + Virgül + Telif hakkı + Sol kıvrık ayraç + Sağ kıvrık ayraç + Derece işareti + Bölme işareti + Dolar işareti + Üç nokta + Uzun çizgi + Orta çizgi + Avro işareti + Ünlem işareti + Aksan işareti + Kısa çizgi + Alt çift tırnak + Çarpma işareti + Yeni satır + Paragraf işareti + Sol parantez + Sağ parantez + Yüzde + Nokta + Pi + Kare + Sterlin para birimi simgesi + Soru işareti + Tırnak + Tescilli ticari marka + Noktalı virgül + Eğik çizgi + Boşluk + Sol köşeli ayraç + Sağ köşeli ayraç + Karekök + Ticari marka + Alt çizgi + Dikey çizgi + Yen + Değil işareti + Kesik çubuk + Mikro işareti + Yaklaşık + Eşit değildir + Para birimi işareti + Bölüm işareti + Yukarı ok + Sol ok + Rupi + Siyah Kalp + Yaklaşık işareti + Eşit işareti + Won para birimi simgesi + Referans İşareti + Beyaz yıldız + Siyah yıldız + Beyaz Kalp + Beyaz daire + Siyah daire + Güneş sembolü + Hedefin ortası + Beyaz ispati grubu + Beyaz maça grubu + Solu gösteren beyaz işaret parmağı + Sağı gösteren beyaz işaret parmağı + Sol yarısı siyah daire + Sağ yarısı siyah daire + Beyaz kare + Siyah kare + Yukarıyı gösteren beyaz üçgen + Aşağı gösteren beyaz üçgen + Solu gösteren beyaz üçgen + Sağı gösteren beyaz üçgen + Beyaz karo + Çeyrek Nota + Sekizlik Nota + Çizgili onaltılık notalar + Kadın sembolü + Erkek sembolü + Siyah Mercek Biçiminde Sol Ayraç + Siyah Mercek Biçiminde Sağ Ayraç + Sol Köşe Ayraç + Sağ Köşe Ayraç + Sağ Ok + Aşağı Ok + Artı eksi işareti + Litre + Santigrat derece + Fahrenhayt derece + Yaklaşık olarak eşit + İntegral + Matematiksel sol açılı ayraç + Matematiksel sağ açılı ayraç + Posta işareti + Yukarıyı gösteren siyah üçgen + Aşağıyı gösteren siyah üçgen + Siyah karo + Yarım Genişlikte Katakana orta nokta + Küçük siyah kare + Sol çift açılı ayraç + Sağ çift açılı ayraç + Ters ünlem işareti + Ters soru işareti + Won para birimi simgesi + Tam genişlikte virgül + Tam genişlikte ünlem işareti + İdeogram tam nokta + Tam genişlikte soru işareti + Orta nokta + Sağ çift tırnak işareti + İdeogram virgül + Tam genişlikte iki nokta üst üste + Tam genişlikte noktalı virgül + Tam genişlikte \"ve\" işareti + Tam genişlikte inceltme işareti + Tam genişlikte tilde işareti + Sol çift tırnak işareti + Tam genişlikte sol parantez + Tam genişlikte sağ parantez + Tam genişlikte yıldız işareti + Tam genişlikte alt çizgi + Sağ tek tırnak işareti + Tam genişlikte sol kıvrık ayraç + Tam genişlikte sağ kıvrık ayraç + Tam genişlikte küçüktür işareti + Tam genişlikte büyüktür işareti + Sol tek tırnak işareti + diff --git a/utils/src/main/res/values-uk/strings.xml b/utils/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..4b08b5d --- /dev/null +++ b/utils/src/main/res/values-uk/strings.xml @@ -0,0 +1,50 @@ + + + Символи від %1$d до %2$d + Символ %1$d + без назви + скопійовано: %1$s + велика літера %1$s + %2$s: %1$d + Використовується %1$s + Натисніть клавіші, щоб установити нову комбінацію. Вона має містити принаймні клавішу Alt або Ctrl. + Щоб налаштувати нову комбінацію клавіш, натисніть її, утримуючи клавішу-модифікатор %1$s. + Не призначено + Shift + Alt + Ctrl + Пошук + Стрілка вправо + Стрілка вліво + Стрілка вгору + Стрілка вниз + За умовчанням + Символи + Слова + Рядки + Абзаци + Вікна + Орієнтири + Заголовки + Списки + Посилання + Елементи керування + Спеціальний контент + Заголовки + Елементи керування + Посилання + Картинка в картинці: %1$s + %1$s угорі, %2$s унизу + %1$s ліворуч, %2$s праворуч + %1$s праворуч, %2$s ліворуч + Показано елементи %1$d – %2$d з %3$d. + Показано елемент %1$d з %2$d. + Сторінка %1$d з %2$d + %1$d з %2$d + %1$s (%2$s) + Вийти + Показано \"%1$s\" + клавіатуру сховано + Голосові підказки ввімкнено + Голосові підказки вимкнено + diff --git a/utils/src/main/res/values-uk/strings_symbols.xml b/utils/src/main/res/values-uk/strings_symbols.xml new file mode 100644 index 0000000..f50d77b --- /dev/null +++ b/utils/src/main/res/values-uk/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Апостроф + Амперсанд + Знак \"менше ніж\" + Знак \"більше ніж\" + Зірочка + Равлик + Обернена скісна риска + Маркер + Символ вставки + Знак цента + Двокрапка + Кома + Авторське право + Ліва фігурна дужка + Права фігурна дужка + Знак градуса + Знак ділення + Знак долара + Три крапки + Довге тире + Коротке тире + Євро + Знак оклику + Гравіс + Тире + Нижні подвійні лапки + Знак множення + Новий рядок + Знак абзацу + Ліва дужка + Права дужка + Відсоток + Крапка + Пі + Решітка + Знак фунта + Знак питання + Лапки + Зареєстрована торговельна марка + Крапка з комою + Скісна риска + Пробіл + Ліва квадратна дужка + Права квадратна дужка + Квадратний корінь + Торговельна марка + Символ підкреслення + Вертикальна лінія + Японська єна + Знак м’якого переносу + Розірвана вертикальна лінія + Знак мікро + Майже дорівнює + Не дорівнює + Знак валюти + Знак параграфа + Стрілка вгору + Стрілка вліво + Рупія + Чорне серце + Тильда + Знак рівності + Знак вони (валюта) + Знак виноски + Біла зірка + Чорна зірка + Біла чирва + Біле коло + Чорне коло + Символ сонця + Бичаче око + Біла трефа + Біла піка + Білий вказівний палець, направлений ліворуч + Білий вказівний палець, направлений праворуч + Коло з чорною лівою половиною + Коло з чорною правою половиною + Білий квадрат + Чорний квадрат + Білий трикутник, направлений угору + Білий трикутник, направлений униз + Білий трикутник, направлений ліворуч + Білий трикутник, направлений праворуч + Біла бубна + Четвертна нота + Восьма нота + З’єднані шістнадцяті ноти + Символ жіночої статі + Символ чоловічої статі + Ліва чорна дужка півмісяцем + Права чорна дужка півмісяцем + Ліва кутова дужка + Права кутова дужка + Стрілка вправо + Стрілка вниз + Знак плюс/мінус + Літр + Градус Цельсія + Градус Фаренгейта + Приблизно дорівнює + Інтеграл + Математична ліва кутова дужка + Математична права кутова дужка + Поштовий знак + Чорний трикутник, спрямований угору + Чорний трикутник, спрямований униз + Чорний ромб + Інтерпункт катакани половинної ширини + Маленький чорний квадрат + Ліва подвійна кутова дужка + Права подвійна кутова дужка + Перевернутий знак оклику + Перевернутий знак питання + Знак південнокорейської вони + Кома повної ширини + Знак оклику повної ширини + Ідеографічна крапка + Знак питання повної ширини + Інтерпункт + Праві подвійні лапки + Ідеографічна кома + Двокрапка повної ширини + Крапка з комою повної ширини + Амперсанд повної ширини + Циркумфлекс повної ширини + Тильда повної ширини + Ліві подвійні лапки + Ліва дужка повної ширини + Права дужка повної ширини + Зірочка повної ширини + Символ підкреслення повної ширини + Праві одинарні лапки + Ліва фігурна дужка повної ширини + Права фігурна дужка повної ширини + Знак \"менше\" повної ширини + Знак \"більше\" повної ширини + Ліві одинарні лапки + diff --git a/utils/src/main/res/values-ur/strings.xml b/utils/src/main/res/values-ur/strings.xml new file mode 100644 index 0000000..d3d4644 --- /dev/null +++ b/utils/src/main/res/values-ur/strings.xml @@ -0,0 +1,50 @@ + + + %1$d سے %2$d کریکٹرز + کریکٹر %1$d + بلا عنوان + کاپی ہو گیا، %1$s + بڑا حرف %1$s + %1$d %2$s + %1$s کا استعمال کر رہا ہے + ‏نیا شارٹ کٹ سیٹ کرنے کیلئے کلید کا جوڑا دبائیں۔ اس میں کم از کم ALT یا Control کلید ہونا لازمی ہے۔ + نیا شارٹ کٹ سیٹ کرنے کیلئے %1$s موڈیفائر کلید کے ساتھ کلید کا مجموعہ دبائیں۔ + غیر مختص + شفٹ + Alt + Ctrl + تلاش + دائیں طرف تیر کا نشان + بائیں طرف تیر کا نشان + اوپر کی طرف تیر کا نشان + نیچے کی طرف تیر کا نشان + ڈیفالٹ + حروف + الفاظ + لائنیں + پیراگرافس + Windows + نمایاں مقامات + ہیڈنگز + فہرستیں + لنکس + کنٹرولز + خصوصی مواد + ہیڈنگز + کنٹرولز + لنکس + %1$s تصویر میں تصویر + %1$s اوپر، %2$s نیچے + %1$s بائیں طرف، %2$s دائیں طرف + %1$s دائیں طرف، %2$s بائیں طرف + %3$d میں سے %1$d سے %2$d تک آئٹمز دکھا رہا ہے۔ + %2$d میں سے %1$d آئٹم دکھا رہا ہے۔ + صفحہ %1$d از %2$d + %1$d از %2$d + %1$s (%2$s) + باہر نکلیں + %1$s دکھایا جا رہا ہے + کی بورڈ پوشیدہ ہے + صوتی تاثرات آن ہے + صوتی تاثرات آف ہے + diff --git a/utils/src/main/res/values-ur/strings_symbols.xml b/utils/src/main/res/values-ur/strings_symbols.xml new file mode 100644 index 0000000..8b0d620 --- /dev/null +++ b/utils/src/main/res/values-ur/strings_symbols.xml @@ -0,0 +1,140 @@ + + + علامت حذف + ایمپرسینڈ + اس سے کم کا نشان + اس سے بڑا کا نشان + ستارہ کا نشان + ایٹ + بیک سلیش + نقطہ + کیرٹ + سینٹ کا نشان + کولن + کاما + کاپی رائٹ + بایاں گھونگرا بریکٹ + دایاں گھونگرا بریکٹ + ڈگری کا نشان + ڈویژن کا اشان + ڈالر کا نشان + ایلپسیس + ایم ڈیش + این ڈیش + یورو + فجائیہ کا نشان + سنجیدہ تلفظ کا نشان + ڈیش + کم ڈبل کوٹ + ضرب کا نشان + نئی لکیر + پیراگراف کا نشان + بایاں قوسین + دایاں قوسین + فیصد + پیریڈ + Pi + پاؤنڈ + پاؤنڈ کرنسی کا نشان + سوالیہ نشان + علامت اقتباس + رجسٹرڈ ٹریڈ مارک + سیمی کولن + سلیش + اسپیس + بایاں سکوئیر بریکٹ + دایاں سکوئیر بریکٹ + جذر مربع + ٹریڈ مارک + انڈرسکور + عمودی لائن + ین + علامت نفی + ٹوٹا ہوا بار + مائیکرو کا نشان + تقریبا اس کے برابر + کے برابر نہیں + کرنسی کا نشان + سیکشن کا نشان + اوپر کی طرف تیر کا نشان + بائیں جانب تیر کا نشان + روپیہ + سیاہ دل + اعراب + برابر کا نشان + وون کرنسی کا نشان + حوالہ کا نشان + سفید ستارہ + سیاہ ستارہ + سفید دل + سفید دائرہ + سیاہ دائرہ + شمسی علامت + بلز آئی + سفید کلب سوٹ + سفید اسپیڈ سوٹ + سفید بائیں اشارہ کرتا ہوا اشاریہ + سفید دائیں اشارہ کرتا ہوا اشاریہ + بائیں نصف سیاہ کے ساتھ دائرہ + دائیں نصف سیاہ کے ساتھ دائرہ + سفید مربع + سیاہ مربع + سفید اوپر اشارہ کرتا ہوا مثلث + سفید نیچے اشارہ کرتا ہوا مثلث + سفید بائیں اشارہ کرتا ہوا مثلث + سفید دائیں اشارہ کرتا ہوا مثلث + سفید ہیرا + کوارٹر نوٹ + آٹھواں نوٹ + بیم کیے ہوئے سولہویں نوٹس + مؤنث کی علامت + مذکر کی علامت + بائیں سیاہ عدسی بریکٹ + دائیں سیاہ عدسی بریکٹ + بائیں کونے کا بریکٹ + دائیں کونے کا بریکٹ + دائیں طرف تیر کا نشان + نیچے کی طرف والا تیر کا نشان + جمع تفریق کا نشان + لیٹر + سیلسیس ڈگری + فارن ہائٹ ڈگری + تقریباً برابر + سالم + ریاضیاتی بائیں زاویہ والا بریکٹ + ریاضیاتی دائیں زاویہ والا بریکٹ + پوسٹل مارک + اوپر کی طرف اشارہ کرتا ہوا سیاہ مثلث + نیچے کی طرف اشارہ کرتا ہوا سیاہ مثلث + ڈائمنڈز کا سیاہ سوٹ + کٹاکانا کا نصف چوڑائی والا نقطہ + چھوٹا سیاہ چوکور + بائیں ڈبل زاویہ والا بریکٹ + دائیں ڈبل زاویہ والا بریکٹ + الٹی علامت استعجاب + الٹا سوالیہ نشان + وون کرنسی کا نشان + پوری چوڑائی والا کوما + پوری چوڑائی والا استعجابیہ نشان + تصور نگاری کا وقف لازم + پوری چوڑائی والا سوالیہ نشان + درمیانی ڈاٹ + دائیں ڈبل کوٹیشن مارک + تصور نگاری والا کوما + پوری چوڑائی والا کولن + پوری چوڑائی والا سیمی کولن + پوری چوڑائی والا ایمپرسینڈ + پوری چوڑائی والا سرکمفلیکس + پوری چوڑائی والا ٹلڈ + بائیں ڈبل کوٹیشن مارک + پوری چوڑائی والا بایاں قوسین + پوری چوڑائی والا دایاں قوسین + پوری چوڑائی والا ستارہ کا نشان + پوری چوڑائی والا انڈرسکور + دائیں سنگل کوٹیشن مارک + پوری چوڑائی والا بائیں کرلی بریکٹ + پوری چوڑائی والا دائیں کرلی بریکٹ + پوری چوڑائی والا از کم کا نشان + پوری چوڑائی والا از زائد کا نشان + بائیں سنگل کوٹیشن مارک + diff --git a/utils/src/main/res/values-uz/strings.xml b/utils/src/main/res/values-uz/strings.xml new file mode 100644 index 0000000..ac9df77 --- /dev/null +++ b/utils/src/main/res/values-uz/strings.xml @@ -0,0 +1,50 @@ + + + Belgilar: %1$d – %2$d + %1$d-belgi + sarlavhasiz + nusxa ko‘chirildi: %1$s + bosh harf: %1$s + %1$d ta %2$s + Foydalanilmoqda: %1$s + Biror amalga tugmalar birikmasini tayinlash uchun ularni bosing. Unda Ctrl yoki ALT tugmasi bo‘lishi shart. + Yangi tezkor tugmalar birikmasini o‘rnatish uchun uni %1$s modifikator tugmasi bilan bosing. + Tayinlanmagan + Shift + Alt + Ctrl + Qidiruv + O‘ngga qaragan ko‘rsatkichli chiziq + Chapga qaragan ko‘rsatkichli chiziq + Tepaga qaragan ko‘rsatkichli chiziq + Pastga qaragan ko‘rsatkichli chiziq + Standart + Belgilar + So‘zlar + Qatorlar + Xatboshilar + Oynalar + Belgilar + Sarlavhalar + Ro‘yxatlar + Havolalar + Boshqaruv elementlari + Maxsus kontent + Sarlavhalar + Boshqaruv + Havolalar + %1$s: tasvir ustida tasvir rejimi + Tepada %1$s, pastda %2$s + Chapda %1$s, o‘ngda %2$s + O‘ngda %1$s, chapda %2$s + Jami %1$d ta elementdan %2$d dan %3$d gacha elementlar ko‘rsatilmoqda. + Jami %1$d ta elementdan %2$d tasi ko‘rsatilmoqda. + Jami %2$d tadan %1$d ta sahifa + Jami %2$d tadan %1$d ta + %1$s (%2$s) + Chiqish + %1$s klaviaturasi ochiq + klaviatura berkitildi + Ovozli fikr-mulohaza yoniq + Ekrandagi matnni oʻqib berish faolsizlantirilgan + diff --git a/utils/src/main/res/values-uz/strings_symbols.xml b/utils/src/main/res/values-uz/strings_symbols.xml new file mode 100644 index 0000000..5ba3c61 --- /dev/null +++ b/utils/src/main/res/values-uz/strings_symbols.xml @@ -0,0 +1,140 @@ + + + Tutuq belgisi + “Va” belgisi + Kichik belgisi + Katta belgisi + Yulduzcha + Elektron pochta belgisi + Teskari qiya chiziq + Marker + Qo‘yish belgisi + Sent belgisi + Ikki nuqta + Vergul + Mualliflik huquqi belgisi + Chap figurali qavs + O‘ng figurali qavs + Daraja belgisi + Bo‘lish belgisi + Dollar belgisi + Uch nuqta + Uzun chiziqcha + O‘rta chiziqcha + Yevro belgisi + Undov belgisi + Gravis + Chiziqcha + Pastki qo‘shtirnoq + Ko‘paytirish belgisi + Yangi chiziq + Xatboshi belgisi + Chap qavs + O‘ng qavs + Foiz belgisi + Nuqta + Pi harfi + Panjara + Funt sterling belgisi + So‘roq belgisi + Iqtibos belgisi + Ro‘yxatga olingan savdo belgisi + Nuqtali vergul + Qiya chiziq + Probel + Chap kvadrat qavs + O‘ng kvadrat qavs + Kvadrat ildiz + Savdo belgisi + Ostiga chizish + Vertikal chiziq + Iyena belgisi + Mumkin emas belgisi + Tik uzuq chiziq + Mikro belgisi + Deyarli teng + Teng emas + Valyuta belgisi + Bo‘lim belgisi + Yuqori ko‘rsatkichli chiziq + Chap ko‘rsatkichli chiziq + Rupiya belgisi + Qora cha + Tilda + Teng belgisi + Von pul birligi belgisi + Havola belgisi + Oq yulduzcha + Qora yulduzcha + Oq yurakcha + Oq doira + Qora doira + Quyosh belgisi + Katta ko‘z + Oq chillik + Oq qarg‘a + Chapga qaragan oq ko‘rsatkich belgisi + O‘ngga qaragan oq ko‘rsatkich belgisi + Chap yarmi qora doira + O‘ng yarmi qora doira + Oq kvadrat + Qora kvadrat + Tepaga qaragan oq uchburchak + Pastga qaragan oq uchburchak + Chapga qaragan oq uchburchak + O‘ngga qaragan oq uchburchak + Oq g‘ishtin + Chorak nota + Sakkiztalik nota + Qo‘shma o‘n oltitalik nota + Ayol belgisi + Erkak belgisi + Qora chap ikkiyoqlama qavariq qavs + Qora o‘ng ikkiyoqlama qavariq qavs + Chap burchak qavs + O‘ng burchak qavs + O‘ngga qaragan ko‘rsatkichli chiziq + Pastga qaragan ko‘rsatkichli chiziq + Plus-minus belgisi + Litr + Selsiy darajasi + Farengeyt darajasi + Taqriban teng + Integral + Chap kvadrat qavs + Oʻng kvadrat qavs + Pochta belgisi + Tepaga qaragan qora uchburchak + Pastga qaragan qora uchburchak + Qora romb + Yarim enli Katakana nuqtasi + Kichik qora kvadrat + Chap juft burchakli qavs + Oʻng juft burchakli qavs + Teskari undov belgisi + Teskari soʻroq belgisi + Von pul birligi belgisi + Qalin vergul + Qalin undov belgisi + Ideografik qalin nuqta + Qalin soʻroq belgisi + Markazlashgan nuqta + Oʻng qoʻshtirnoq belgisi + Ideografik vergul + Qalin ikki nuqta + Qalin nuqtali vergul + Qalin ampersand + Qalin sirkumfleks + Qalin tilda + Chap qoʻshtirnoq belgisi + Qalin chap qavs + Qalin oʻng qavs + Qalin yulduzcha + Qalin pastki chiziqcha + Oʻng bir tirnoq belgisi + Qalin chap figurali qavs + Qalin oʻng figurali qavs + Qalin kichik belgisi + Qalin katta belgisi + Chap bir tirnoq belgisi + diff --git a/utils/src/main/res/values-v29/styles.xml b/utils/src/main/res/values-v29/styles.xml new file mode 100644 index 0000000..0359241 --- /dev/null +++ b/utils/src/main/res/values-v29/styles.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + diff --git a/utils/src/main/res/values-zh-rCN/strings.xml b/utils/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..bf9cd4b --- /dev/null +++ b/utils/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,50 @@ + + + 第 %1$d 到 %2$d 个字符 + 字符 %1$d + 无标题 + 已复制,%1$s + 大写的 %1$s + %1$d 个%2$s + 使用的是%1$s + 按组合键可设置新的快捷方式。快捷方式必须包含 ALT 或 Ctrl 键。 + 按下包含 %1$s 辅助键的组合键即可设置新的快捷键。 + 未指定 + Shift 键 + Alt 键 + Ctrl 键 + 搜索键 + 向右箭头 + 向左箭头 + 向上箭头 + 向下箭头 + 默认 + 字符 + 字词 + + 段落 + 窗口 + 地标 + 标题 + 列表 + 链接 + 控件 + 特殊内容 + 标题 + 控件 + 链接 + %1$s画中画 + %1$s在顶部,%2$s在底部 + %1$s在左侧,%2$s在右侧 + %1$s在右侧,%2$s在左侧 + 当前显示的是第 %1$d 项到第 %2$d 项,共 %3$d 项。 + 当前显示的是第 %1$d 项,共 %2$d 项。 + 第 %1$d 页,共 %2$d 页 + 第 %1$d 页,共 %2$d 页 + %1$s (%2$s) + 退出 + 当前显示的是“%1$s”窗口 + 键盘已隐藏 + 已开启语音反馈 + 已关闭语音反馈 + diff --git a/utils/src/main/res/values-zh-rCN/strings_symbols.xml b/utils/src/main/res/values-zh-rCN/strings_symbols.xml new file mode 100644 index 0000000..0ddad20 --- /dev/null +++ b/utils/src/main/res/values-zh-rCN/strings_symbols.xml @@ -0,0 +1,140 @@ + + + 单引号 + 和符号 + 小于号 + 大于号 + 星号 + At 符号 + 反斜杠 + 项目符号 + 脱字符号 + 分币符号 + 冒号 + 逗号 + 版权符号 + 左花括号 + 右花括号 + 度符号 + 除号 + 美元符号 + 省略号 + 长破折号 + 短破折号 + 欧元符号 + 感叹号 + 重音符号 + 破折号 + 低双引号 + 乘号 + 换行符号 + 段落符号 + 左括号 + 右括号 + 百分比符号 + 句点 + 圆周率符号 + 井号 + 英镑货币符号 + 问号 + 引号 + 注册商标 + 分号 + 斜线 + 空格 + 左方括号 + 右方括号 + 平方根符号 + 商标符号 + 下划线 + 竖线 + 日元符号 + 非符号 + 断竖线 + 微单位标记 + 约等于 + 不等于 + 货币符号 + 章节符号 + 向上箭头 + 向左箭头 + 卢比符号 + 实心红桃 + 波浪号 + 等号 + 韩元货币符号 + 参考符号 + 空心星形 + 实心星形 + 空心红桃 + 空心圆圈 + 实心圆圈 + 太阳符号 + 靶心符号 + 空心梅花 + 空心黑桃 + 空心向左指的食指 + 空心向右指的食指 + 左半边实心的圆圈 + 右半边实心的圆圈 + 空心方形 + 实心方形 + 空心正三角 + 空心倒三角 + 空心左指三角形 + 空心右指三角形 + 空心方块 + 四分音符 + 八分音符 + 带连线符的两个十六分音符 + 雌性符号 + 雄性符号 + 左实心凹形括号 + 右实心凹形括号 + 左角括号 + 右角括号 + 向右箭头 + 向下箭头 + 正负号 + 公升符号 + 摄氏度符号 + 华氏度符号 + 约等号 + 积分符号 + 数学左尖括号 + 数学右尖括号 + 邮政符号 + 指向上方的黑色三角形 + 指向下方的黑色三角形 + 黑色菱形 + 半角片假名中间点 + 黑色小正方形 + 左双尖括号 + 右双尖括号 + 倒置的感叹号 + 倒置的问号 + 韩元货币符号 + 全角逗号 + 全角感叹号 + 表意文字句号 + 全角问号 + 间隔号 + 右双引号 + 表意文字逗号 + 全角冒号 + 全角分号 + 全角和符号 + 全角扬抑符 + 全角波浪号 + 左双引号 + 全角左括号 + 全角右括号 + 全角星号 + 全角下划线 + 右单引号 + 全角左花括号 + 全角右花括号 + 全角小于号 + 全角大于号 + 左单引号 + diff --git a/utils/src/main/res/values-zh-rHK/strings.xml b/utils/src/main/res/values-zh-rHK/strings.xml new file mode 100644 index 0000000..a1b751f --- /dev/null +++ b/utils/src/main/res/values-zh-rHK/strings.xml @@ -0,0 +1,50 @@ + + + 第 %1$d 到第 %2$d 個字元 + 第 %1$d 個字元 + 無標題 + 已經複製:%1$s + 大寫:%1$s + %1$d 個%2$s + 正在使用 %1$s + 請按下組合鍵來設定新的快速鍵。它必須至少包含 ALT 或 Control 鍵。 + 按下包含 %1$s 輔助鍵的按鍵組合即可設定新快速鍵。 + 未指派 + Shift 鍵 + Alt 鍵 + Ctrl 鍵 + 搜尋鍵 + 向右方向鍵 + 向左方向鍵 + 向上方向鍵 + 向下方向鍵 + 預設 + 字元 + + + + 視窗 + 位置標記 + 標題 + 清單 + 連結 + 控制 + 特殊內容 + 標題 + 控制項 + 連結 + 「%1$s」畫中畫 + %1$s喺上面,%2$s喺下面 + %1$s喺左邊,%2$s喺右邊 + %1$s喺右邊,%2$s喺左邊 + 顯示第 %1$d 至 %2$d 個項目 (共 %3$d 個)。 + 顯示第 %1$d 個項目 (共 %2$d 個)。 + %2$d 頁中嘅第 %1$d 頁 + %2$d 頁中嘅第 %1$d 頁 + %1$s (%2$s) + 退出 + 宜家顯示嘅係%1$s + 隱藏咗鍵盤 + 開咗朗讀功能 + 閂咗朗讀功能 + diff --git a/utils/src/main/res/values-zh-rHK/strings_symbols.xml b/utils/src/main/res/values-zh-rHK/strings_symbols.xml new file mode 100644 index 0000000..653a9b7 --- /dev/null +++ b/utils/src/main/res/values-zh-rHK/strings_symbols.xml @@ -0,0 +1,140 @@ + + + 撇號 + & 符號 + 細於符號 + 大於符號 + 星號 + 「At」符號 + 反斜線 + 項目符號 + 插入符號 + 仙符號 + 冒號 + 逗號 + 版權符號 + 左大括弧 + 右大括弧 + 度數符號 + 除號 + 貨幣符號 + 省略號 + 長破折號 + 短破折號 + 歐元符號 + 感歎號 + 重音符號 + 破折號 + 下雙引號 + 乘號 + 新行 + 段落標記 + 左括弧 + 右括弧 + 百分比符號 + 句號 + 圓周率符號 + 井號 + 英鎊貨幣符號 + 問號 + 引號 + 註冊商標符號 + 分號 + 斜線 + 空格 + 左方括弧 + 右方括弧 + 平方根 + 商標符號 + 下劃線 + 直線 + 日元符號 + 邏輯非符號 + 斷裂線 + Micro 符號 + 幾乎等於 + 唔等於 + 貨幣符號 + 章節符號 + 向上箭頭 + 向左箭頭 + 盧比符號 + 黑色心形 + 波浪號 + 等號 + 韓圜貨幣符號 + 參考標記 + 白色星形 + 黑色星形 + 白色心形 + 白色圓形 + 黑色圓形 + 太陽符號 + 靶心 + 白色梅花 + 白色葵扇 + 白色向左指示標記 + 白色向右指示標記 + 左半邊黑色圓形 + 右半邊黑色圓形 + 白色方形 + 黑色方形 + 白色正三角形 + 白色倒三角形 + 白色向左三角形 + 白色向右三角形 + 白色階磚 + 四分音符 + 八分音符 + 連寫十六分音符 + 女性符號 + 男性符號 + 左邊黑方頭括號 + 右邊黑方頭括號 + 左邊單引號 + 右邊單引號 + 向右箭頭 + 向下箭頭 + 加減符號 + 公升 + 攝氏溫度符號 + 華氏溫度符號 + 約等號 + 積分符號 + 數學左角括號 + 數學右角括號 + 郵局記號 + 指向上方的黑色三角形 + 指向下方的黑色三角形 + 黑色菱形 + 半形片假名中間點 + 黑色小正方形 + 雙書名號開引號 + 雙書名號閂引號 + 倒轉嘅感嘆號 + 倒問號 + 韓圜貨幣符號 + 全形逗號 + 全形感嘆號 + 表意文字句號 + 全形問號 + 中間點 + 右雙引號 + 表意文字逗號 + 全形冒號 + 全形分號 + 全形「&」符號 + 全形抑揚符 + 全形波浪號 + 左雙引號 + 全形左括號 + 全形右括號 + 全形星號 + 全形底線 + 右單引號 + 全形左大括號 + 全形右大括號 + 全形細於符號 + 全形大於符號 + 左單引號 + diff --git a/utils/src/main/res/values-zh-rTW/strings.xml b/utils/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..b08647d --- /dev/null +++ b/utils/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,50 @@ + + + 第 %1$d 到 %2$d 個字元 + 字元 %1$d + 未命名 + 已複製,%1$s + 大寫 %1$s + %1$d 個%2$s + 目前使用 %1$s + 按下組合鍵來設定新的快速鍵。快速鍵必須至少包含 ALT 或 Ctrl 鍵。 + 同時按下組合鍵和「%1$s」輔助鍵,即可設定新快速鍵。 + 未指派 + Shift 鍵 + Alt 鍵 + Ctrl 鍵 + 搜尋鍵 + 向右鍵 + 向左鍵 + 向上鍵 + 向下鍵 + 預設 + 字元 + + 單行 + 段落 + 視窗 + 界標 + 標題 + 清單 + 連結 + 控制項 + 特殊內容 + 標題 + 控制項 + 連結 + 「%1$s」子母畫面 + 上方是「%1$s」,下方是「%2$s」 + 左側是「%1$s」,右側是「%2$s」 + 右側是「%1$s」,左側是「%2$s」 + 顯示項目 %1$d 到 %2$d,總共 %3$d 項。 + 顯示項目 %1$d,總共 %2$d 項。 + 第 %1$d 頁,共 %2$d 頁 + 第 %1$d 頁,共 %2$d 頁 + %1$s (%2$s) + 結束 + 目前顯示的是%1$s + 已隱藏鍵盤 + 已開啟互動朗讀功能 + 已關閉互動朗讀功能 + diff --git a/utils/src/main/res/values-zh-rTW/strings_symbols.xml b/utils/src/main/res/values-zh-rTW/strings_symbols.xml new file mode 100644 index 0000000..8aff71b --- /dev/null +++ b/utils/src/main/res/values-zh-rTW/strings_symbols.xml @@ -0,0 +1,140 @@ + + + 單引號 + AND 符號 + 小於符號 + 大於符號 + 星號 + 小老鼠 + 反斜線 + 項目符號 + 脫字符號 + 美分符號 + 冒號 + 英文逗號 + 版權符號 + 左大括號 + 右大括號 + 度數符號 + 除號 + 錢幣符號 + 省略符號 + 長破折號 + 短破折號 + 歐元符號 + 驚嘆號 + 重音符號 + 破折號 + 下雙引號 + 乘號 + 換行 + 段落標記 + 左括號 + 右括號 + 百分比符號 + 句號 + 圓周率 + 井字鍵 + 英鎊貨幣符號 + 問號 + 引號 + 註冊商標 + 分號 + 斜線 + 空格鍵 + 左方括號 + 右方括號 + 平方根 + 商標 + 底線 + 垂直線 + 日圓 + 否定符號 + 斷裂線 + Micro 符號 + 約等於 + 不等於 + 貨幣符號 + 章節符號 + 向上鍵 + 向左鍵 + 盧比符號 + 黑色心形 + 波狀符號 + 等號 + 韓元貨幣符號 + 參照標記 + 白色星形 + 黑色星形 + 白色心形 + 白色圓形 + 黑色圓形 + 太陽符號 + 靶心 + 白色梅花形 + 白色桃形 + 白色向左指引標誌 + 白色向右指引標誌 + 左半部填滿黑色的圓形 + 右半部填滿黑色的圓形 + 白色方形 + 黑色方形 + 白色正三角形 + 白色倒三角形 + 白色向左指引三角形 + 白色向右指引三角形 + 白色菱形 + 四分音符 + 八分音符 + 有連線符的十六分音符 + 女性符號 + 男性符號 + 黑色左透鏡狀括弧 + 黑色右透鏡狀括弧 + 左全形引號 + 右全形引號 + 向右箭頭 + 向下箭頭 + 加減號 + 公升 + 攝氏溫度 + 華氏溫度 + 大約等於 + 積分 + 數學左角括號 + 數學右角括號 + 郵政符號 + 指向上方的黑色三角形 + 指向下方的黑色三角形 + 黑色菱形 + 半形片假名中間點 + 小型黑色正方形 + 左雙角括號 + 右雙角括號 + 倒置的驚嘆號 + 倒置的問號 + 韓元貨幣符號 + 全形逗號 + 全形驚嘆號 + 表意文字全形句號 + 全形問號 + 中間點 + 右雙引號 + 表意文字逗號 + 全形冒號 + 全形分號 + 全形 & 號 + 全形揚抑符 + 全形波浪號 + 左雙引號 + 全形左括號 + 全形右括號 + 全形星號 + 全形底線 + 右單引號 + 全形左大括號 + 全形右大括號 + 全形小於符號 + 全形大於符號 + 左單引號 + diff --git a/utils/src/main/res/values-zu/strings.xml b/utils/src/main/res/values-zu/strings.xml new file mode 100644 index 0000000..19acbaa --- /dev/null +++ b/utils/src/main/res/values-zu/strings.xml @@ -0,0 +1,50 @@ + + + Izinhlamvu kusukela ku-%1$d ukuya ku-%2$d + Uhlamvu olungu-%1$d + okungenasihloko + kukopishiwe, %1$s + usonhlamvukazi %1$s + %1$d %2$s + Isebenzisa i-%1$s + Cindezela ukuhlanganiswa kokhiye ukuze usethe isinqamuleli esisha. Kumele kuqukathe okungenani ukhiye ongu-ALT noma ongu-Control. + Cindezela ukuhlanganiswa kokhiye ngokhiye wokushintsha ongu-%1$s ukuze kusethwe isinqamuleli esisha. + Akunikezwanga + U-Shift + U-Alt + U-Ctrl + Sesha + Umcibisholo wangakwesokudla + Umcimbisholo wangakwesokunxele + Umcibisholo waphezulu + Umcibisholo waphansi + Okuzenzakalelayo + Izinhlamvu + Amagama + Imigqa + Amapharagrafu + Amawindi + Isici sokubonisa izwe + Izihloko + Uhlu + Izixhumanisi + Izilawuli + Okuqukethwe okubalulekile + Izihloko + Izilawuli + Izixhumanisi + %1$s isithombe esingaphakathi kwesithombe + %1$s phezulu, %2$s phansi + %1$s ngakwesokunxele, %2$s ngakwesokudla + %1$s ngakwesokudla, %2$s ngakwesokunxele + Ibonisa izinto ezisuka ku-%1$d ukuya ku-%2$d kwezingu-%3$d. + Ibonisa into esuka ku-%1$d kwezingu-%2$d. + Ikhasi elingu-%1$d kwangu-%2$d + %1$d kwangu-%2$d + %1$s (%2$s) + Phuma + Ibonisa i-%1$s + Ikhibhodi ifihliwe + Impendulo ekhulunywayo ivuliwe + Impendulo ekhulunywayo ivaliwe + diff --git a/utils/src/main/res/values-zu/strings_symbols.xml b/utils/src/main/res/values-zu/strings_symbols.xml new file mode 100644 index 0000000..f662e16 --- /dev/null +++ b/utils/src/main/res/values-zu/strings_symbols.xml @@ -0,0 +1,140 @@ + + + I-aphostrofi + I-Ampersand + Uphawu lokuncane kunokunye + Uphawu lokukhulu kunokunye + I-Asterisk + E- + I-Backslash + Inhlamvu + Ikharethi + Uphawu lwesenti + Ikholoni + Ikhefu + I-Copyright + Ubakaki ojikayo ongakwesobunxele + Ubakaki ojikayo ongakwesokudla + Uphawu lwe-degree + Uphawu lokuhlukanisa + Uphawu ledola + Okuqhubekayo + Ideshi le-Em + Ideshi le-En + I-Euro + Umbabazi + I-Grave accent + Ideshi + Isilinganiso esikabili esiphansi + Uphawu lokuphindaphinda + Umugqa omusha + Umaki wepharagrafu + Umkaki wangakwesokunxele + Umkaki wangakwesokudla + Iphesenti + Isikhathi + i-Pi + Iphawundi + Uphawu lohlobo lwemali lephawundi + Uphawu lombuzo + Izicaphuni + Uphawu lentengiso olubhalisiwe + Ikhefanangqi + Umhwaphuluzo + Isikhala + Ubakaku wesikwele ongakwesokunxele + Ubakaki wangakwesokudla + Impande yesikwele + Uphawu lomkhiqizo + I-Underscore + Umugqa omile + Yen + Akulona uphawu + Ibha ephukile + Uphawu olukhulu + Cishe kulingana ne- + Akulingani ne- + Uphawu lwemali + Uphawu lwesigaba + Umcibisholo waphezulu + Umcibisholo ongakwesokunxele + I-Rupee + Inhliziyo emnyama + I-tilde + Uphawu lokulingana + Uphawu lwemali lwe-Won + Umaki wereferensi + Inkanyezi emhlophe + Inkanyezi emnyama + Inhliziyo emhlophe + Indingilizi emhlophe + Umbuthano omnyama + Uphawu le-Solar + Bullseye + Insudu yeklabhu emnyama + Insudu emhlophe + Inkomba ekhombe ngakwesokunxele emhlophe + Inkomba ekhombe ngakwesokudla emhlophe + Indingilizi enohhafu omnyama ngakwesokunxele + Indingilizi enohhafu omnyama ngakwesokudla + Isikwele esimhlophe + Isikwele esimnyama + Unxantathu okhombe phezulu omhlophe + Unxantathu okhombe ezansi omhlophe + Unxantathu okhombe ngakwesokunxele omhlophe + Unxantathu okhombe ngakwesokudla omhlophe + Idayimondi emhlophe + Inothi yekhotha + Inothi lesishiyagalombili + Amanothi weshumu nesithupha avumelanisiwe + Uphawu lowesifazane + Uphawu lowesilisa + Ibhulakhethi le-Lenticular elimnyama ngakwesokunxele + Ibhulakhethi le-Lenticular elimnyama ngakwesokudla + Ibhulakhethi elikhoneni ngakwesokunxele + Ibhulakhethi elikhoneni ngakwesokudla + Umcibisholo wangakwesokudla + Umcibisholo oya phansi + Uphawu lokuhlanganisa nokukhipha + Ilitha + I-Celsius degree + I-Fahrenheit degree + Cishe kuyalingana + I-Integral + Ibhulakhethi le-engeli engakwesokunxele lezibalo + Ibhulakhethi le-engeli engakwesokunxele lezibalo + Uphawu lweposi + Unxantantu omnyama ukhombe phezulu + Unxantathu omnyama ukhombe phansi + Isudi emnyama yamadayimane + Icashaza elimaphakathi le-Halfwidth Katakana + Isikwele esincane esimnyama + Abakaki be-engele ekabili ngakwesobunxele + Abakaki be-engele ekabili ngakwesokudla + Umbabazi okokushiwo + Umbuzi okokushiwo + Uwine uphawu lohlobo lwemali + Ububanzi obugcwele bekhefu + Ububanzi obugcwele bophawu lokubabaza + Ungci we-Ideographic + Ububanzi obugcwele bophawu lombuzo + Icashaza eliphakathi + Uphawu olungakwesokudla lwezicaphuni eziphindwe kabili + Ukhefana we-Ideographic + Ububanzi obugcwele bekholoni + Ububanzi obugcwele bekhefanangqi + Ububanzi obugcwele be-ampersand + Ububanzi obugcwele be-circumflex + Ububanzi obugcwele be-tilde + Uphawu olungakwesokunxele lwezicaphuni eziphindwe kabili + Ububanzi obugcwele be-parenthesis engakwesokunxele + Ububanzi obugcwele be-parenthesis engakwesokudla + Ububanzi obugcwele benkanyezana + Ububanzi obugcwele be-underscore + Uphawu olungakwesokudla lwezicaphuni olulodwa + Ububanzi obugcwele bobakaki ojikayo ngakwesobunxele + Ububanzi obugcwele bobakaki ojikayo ngakwesokudla + Ububanzi obugcwele bophawu lokuncane + Ububanzi obugcwele bophawu lokukhulu + Uphawu olungakwesokunxele lwezicaphuni olulodwa + diff --git a/utils/src/main/res/values/bools.xml b/utils/src/main/res/values/bools.xml new file mode 100644 index 0000000..4187fbe --- /dev/null +++ b/utils/src/main/res/values/bools.xml @@ -0,0 +1,7 @@ + + + + + true + + diff --git a/utils/src/main/res/values/colors.xml b/utils/src/main/res/values/colors.xml new file mode 100644 index 0000000..1bf4ff0 --- /dev/null +++ b/utils/src/main/res/values/colors.xml @@ -0,0 +1,33 @@ + + + + + + @color/google_blue600 + @color/google_blue900 + + @color/google_white + @color/a11y_colorAccent + @color/a11y_colorAccentFocused + @color/google_black + + + @color/google_black + @color/google_grey200 + @color/google_grey200 + + diff --git a/utils/src/main/res/values/dimens.xml b/utils/src/main/res/values/dimens.xml new file mode 100644 index 0000000..d024e8a --- /dev/null +++ b/utils/src/main/res/values/dimens.xml @@ -0,0 +1,22 @@ + + + + + 8dp + 12dp + 8dp + diff --git a/utils/src/main/res/values/donottranslate.xml b/utils/src/main/res/values/donottranslate.xml new file mode 100644 index 0000000..494afbd --- /dev/null +++ b/utils/src/main/res/values/donottranslate.xml @@ -0,0 +1,91 @@ + + + + pref_select_keymap + pref_default_keymap_trigger_modifier + + classic_keymap + default_keymap + + trigger_modifier_alt + trigger_modifier_meta + + keycombo_shortcut_navigate_next + keycombo_shortcut_navigate_previous + keycombo_shortcut_navigate_next_default + keycombo_shortcut_navigate_previous_default + keycombo_shortcut_navigate_first + keycombo_shortcut_navigate_last + keycombo_shortcut_perform_click + keycombo_shortcut_perform_long_click + keycombo_shortcut_global_back + keycombo_shortcut_global_home + keycombo_shortcut_global_recents + keycombo_shortcut_global_notifications + keycombo_shortcut_global_suspend + keycombo_shortcut_global_play_pause_media + keycombo_shortcut_global_scroll_forward_reading_menu + keycombo_shortcut_global_scroll_backward_reading_menu + keycombo_shortcut_global_adjust_reading_settings_previous + keycombo_shortcut_global_adjust_reading_setting_next + keycombo_shortcut_granularity_increase + keycombo_shortcut_granularity_decrease + keycombo_shortcut_other_read_from_top + keycombo_shortcut_other_read_from_next_item + keycombo_shortcut_other_toggle_search + keycombo_shortcut_other_global_context_menu + keycombo_shortcut_other_custom_actions + keycombo_shortcut_other_language_options + keycombo_shortcut_navigate_up + keycombo_shortcut_navigate_down + keycombo_shortcut_navigate_next_word + keycombo_shortcut_navigate_previous_word + keycombo_shortcut_navigate_next_character + keycombo_shortcut_navigate_previous_character + keycombo_shortcut_navigate_next_button + keycombo_shortcut_navigate_previous_button + keycombo_shortcut_navigate_next_control + keycombo_shortcut_navigate_previous_control + keycombo_shortcut_navigate_next_checkbox + keycombo_shortcut_navigate_previous_checkbox + keycombo_shortcut_navigate_next_aria_landmark + keycombo_shortcut_navigate_previous_aria_landmark + keycombo_shortcut_navigate_next_edit_field + keycombo_shortcut_navigate_previous_edit_field + keycombo_shortcut_navigate_next_focusable_item + keycombo_shortcut_navigate_previous_focusable_item + keycombo_shortcut_navigate_next_graphic + keycombo_shortcut_navigate_previous_graphic + keycombo_shortcut_navigate_next_heading + keycombo_shortcut_navigate_previous_heading + keycombo_shortcut_navigate_next_heading_1 + keycombo_shortcut_navigate_previous_heading_1 + keycombo_shortcut_navigate_next_heading_2 + keycombo_shortcut_navigate_previous_heading_2 + keycombo_shortcut_navigate_next_heading_3 + keycombo_shortcut_navigate_previous_heading_3 + keycombo_shortcut_navigate_next_heading_4 + keycombo_shortcut_navigate_previous_heading_4 + keycombo_shortcut_navigate_next_heading_5 + keycombo_shortcut_navigate_previous_heading_5 + keycombo_shortcut_navigate_next_heading_6 + keycombo_shortcut_navigate_previous_heading_6 + keycombo_shortcut_navigate_next_list_item + keycombo_shortcut_navigate_previous_list_item + keycombo_shortcut_navigate_next_link + keycombo_shortcut_navigate_previous_link + keycombo_shortcut_navigate_next_list + keycombo_shortcut_navigate_previous_list + keycombo_shortcut_navigate_next_table + keycombo_shortcut_navigate_previous_table + keycombo_shortcut_navigate_next_combobox + keycombo_shortcut_navigate_previous_combobox + keycombo_shortcut_navigate_next_window + keycombo_shortcut_navigate_previous_window + keycombo_shortcut_open_manage_keyboard_shortcuts + keycombo_shortcut_open_talkback_settings + + + 6 + pref_log_level + diff --git a/utils/src/main/res/values/fonts.xml b/utils/src/main/res/values/fonts.xml new file mode 100644 index 0000000..27239ed --- /dev/null +++ b/utils/src/main/res/values/fonts.xml @@ -0,0 +1,20 @@ + + + + sans-serif + sans-serif-medium + diff --git a/utils/src/main/res/values/google_colors.xml b/utils/src/main/res/values/google_colors.xml new file mode 100644 index 0000000..80a84ef --- /dev/null +++ b/utils/src/main/res/values/google_colors.xml @@ -0,0 +1,34 @@ + + + + + + #00000000 + #ffffff + #000000 + #f8f9fa + #f1f3f4 + #e8eaed + #9aa0a6 + #5f6368 + #202124 + #d2e3fc + #8AB4F8 + #1a73e8 + #174ea6 + + diff --git a/utils/src/main/res/values/strings.xml b/utils/src/main/res/values/strings.xml new file mode 100644 index 0000000..c8f3d00 --- /dev/null +++ b/utils/src/main/res/values/strings.xml @@ -0,0 +1,161 @@ + + + + + + Characters %1$d to %2$d + + Character %1$d + + + untitled + + + copied, %1$s + + capital %1$s + + \u0020%1$d %2$s + + Using %1$s + + + Press key combination to set new shortcut. It must contain at least ALT or Control key. + + Press key combination with %1$s modifier key to set new shortcut. + + Unassigned + + + Shift + + Alt + + Ctrl + + Search + + Arrow Right + + Arrow Left + + Arrow Up + + Arrow Down + + + Default + + Characters + + Words + + Lines + + Paragraphs + + Windows + + Landmarks + + Headings + + Lists + + Links + + Controls + + Special content + + Headings + + Controls + + Links + + + %1$s picture in picture + + %1$s on top, %2$s on bottom + + %1$s on left, %2$s on right + + %1$s on right, %2$s on left + + + + Showing items %1$d to %2$d of %3$d. + + + Showing item %1$d of %2$d. + + + Page %1$d of %2$d + + + %1$d of %2$d + + + %1$s (%2$s) + + + + Exit + + + + Showing %1$s + + keyboard hidden + + + Spoken feedback is on + + Spoken feedback is off + diff --git a/utils/src/main/res/values/strings_symbols.xml b/utils/src/main/res/values/strings_symbols.xml new file mode 100644 index 0000000..b7b3a7f --- /dev/null +++ b/utils/src/main/res/values/strings_symbols.xml @@ -0,0 +1,293 @@ + + + + Apostrophe + + Ampersand + + Less than sign + + Greater than sign + + Asterisk + + At + + Backslash + + Bullet + + Caret + + Cent sign + + Colon + + Comma + + Copyright + + Left curly bracket + + Right curly bracket + + Degree sign + + Division sign + + Dollar sign + + Ellipsis + + Em dash + + En dash + + Euro + + Exclamation mark + + Grave accent + + Dash + + Low double quote + + Multiplication sign + + New line + + Paragraph mark + + Left paren + + Right paren + + Percent + + Period + + Pi + + Pound + + Pound currency sign + + Question mark + + Quote + + Registered trademark + + Semicolon + + Slash + + Space + + Left square bracket + + Right square bracket + + Square root + + Trademark + + Underscore + + Vertical line + + Yen + + Not sign + + Broken bar + + Micro sign + + Almost equal to + + Not equal to + + Currency sign + + Section sign + + Upwards arrow + + Leftwards arrow + + Rupee + + Black Heart + + Tilde + + Equal sign + + Won currency sign + + Reference Mark + + White star + + Black star + + White Heart + + White circle + + Black circle + + Solar symbol + + Bullseye + + White club suit + + White spade suit + + White left pointing index + + White right pointing index + + Circle with left half black + + Circle with right half black + + White square + + Black square + + White up pointing triangle + + White down pointing triangle + + White left pointing triangle + + White right pointing triangle + + White diamond + + Quarter Note + + Eighth Note + + Beamed sixteenth notes + + Female symbol + + Male symbol + + Left Black Lenticular Bracket + + Right Black Lenticular Bracket + + Left Corner Bracket + + Right Corner Bracket + + Rightwards Arrow + + Downwards Arrow + + Plus minus sign + + Liter + + Celsius degree + + Fahrenheit degree + + Approximately equals + + Integral + + Mathematical left angle bracket + + Mathematical right angle bracket + + Postal mark + + Black triangle pointing up + + Black triangle pointing down + + Black suit of diamonds + + Halfwidth Katakana middle dot + + Small black square + + Left double angle bracket + + Right double angle bracket + + Inverted exclamation mark + + Inverted question mark + + Won currency sign + + Full-width comma + + Full-width exclamation mark + + Ideographic full stop + + Full-width question mark + + Middle dot + + Right double quotation mark + + Ideographic comma + + Full-width colon + + Full-width semicolon + + Full-width ampersand + + Full-width circumflex + + Full-width tilde + + Left double quotation mark + + Full-width left parenthesis + + Full-width right parenthesis + + Full-width asterisk + + Full-width underscore + + Right single quotation mark + + Full-width left curly bracket + + Full-width right curly bracket + + Full-width less than sign + + Full-width greater than sign + + Left single quotation mark + + فَتْحَة + + كَسْرَة + + ضَمَّة + + فتحَة تنوين + + كَسْرَة تَنْوِين + + ضَمَّة تَنْوِين + + شَدّة‎ + + سُكُونْ + diff --git a/utils/src/main/res/values/styles.xml b/utils/src/main/res/values/styles.xml new file mode 100644 index 0000000..27a7dd8 --- /dev/null +++ b/utils/src/main/res/values/styles.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + +