diff options
author | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2023-07-07 01:07:29 +0000 |
---|---|---|
committer | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2023-07-07 01:07:29 +0000 |
commit | 4a27c093ca3d30ef78e25c7009698c3da98b1982 (patch) | |
tree | f89956b417badcafc6ed72d30a2507aa4068bb95 | |
parent | 18ec8059d390a77371c1d3aa6effafc4ac2548fb (diff) | |
parent | ac210c97211a51828d906741c22683312f1c1a41 (diff) | |
download | systemui-android14-mainline-tethering-release.tar.gz |
Snap for 10447354 from ac210c97211a51828d906741c22683312f1c1a41 to mainline-tethering-releaseaml_tet_341712060aml_tet_341610020aml_tet_341511010aml_tet_341411060aml_tet_341310230aml_tet_341112070aml_tet_341010040aml_tet_340913030android14-mainline-tethering-release
Change-Id: I8501bf759b0e59e9a663d5564633ce5df14a9af2
67 files changed, 3827 insertions, 419 deletions
diff --git a/animationlib/Android.bp b/animationlib/Android.bp new file mode 100644 index 0000000..d67a5de --- /dev/null +++ b/animationlib/Android.bp @@ -0,0 +1,59 @@ +// 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + + +android_library { + name: "animationlib", + manifest: "AndroidManifest.xml", + sdk_version: "system_current", + min_sdk_version: "26", + static_libs: [ + "androidx.core_core-animation", + "androidx.core_core-ktx", + "androidx.annotation_annotation", + ], + srcs: [ + "src/**/*.java", + "src/**/*.kt" + ], + kotlincflags: ["-Xjvm-default=all"], + // This library is meant to access only public APIs + // do not flip this flag to true + platform_apis: false +} + +android_test { + name: "animationlib_tests", + manifest: "tests/AndroidManifest.xml", + + static_libs: [ + "animationlib", + "androidx.test.ext.junit", + "androidx.test.rules", + "testables", + ], + libs: [ + "android.test.base", + ], + srcs: [ + "**/*.java", + "**/*.kt" + ], + kotlincflags: ["-Xjvm-default=all"], + test_suites: ["general-tests"], +} diff --git a/searchuilib/AndroidManifest.xml b/animationlib/AndroidManifest.xml index 6c6c5f6..b05fb11 100644 --- a/searchuilib/AndroidManifest.xml +++ b/animationlib/AndroidManifest.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <!-- - Copyright (C) 2020 The Android Open Source Project + Copyright (C) 2023 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. @@ -16,5 +16,5 @@ --> <manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.android.app.search"> + package="com.android.app.animation"> </manifest> diff --git a/animationlib/TEST_MAPPING b/animationlib/TEST_MAPPING new file mode 100644 index 0000000..4fd6f09 --- /dev/null +++ b/animationlib/TEST_MAPPING @@ -0,0 +1,7 @@ +{ + "presubmit": [ + { + "name": "animationlib_tests" + } + ] +} diff --git a/animationlib/build.gradle b/animationlib/build.gradle new file mode 100644 index 0000000..f9c4485 --- /dev/null +++ b/animationlib/build.gradle @@ -0,0 +1,46 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace = "com.android.app.animation" + testNamespace = "com.android.app.animation.tests" + defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + sourceSets { + main { + java.srcDirs = ['src'] + manifest.srcFile 'AndroidManifest.xml' + } + androidTest { + java.srcDirs = ["tests"] + } + } + compileSdk 33 + + defaultConfig { + minSdk 33 + targetSdk 33 + } + + lintOptions { + abortOnError false + } + tasks.lint.enabled = false + tasks.withType(JavaCompile) { + options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" + } + kotlinOptions { + jvmTarget = '1.8' + freeCompilerArgs = ["-Xjvm-default=all"] + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0" + implementation "androidx.core:core-animation:1.0.0-alpha02" + implementation "androidx.core:core-ktx:1.9.0" + androidTestImplementation "androidx.test.ext:junit:1.1.3" + androidTestImplementation "androidx.test:rules:1.4.0" +} diff --git a/animationlib/src/com/android/app/animation/Interpolators.java b/animationlib/src/com/android/app/animation/Interpolators.java new file mode 100644 index 0000000..0f3776c --- /dev/null +++ b/animationlib/src/com/android/app/animation/Interpolators.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2023 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.android.app.animation; + +import android.graphics.Path; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.BounceInterpolator; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; +import android.view.animation.LinearInterpolator; +import android.view.animation.PathInterpolator; + +/** + * Utility class to receive interpolators from. + * + * Make sure that changes made to this class are also reflected in {@link InterpolatorsAndroidX}. + * Please consider using the androidx dependencies featuring better testability altogether. + */ +public class Interpolators { + + /* + * ============================================================================================ + * Emphasized interpolators. + * ============================================================================================ + */ + + /** + * The default emphasized interpolator. Used for hero / emphasized movement of content. + */ + public static final Interpolator EMPHASIZED = createEmphasizedInterpolator(); + + /** + * The accelerated emphasized interpolator. Used for hero / emphasized movement of content that + * is disappearing e.g. when moving off screen. + */ + public static final Interpolator EMPHASIZED_ACCELERATE = new PathInterpolator( + 0.3f, 0f, 0.8f, 0.15f); + + /** + * The decelerating emphasized interpolator. Used for hero / emphasized movement of content that + * is appearing e.g. when coming from off screen + */ + public static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator( + 0.05f, 0.7f, 0.1f, 1f); + + + /* + * ============================================================================================ + * Standard interpolators. + * ============================================================================================ + */ + + /** + * The standard interpolator that should be used on every normal animation + */ + public static final Interpolator STANDARD = new PathInterpolator( + 0.2f, 0f, 0f, 1f); + + /** + * The standard accelerating interpolator that should be used on every regular movement of + * content that is disappearing e.g. when moving off screen. + */ + public static final Interpolator STANDARD_ACCELERATE = new PathInterpolator( + 0.3f, 0f, 1f, 1f); + + /** + * The standard decelerating interpolator that should be used on every regular movement of + * content that is appearing e.g. when coming from off screen. + */ + public static final Interpolator STANDARD_DECELERATE = new PathInterpolator( + 0f, 0f, 0f, 1f); + + /* + * ============================================================================================ + * Legacy + * ============================================================================================ + */ + + /** + * The default legacy interpolator as defined in Material 1. Also known as FAST_OUT_SLOW_IN. + */ + public static final Interpolator LEGACY = new PathInterpolator(0.4f, 0f, 0.2f, 1f); + + /** + * The default legacy accelerating interpolator as defined in Material 1. + * Also known as FAST_OUT_LINEAR_IN. + */ + public static final Interpolator LEGACY_ACCELERATE = new PathInterpolator(0.4f, 0f, 1f, 1f); + + /** + * The default legacy decelerating interpolator as defined in Material 1. + * Also known as LINEAR_OUT_SLOW_IN. + */ + public static final Interpolator LEGACY_DECELERATE = new PathInterpolator(0f, 0f, 0.2f, 1f); + + /** + * Linear interpolator. Often used if the interpolator is for different properties who need + * different interpolations. + */ + public static final Interpolator LINEAR = new LinearInterpolator(); + + /* + * ============================================================================================ + * Custom interpolators + * ============================================================================================ + */ + + public static final Interpolator FAST_OUT_SLOW_IN = LEGACY; + public static final Interpolator FAST_OUT_LINEAR_IN = LEGACY_ACCELERATE; + public static final Interpolator LINEAR_OUT_SLOW_IN = LEGACY_DECELERATE; + + /** + * Like {@link #FAST_OUT_SLOW_IN}, but used in case the animation is played in reverse (i.e. t + * goes from 1 to 0 instead of 0 to 1). + */ + public static final Interpolator FAST_OUT_SLOW_IN_REVERSE = + new PathInterpolator(0.8f, 0f, 0.6f, 1f); + public static final Interpolator SLOW_OUT_LINEAR_IN = new PathInterpolator(0.8f, 0f, 1f, 1f); + public static final Interpolator ALPHA_IN = new PathInterpolator(0.4f, 0f, 1f, 1f); + public static final Interpolator ALPHA_OUT = new PathInterpolator(0f, 0f, 0.8f, 1f); + public static final Interpolator ACCELERATE = new AccelerateInterpolator(); + public static final Interpolator ACCELERATE_DECELERATE = new AccelerateDecelerateInterpolator(); + public static final Interpolator DECELERATE_QUINT = new DecelerateInterpolator(2.5f); + public static final Interpolator CUSTOM_40_40 = new PathInterpolator(0.4f, 0f, 0.6f, 1f); + public static final Interpolator ICON_OVERSHOT = new PathInterpolator(0.4f, 0f, 0.2f, 1.4f); + public static final Interpolator ICON_OVERSHOT_LESS = new PathInterpolator(0.4f, 0f, 0.2f, + 1.1f); + public static final Interpolator PANEL_CLOSE_ACCELERATED = new PathInterpolator(0.3f, 0, 0.5f, + 1); + public static final Interpolator BOUNCE = new BounceInterpolator(); + /** + * For state transitions on the control panel that lives in GlobalActions. + */ + public static final Interpolator CONTROL_STATE = new PathInterpolator(0.4f, 0f, 0.2f, + 1.0f); + + /** + * Interpolator to be used when animating a move based on a click. Pair with enough duration. + */ + public static final Interpolator TOUCH_RESPONSE = + new PathInterpolator(0.3f, 0f, 0.1f, 1f); + + /** + * Like {@link #TOUCH_RESPONSE}, but used in case the animation is played in reverse (i.e. t + * goes from 1 to 0 instead of 0 to 1). + */ + public static final Interpolator TOUCH_RESPONSE_REVERSE = + new PathInterpolator(0.9f, 0f, 0.7f, 1f); + + /* + * ============================================================================================ + * Functions / Utilities + * ============================================================================================ + */ + + /** + * Calculate the amount of overshoot using an exponential falloff function with desired + * properties, where the overshoot smoothly transitions at the 1.0f boundary into the + * overshoot, retaining its acceleration. + * + * @param progress a progress value going from 0 to 1 + * @param overshootAmount the amount > 0 of overshoot desired. A value of 0.1 means the max + * value of the overall progress will be at 1.1. + * @param overshootStart the point in (0,1] where the result should reach 1 + * @return the interpolated overshoot + */ + public static float getOvershootInterpolation(float progress, float overshootAmount, + float overshootStart) { + if (overshootAmount == 0.0f || overshootStart == 0.0f) { + throw new IllegalArgumentException("Invalid values for overshoot"); + } + float b = MathUtils.log((overshootAmount + 1) / (overshootAmount)) / overshootStart; + return MathUtils.max(0.0f, + (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f)); + } + + /** + * Similar to {@link #getOvershootInterpolation(float, float, float)} but the overshoot + * starts immediately here, instead of first having a section of non-overshooting + * + * @param progress a progress value going from 0 to 1 + */ + public static float getOvershootInterpolation(float progress) { + return MathUtils.max(0.0f, (float) (1.0f - Math.exp(-4 * progress))); + } + + // Create the default emphasized interpolator + private static PathInterpolator createEmphasizedInterpolator() { + Path path = new Path(); + // Doing the same as fast_out_extra_slow_in + path.moveTo(0f, 0f); + path.cubicTo(0.05f, 0f, 0.133333f, 0.06f, 0.166666f, 0.4f); + path.cubicTo(0.208333f, 0.82f, 0.25f, 1f, 1f, 1f); + return new PathInterpolator(path); + } +}
\ No newline at end of file diff --git a/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java b/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java new file mode 100644 index 0000000..7142f54 --- /dev/null +++ b/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2023 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.android.app.animation; + +import android.graphics.Path; + +import androidx.core.animation.AccelerateDecelerateInterpolator; +import androidx.core.animation.AccelerateInterpolator; +import androidx.core.animation.BounceInterpolator; +import androidx.core.animation.DecelerateInterpolator; +import androidx.core.animation.Interpolator; +import androidx.core.animation.LinearInterpolator; +import androidx.core.animation.PathInterpolator; + +/** + * Utility class to receive interpolators from. (androidx compatible version) + * + * This is the androidx compatible version of {@link Interpolators}. Make sure that changes made to + * this class are also reflected in {@link Interpolators}. + * + * Using the androidx versions of {@link androidx.core.animation.ValueAnimator} or + * {@link androidx.core.animation.ObjectAnimator} improves animation testability. This file provides + * the androidx compatible versions of the interpolators defined in {@link Interpolators}. + * AnimatorTestRule can be used in Tests to manipulate the animation under test (e.g. artificially + * advancing the time). + */ +public class InterpolatorsAndroidX { + + /* + * ============================================================================================ + * Emphasized interpolators. + * ============================================================================================ + */ + + /** + * The default emphasized interpolator. Used for hero / emphasized movement of content. + */ + public static final Interpolator EMPHASIZED = createEmphasizedInterpolator(); + + /** + * The accelerated emphasized interpolator. Used for hero / emphasized movement of content that + * is disappearing e.g. when moving off screen. + */ + public static final Interpolator EMPHASIZED_ACCELERATE = new PathInterpolator( + 0.3f, 0f, 0.8f, 0.15f); + + /** + * The decelerating emphasized interpolator. Used for hero / emphasized movement of content that + * is appearing e.g. when coming from off screen + */ + public static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator( + 0.05f, 0.7f, 0.1f, 1f); + + + /* + * ============================================================================================ + * Standard interpolators. + * ============================================================================================ + */ + + /** + * The standard interpolator that should be used on every normal animation + */ + public static final Interpolator STANDARD = new PathInterpolator( + 0.2f, 0f, 0f, 1f); + + /** + * The standard accelerating interpolator that should be used on every regular movement of + * content that is disappearing e.g. when moving off screen. + */ + public static final Interpolator STANDARD_ACCELERATE = new PathInterpolator( + 0.3f, 0f, 1f, 1f); + + /** + * The standard decelerating interpolator that should be used on every regular movement of + * content that is appearing e.g. when coming from off screen. + */ + public static final Interpolator STANDARD_DECELERATE = new PathInterpolator( + 0f, 0f, 0f, 1f); + + /* + * ============================================================================================ + * Legacy + * ============================================================================================ + */ + + /** + * The default legacy interpolator as defined in Material 1. Also known as FAST_OUT_SLOW_IN. + */ + public static final Interpolator LEGACY = new PathInterpolator(0.4f, 0f, 0.2f, 1f); + + /** + * The default legacy accelerating interpolator as defined in Material 1. + * Also known as FAST_OUT_LINEAR_IN. + */ + public static final Interpolator LEGACY_ACCELERATE = new PathInterpolator(0.4f, 0f, 1f, 1f); + + /** + * The default legacy decelerating interpolator as defined in Material 1. + * Also known as LINEAR_OUT_SLOW_IN. + */ + public static final Interpolator LEGACY_DECELERATE = new PathInterpolator(0f, 0f, 0.2f, 1f); + + /** + * Linear interpolator. Often used if the interpolator is for different properties who need + * different interpolations. + */ + public static final Interpolator LINEAR = new LinearInterpolator(); + + /* + * ============================================================================================ + * Custom interpolators + * ============================================================================================ + */ + + public static final Interpolator FAST_OUT_SLOW_IN = LEGACY; + public static final Interpolator FAST_OUT_LINEAR_IN = LEGACY_ACCELERATE; + public static final Interpolator LINEAR_OUT_SLOW_IN = LEGACY_DECELERATE; + + /** + * Like {@link #FAST_OUT_SLOW_IN}, but used in case the animation is played in reverse (i.e. t + * goes from 1 to 0 instead of 0 to 1). + */ + public static final Interpolator FAST_OUT_SLOW_IN_REVERSE = + new PathInterpolator(0.8f, 0f, 0.6f, 1f); + public static final Interpolator SLOW_OUT_LINEAR_IN = new PathInterpolator(0.8f, 0f, 1f, 1f); + public static final Interpolator ALPHA_IN = new PathInterpolator(0.4f, 0f, 1f, 1f); + public static final Interpolator ALPHA_OUT = new PathInterpolator(0f, 0f, 0.8f, 1f); + public static final Interpolator ACCELERATE = new AccelerateInterpolator(); + public static final Interpolator ACCELERATE_DECELERATE = new AccelerateDecelerateInterpolator(); + public static final Interpolator DECELERATE_QUINT = new DecelerateInterpolator(2.5f); + public static final Interpolator CUSTOM_40_40 = new PathInterpolator(0.4f, 0f, 0.6f, 1f); + public static final Interpolator ICON_OVERSHOT = new PathInterpolator(0.4f, 0f, 0.2f, 1.4f); + public static final Interpolator ICON_OVERSHOT_LESS = new PathInterpolator(0.4f, 0f, 0.2f, + 1.1f); + public static final Interpolator PANEL_CLOSE_ACCELERATED = new PathInterpolator(0.3f, 0, 0.5f, + 1); + public static final Interpolator BOUNCE = new BounceInterpolator(); + /** + * For state transitions on the control panel that lives in GlobalActions. + */ + public static final Interpolator CONTROL_STATE = new PathInterpolator(0.4f, 0f, 0.2f, + 1.0f); + + /** + * Interpolator to be used when animating a move based on a click. Pair with enough duration. + */ + public static final Interpolator TOUCH_RESPONSE = + new PathInterpolator(0.3f, 0f, 0.1f, 1f); + + /** + * Like {@link #TOUCH_RESPONSE}, but used in case the animation is played in reverse (i.e. t + * goes from 1 to 0 instead of 0 to 1). + */ + public static final Interpolator TOUCH_RESPONSE_REVERSE = + new PathInterpolator(0.9f, 0f, 0.7f, 1f); + + /* + * ============================================================================================ + * Functions / Utilities + * ============================================================================================ + */ + + /** + * Calculate the amount of overshoot using an exponential falloff function with desired + * properties, where the overshoot smoothly transitions at the 1.0f boundary into the + * overshoot, retaining its acceleration. + * + * @param progress a progress value going from 0 to 1 + * @param overshootAmount the amount > 0 of overshoot desired. A value of 0.1 means the max + * value of the overall progress will be at 1.1. + * @param overshootStart the point in (0,1] where the result should reach 1 + * @return the interpolated overshoot + */ + public static float getOvershootInterpolation(float progress, float overshootAmount, + float overshootStart) { + if (overshootAmount == 0.0f || overshootStart == 0.0f) { + throw new IllegalArgumentException("Invalid values for overshoot"); + } + float b = MathUtils.log((overshootAmount + 1) / (overshootAmount)) / overshootStart; + return MathUtils.max(0.0f, + (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f)); + } + + /** + * Similar to {@link #getOvershootInterpolation(float, float, float)} but the overshoot + * starts immediately here, instead of first having a section of non-overshooting + * + * @param progress a progress value going from 0 to 1 + */ + public static float getOvershootInterpolation(float progress) { + return MathUtils.max(0.0f, (float) (1.0f - Math.exp(-4 * progress))); + } + + // Create the default emphasized interpolator + private static PathInterpolator createEmphasizedInterpolator() { + Path path = new Path(); + // Doing the same as fast_out_extra_slow_in + path.moveTo(0f, 0f); + path.cubicTo(0.05f, 0f, 0.133333f, 0.06f, 0.166666f, 0.4f); + path.cubicTo(0.208333f, 0.82f, 0.25f, 1f, 1f, 1f); + return new PathInterpolator(path); + } +}
\ No newline at end of file diff --git a/animationlib/src/com/android/app/animation/MathUtils.java b/animationlib/src/com/android/app/animation/MathUtils.java new file mode 100644 index 0000000..d0a34c8 --- /dev/null +++ b/animationlib/src/com/android/app/animation/MathUtils.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2023 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.android.app.animation; + +public final class MathUtils { + public static float log(float a) { + return (float) Math.log(a); + } + + public static float max(float a, float b) { + return a > b ? a : b; + } +} diff --git a/animationlib/tests/AndroidManifest.xml b/animationlib/tests/AndroidManifest.xml new file mode 100644 index 0000000..77a5990 --- /dev/null +++ b/animationlib/tests/AndroidManifest.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 2023 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. + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.app.animation.tests"> + + <instrumentation + android:name="android.testing.TestableInstrumentation" + android:label="Tests for public Animation Lib" + android:targetPackage="com.android.app.animation.tests"/> + +</manifest> diff --git a/animationlib/tests/com/android/app/animation/InterpolatorsAndroidXTest.kt b/animationlib/tests/com/android/app/animation/InterpolatorsAndroidXTest.kt new file mode 100644 index 0000000..841e141 --- /dev/null +++ b/animationlib/tests/com/android/app/animation/InterpolatorsAndroidXTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2023 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.android.app.animation + +import androidx.test.filters.SmallTest +import java.lang.reflect.Modifier +import junit.framework.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@SmallTest +@RunWith(JUnit4::class) +class InterpolatorsAndroidXTest { + + @Test + fun testInterpolatorsAndInterpolatorsAndroidXPublicMethodsAreEqual() { + assertEquals( + Interpolators::class.java.getPublicMethods(), + InterpolatorsAndroidX::class.java.getPublicMethods() + ) + } + + @Test + fun testInterpolatorsAndInterpolatorsAndroidXPublicFieldsAreEqual() { + assertEquals( + Interpolators::class.java.getPublicFields(), + InterpolatorsAndroidX::class.java.getPublicFields() + ) + } + + private fun <T> Class<T>.getPublicMethods() = + declaredMethods + .filter { Modifier.isPublic(it.modifiers) } + .map { it.toString().replace(name, "") } + .toSet() + + private fun <T> Class<T>.getPublicFields() = + fields.filter { Modifier.isPublic(it.modifiers) }.map { it.name }.toSet() +} diff --git a/iconloaderlib/Android.bp b/iconloaderlib/Android.bp index 6867e6b..083091d 100644 --- a/iconloaderlib/Android.bp +++ b/iconloaderlib/Android.bp @@ -45,4 +45,8 @@ android_library { "src/**/*.java", "src_full_lib/**/*.java", ], + apex_available: [ + "//apex_available:platform", + "com.android.permission", + ], } diff --git a/iconloaderlib/build.gradle b/iconloaderlib/build.gradle index 10ec889..344ac20 100644 --- a/iconloaderlib/build.gradle +++ b/iconloaderlib/build.gradle @@ -1,14 +1,9 @@ -apply plugin: 'com.android.library' +plugins { + id 'com.android.library' +} android { - compileSdkVersion COMPILE_SDK - buildToolsVersion BUILD_TOOLS_VERSION - - defaultConfig { - minSdkVersion 26 - targetSdkVersion 28 - } - + namespace = "com.android.launcher3.icons" sourceSets { main { java.srcDirs = ['src', 'src_full_lib'] @@ -16,21 +11,15 @@ android { res.srcDirs = ['res'] } } - - lintOptions { + lint { abortOnError false } tasks.withType(JavaCompile) { options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } } dependencies { - implementation "androidx.core:core:${ANDROID_X_VERSION}" + implementation "androidx.core:core" } diff --git a/iconloaderlib/res/drawable/ic_clone_app_badge.xml b/iconloaderlib/res/drawable/ic_clone_app_badge.xml new file mode 100644 index 0000000..9f0876d --- /dev/null +++ b/iconloaderlib/res/drawable/ic_clone_app_badge.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2023 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. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="@dimen/profile_badge_size" + android:height="@dimen/profile_badge_size" + android:viewportWidth="24" + android:viewportHeight="24"> + + <path + android:fillColor="#11000000" + android:pathData="M.5,12.25 + A11.5,11.5 0 1,1 23.5,12.25 + A11.5,11.5 0 1,1 .5,12.25" /> + + <path + android:fillColor="@android:color/white" + android:pathData="M1,12 + A11,11 0 1,1 23,12 + A11,11 0 1,1 1,12" /> + + <group android:scaleX=".6" android:scaleY=".6" android:pivotX="12" android:pivotY="12"> + <path + android:pathData="M22,9.5C22,13.642 18.642,17 14.5,17C10.358,17 7,13.642 7,9.5C7,5.358 10.358,2 14.5,2C18.642,2 22,5.358 22,9.5Z" + android:fillColor="#ff3C4043"/> + <path + android:pathData="M9.5,20.333C12.722,20.333 15.333,17.722 15.333,14.5C15.333,11.278 12.722,8.667 9.5,8.667C6.278,8.667 3.667,11.278 3.667,14.5C3.667,17.722 6.278,20.333 9.5,20.333ZM9.5,22C13.642,22 17,18.642 17,14.5C17,10.358 13.642,7 9.5,7C5.358,7 2,10.358 2,14.5C2,18.642 5.358,22 9.5,22Z" + android:fillColor="#ff3C4043" + android:fillType="evenOdd"/> + </group> +</vector> diff --git a/iconloaderlib/res/drawable/ic_clone_app_badge_themed.xml b/iconloaderlib/res/drawable/ic_clone_app_badge_themed.xml new file mode 100644 index 0000000..3a59e3d --- /dev/null +++ b/iconloaderlib/res/drawable/ic_clone_app_badge_themed.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2023 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. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="@dimen/profile_badge_size" + android:height="@dimen/profile_badge_size" + android:viewportWidth="24" + android:viewportHeight="24"> + + <path + android:fillColor="#11000000" + android:pathData="M.5,12.25 + A11.5,11.5 0 1,1 23.5,12.25 + A11.5,11.5 0 1,1 .5,12.25" /> + + <path + android:fillColor="@color/themed_icon_background_color" + android:pathData="M1,12 + A11,11 0 1,1 23,12 + A11,11 0 1,1 1,12" /> + + <group android:scaleX=".6" android:scaleY=".6" android:pivotX="12" android:pivotY="12"> + <path + android:pathData="M22,9.5C22,13.642 18.642,17 14.5,17C10.358,17 7,13.642 7,9.5C7,5.358 10.358,2 14.5,2C18.642,2 22,5.358 22,9.5Z" + android:fillColor="@color/themed_badge_icon_color"/> + <path + android:pathData="M9.5,20.333C12.722,20.333 15.333,17.722 15.333,14.5C15.333,11.278 12.722,8.667 9.5,8.667C6.278,8.667 3.667,11.278 3.667,14.5C3.667,17.722 6.278,20.333 9.5,20.333ZM9.5,22C13.642,22 17,18.642 17,14.5C17,10.358 13.642,7 9.5,7C5.358,7 2,10.358 2,14.5C2,18.642 5.358,22 9.5,22Z" + android:fillColor="@color/themed_badge_icon_color" + android:fillType="evenOdd"/> + </group> +</vector> diff --git a/iconloaderlib/res/drawable/ic_instant_app_badge.xml b/iconloaderlib/res/drawable/ic_instant_app_badge.xml index b74317e..e6b5701 100644 --- a/iconloaderlib/res/drawable/ic_instant_app_badge.xml +++ b/iconloaderlib/res/drawable/ic_instant_app_badge.xml @@ -20,20 +20,11 @@ android:viewportHeight="18"> <path - android:fillColor="@android:color/black" - android:strokeWidth="1" - android:pathData="M 9 0 C 13.9705627485 0 18 4.02943725152 18 9 C 18 13.9705627485 13.9705627485 18 9 18 C 4.02943725152 18 0 13.9705627485 0 9 C 0 4.02943725152 4.02943725152 0 9 0 Z" /> - <path - android:fillColor="@android:color/white" - android:strokeWidth="1" - android:pathData="M 9 0 C 13.9705627485 0 18 4.02943725152 18 9 C 18 13.9705627485 13.9705627485 18 9 18 C 4.02943725152 18 0 13.9705627485 0 9 C 0 4.02943725152 4.02943725152 0 9 0 Z" /> - <path android:fillColor="@android:color/white" android:strokeWidth="1" android:pathData="M 9 0 C 13.9705627485 0 18 4.02943725152 18 9 C 18 13.9705627485 13.9705627485 18 9 18 C 4.02943725152 18 0 13.9705627485 0 9 C 0 4.02943725152 4.02943725152 0 9 0 Z" /> <path android:fillColor="@android:color/black" - android:fillAlpha="0.87" android:strokeWidth="1" android:pathData="M 6 10.4123279 L 8.63934949 10.4123279 L 8.63934949 15.6 L 12.5577168 7.84517705 L 9.94547194 7.84517705 L 9.94547194 2 Z" /> </vector> diff --git a/iconloaderlib/res/drawable/ic_instant_app_badge_themed.xml b/iconloaderlib/res/drawable/ic_instant_app_badge_themed.xml new file mode 100644 index 0000000..6e19339 --- /dev/null +++ b/iconloaderlib/res/drawable/ic_instant_app_badge_themed.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="@dimen/profile_badge_size" + android:height="@dimen/profile_badge_size" + android:viewportWidth="18" + android:viewportHeight="18"> + + <path + android:fillColor="@color/themed_badge_icon_background_color" + android:strokeWidth="1" + android:pathData="M 9 0 C 13.9705627485 0 18 4.02943725152 18 9 C 18 13.9705627485 13.9705627485 18 9 18 C 4.02943725152 18 0 13.9705627485 0 9 C 0 4.02943725152 4.02943725152 0 9 0 Z" /> + <path + android:fillColor="@color/themed_badge_icon_color" + android:strokeWidth="1" + android:pathData="M 6 10.4123279 L 8.63934949 10.4123279 L 8.63934949 15.6 L 12.5577168 7.84517705 L 9.94547194 7.84517705 L 9.94547194 2 Z" /> +</vector> diff --git a/iconloaderlib/res/drawable/ic_work_app_badge_themed.xml b/iconloaderlib/res/drawable/ic_work_app_badge_themed.xml new file mode 100644 index 0000000..6866d2f --- /dev/null +++ b/iconloaderlib/res/drawable/ic_work_app_badge_themed.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2023 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. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="@dimen/profile_badge_size" + android:height="@dimen/profile_badge_size" + android:viewportWidth="24" + android:viewportHeight="24"> + + <path + android:fillColor="#11000000" + android:pathData="M.5,12.25 + A11.5,11.5 0 1,1 23.5,12.25 + A11.5,11.5 0 1,1 .5,12.25" /> + + <path + android:fillColor="@color/themed_badge_icon_background_color" + android:pathData="M1,12 + A11,11 0 1,1 23,12 + A11,11 0 1,1 1,12" /> + + <group android:scaleX=".6" android:scaleY=".6" android:pivotX="12" android:pivotY="12"> + <path + android:fillColor="@color/themed_badge_icon_color" + android:pathData="M20,6h-4L16,4c0,-1.11 -0.89,-2 -2,-2h-4c-1.11,0 -2,0.89 -2,2v2L4,6c-1.11,0 -1.99,0.89 -1.99,2L2,19c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2L22,8c0,-1.11 -0.89,-2 -2,-2zM14,6h-4L10,4h4v2z" /> + </group> +</vector> diff --git a/iconloaderlib/res/values-night-v31/colors.xml b/iconloaderlib/res/values-night-v31/colors.xml new file mode 100644 index 0000000..e5ebda6 --- /dev/null +++ b/iconloaderlib/res/values-night-v31/colors.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +** +** Copyright 2023, 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. +*/ +--> +<resources> + <color name="themed_icon_color">@android:color/system_accent1_200</color> + <color name="themed_icon_background_color">@android:color/system_accent2_800</color> + <color name="themed_badge_icon_color">@android:color/system_accent2_800</color> + <color name="themed_badge_icon_background_color">@android:color/system_accent1_200</color> +</resources> diff --git a/iconloaderlib/res/values-night/colors.xml b/iconloaderlib/res/values-night/colors.xml new file mode 100644 index 0000000..9de7074 --- /dev/null +++ b/iconloaderlib/res/values-night/colors.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +** +** Copyright 2023, 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. +*/ +--> +<resources> + <color name="themed_icon_color">#A8C7FA</color> + <color name="themed_icon_background_color">#003355</color> + <color name="themed_badge_icon_color">#003355</color> + <color name="themed_badge_icon_background_color">#A8C7FA</color> +</resources> diff --git a/iconloaderlib/res/values-v31/colors.xml b/iconloaderlib/res/values-v31/colors.xml new file mode 100644 index 0000000..1405ad0 --- /dev/null +++ b/iconloaderlib/res/values-v31/colors.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +** +** Copyright 2023, 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. +*/ +--> +<resources> + <color name="themed_icon_color">@android:color/system_accent1_700</color> + <color name="themed_icon_background_color">@android:color/system_accent1_100</color> + <color name="themed_badge_icon_color">@android:color/system_accent1_700</color> + <color name="themed_badge_icon_background_color">@android:color/system_accent1_100</color> +</resources> diff --git a/iconloaderlib/res/values/colors.xml b/iconloaderlib/res/values/colors.xml index 70582c2..8eeafb4 100644 --- a/iconloaderlib/res/values/colors.xml +++ b/iconloaderlib/res/values/colors.xml @@ -17,6 +17,10 @@ */ --> <resources> + <color name="themed_icon_color">#0842A0</color> + <color name="themed_icon_background_color">#D3E3FD</color> + <color name="themed_badge_icon_color">#0842A0</color> + <color name="themed_badge_icon_background_color">#D3E3FD</color> <color name="legacy_icon_background">#FFFFFF</color> <!-- Yellow 600, used for highlighting "important" conversations in settings & notifications --> diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java index c0be55d..704df6f 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java @@ -1,13 +1,17 @@ package com.android.launcher3.icons; +import static android.graphics.Paint.ANTI_ALIAS_FLAG; import static android.graphics.Paint.DITHER_FLAG; import static android.graphics.Paint.FILTER_BITMAP_FLAG; import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction; +import static com.android.launcher3.icons.BitmapInfo.FLAG_CLONE; import static com.android.launcher3.icons.BitmapInfo.FLAG_INSTANT; import static com.android.launcher3.icons.BitmapInfo.FLAG_WORK; import static com.android.launcher3.icons.ShadowGenerator.BLUR_FACTOR; +import static java.lang.annotation.RetentionPolicy.SOURCE; + import android.annotation.TargetApi; import android.content.Context; import android.content.Intent; @@ -30,12 +34,17 @@ import android.os.Build; import android.os.UserHandle; import android.util.SparseBooleanArray; +import androidx.annotation.ColorInt; +import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.launcher3.icons.BitmapInfo.Extender; import com.android.launcher3.util.FlagOp; +import java.lang.annotation.Retention; +import java.util.Objects; + /** * This class will be moved to androidx library. There shouldn't be any dependency outside * this package. @@ -44,23 +53,47 @@ public class BaseIconFactory implements AutoCloseable { private static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; + public static final int MODE_DEFAULT = 0; + public static final int MODE_ALPHA = 1; + public static final int MODE_WITH_SHADOW = 2; + public static final int MODE_HARDWARE = 3; + public static final int MODE_HARDWARE_WITH_SHADOW = 4; + + @Retention(SOURCE) + @IntDef({MODE_DEFAULT, MODE_ALPHA, MODE_WITH_SHADOW, MODE_HARDWARE_WITH_SHADOW, MODE_HARDWARE}) + @interface BitmapGenerationMode {} + private static final float ICON_BADGE_SCALE = 0.444f; + @NonNull private final Rect mOldBounds = new Rect(); + + @NonNull private final SparseBooleanArray mIsUserBadged = new SparseBooleanArray(); + + @NonNull protected final Context mContext; + + @NonNull private final Canvas mCanvas; + + @NonNull private final PackageManager mPm; + + @NonNull private final ColorExtractor mColorExtractor; - private boolean mDisableColorExtractor; protected final int mFillResIconDpi; protected final int mIconBitmapSize; protected boolean mMonoIconEnabled; + @Nullable private IconNormalizer mNormalizer; + + @Nullable private ShadowGenerator mShadowGenerator; + private final boolean mShapeDetection; // Shadow bitmap used as background for theme icons @@ -69,8 +102,6 @@ public class BaseIconFactory implements AutoCloseable { private Drawable mWrapperIcon; private int mWrapperBackgroundColor = DEFAULT_WRAPPER_BACKGROUND; - private final Paint mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); - private static final float PLACEHOLDER_TEXT_SIZE = 20f; private static int PLACEHOLDER_BACKGROUND_COLOR = Color.rgb(245, 245, 245); protected BaseIconFactory(Context context, int fillResIconDpi, int iconBitmapSize, @@ -85,10 +116,6 @@ public class BaseIconFactory implements AutoCloseable { mCanvas = new Canvas(); mCanvas.setDrawFilter(new PaintFlagsDrawFilter(DITHER_FLAG, FILTER_BITMAP_FLAG)); - mTextPaint.setTextAlign(Paint.Align.CENTER); - mTextPaint.setColor(PLACEHOLDER_BACKGROUND_COLOR); - mTextPaint.setTextSize(context.getResources().getDisplayMetrics().density * - PLACEHOLDER_TEXT_SIZE); clear(); } @@ -98,9 +125,9 @@ public class BaseIconFactory implements AutoCloseable { protected void clear() { mWrapperBackgroundColor = DEFAULT_WRAPPER_BACKGROUND; - mDisableColorExtractor = false; } + @NonNull public ShadowGenerator getShadowGenerator() { if (mShadowGenerator == null) { mShadowGenerator = new ShadowGenerator(mIconBitmapSize); @@ -108,6 +135,7 @@ public class BaseIconFactory implements AutoCloseable { return mShadowGenerator; } + @NonNull public IconNormalizer getNormalizer() { if (mNormalizer == null) { mNormalizer = new IconNormalizer(mContext, mIconBitmapSize, mShapeDetection); @@ -138,16 +166,11 @@ public class BaseIconFactory implements AutoCloseable { * @return */ public BitmapInfo createIconBitmap(String placeholder, int color) { - Bitmap placeholderBitmap = Bitmap.createBitmap(mIconBitmapSize, mIconBitmapSize, - Bitmap.Config.ARGB_8888); - mTextPaint.setColor(color); - Canvas canvas = new Canvas(placeholderBitmap); - canvas.drawText(placeholder, mIconBitmapSize / 2, mIconBitmapSize * 5 / 8, mTextPaint); AdaptiveIconDrawable drawable = new AdaptiveIconDrawable( new ColorDrawable(PLACEHOLDER_BACKGROUND_COLOR), - new BitmapDrawable(mContext.getResources(), placeholderBitmap)); + new CenterTextDrawable(placeholder, color)); Bitmap icon = createIconBitmap(drawable, IconNormalizer.ICON_VISIBLE_AREA_FACTOR); - return BitmapInfo.of(icon, extractColor(icon)); + return BitmapInfo.of(icon, color); } public BitmapInfo createIconBitmap(Bitmap icon) { @@ -155,12 +178,13 @@ public class BaseIconFactory implements AutoCloseable { icon = createIconBitmap(new BitmapDrawable(mContext.getResources(), icon), 1f); } - return BitmapInfo.of(icon, extractColor(icon)); + return BitmapInfo.of(icon, mColorExtractor.findDominantColorByHue(icon)); } /** * Creates an icon from the bitmap cropped to the current device icon shape */ + @NonNull public BitmapInfo createShapedIconBitmap(Bitmap icon, IconOptions options) { Drawable d = new FixedSizeBitmapDrawable(icon); float inset = getExtraInsetFraction(); @@ -170,6 +194,7 @@ public class BaseIconFactory implements AutoCloseable { return createBadgedIconBitmap(d, options); } + @NonNull public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon) { return createBadgedIconBitmap(icon, null); } @@ -182,38 +207,47 @@ public class BaseIconFactory implements AutoCloseable { * @return a bitmap suitable for disaplaying as an icon at various system UIs. */ @TargetApi(Build.VERSION_CODES.TIRAMISU) + @NonNull public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon, @Nullable IconOptions options) { boolean shrinkNonAdaptiveIcons = options == null || options.mShrinkNonAdaptiveIcons; float[] scale = new float[1]; icon = normalizeAndWrapToAdaptiveIcon(icon, shrinkNonAdaptiveIcons, null, scale); - Bitmap bitmap = createIconBitmap(icon, scale[0]); - if (icon instanceof AdaptiveIconDrawable) { - mCanvas.setBitmap(bitmap); - getShadowGenerator().recreateIcon(Bitmap.createBitmap(bitmap), mCanvas); - mCanvas.setBitmap(null); - } + Bitmap bitmap = createIconBitmap(icon, scale[0], + options == null ? MODE_WITH_SHADOW : options.mGenerationMode); - int color = extractColor(bitmap); + int color = (options != null && options.mExtractedColor != null) + ? options.mExtractedColor : mColorExtractor.findDominantColorByHue(bitmap); BitmapInfo info = BitmapInfo.of(bitmap, color); if (icon instanceof BitmapInfo.Extender) { info = ((BitmapInfo.Extender) icon).getExtendedInfo(bitmap, color, this, scale[0]); - } else if (mMonoIconEnabled && IconProvider.ATLEAST_T - && icon instanceof AdaptiveIconDrawable) { - Drawable mono = ((AdaptiveIconDrawable) icon).getMonochrome(); + } else if (IconProvider.ATLEAST_T && mMonoIconEnabled) { + Drawable mono = getMonochromeDrawable(icon); if (mono != null) { - // Convert mono drawable to bitmap - Drawable paddedMono = new ClippedMonoDrawable(mono); - info.setMonoIcon( - createIconBitmap(paddedMono, scale[0], mIconBitmapSize, Config.ALPHA_8), - this); + info.setMonoIcon(createIconBitmap(mono, scale[0], MODE_ALPHA), this); } } info = info.withFlags(getBitmapFlagOp(options)); return info; } + /** + * Returns a monochromatic version of the given drawable or null, if it is not supported + * @param base the original icon + */ + @TargetApi(Build.VERSION_CODES.TIRAMISU) + protected Drawable getMonochromeDrawable(Drawable base) { + if (base instanceof AdaptiveIconDrawable) { + Drawable mono = ((AdaptiveIconDrawable) base).getMonochrome(); + if (mono != null) { + return new ClippedMonoDrawable(mono); + } + } + return null; + } + + @NonNull public FlagOp getBitmapFlagOp(@Nullable IconOptions options) { FlagOp op = FlagOp.NO_OP; if (options != null) { @@ -233,55 +267,45 @@ public class BaseIconFactory implements AutoCloseable { isBadged = (d != mPm.getUserBadgedIcon(d, options.mUserHandle)); mIsUserBadged.put(key, isBadged); } - op = op.setFlag(FLAG_WORK, isBadged); + // Set the clone profile badge flag in case it is present. + op = op.setFlag(FLAG_CLONE, isBadged && options.mIsCloneProfile); + // Set the Work profile badge for all other cases. + op = op.setFlag(FLAG_WORK, isBadged && !options.mIsCloneProfile); } } return op; } - /** package private */ - Bitmap getWhiteShadowLayer() { + @NonNull + public Bitmap getWhiteShadowLayer() { if (mWhiteShadowLayer == null) { - mWhiteShadowLayer = createScaledBitmapWithShadow( - new AdaptiveIconDrawable(new ColorDrawable(Color.WHITE), null)); + mWhiteShadowLayer = createScaledBitmap( + new AdaptiveIconDrawable(new ColorDrawable(Color.WHITE), null), + MODE_HARDWARE_WITH_SHADOW); } return mWhiteShadowLayer; } - /** package private */ - public Bitmap createScaledBitmapWithShadow(Drawable d) { - float scale = getNormalizer().getScale(d, null, null, null); - Bitmap bitmap = createIconBitmap(d, scale); - mCanvas.setBitmap(bitmap); - getShadowGenerator().recreateIcon(Bitmap.createBitmap(bitmap), mCanvas); - mCanvas.setBitmap(null); - return bitmap; - } - - public Bitmap createScaledBitmapWithoutShadow(Drawable icon) { + @NonNull + public Bitmap createScaledBitmap(@NonNull Drawable icon, @BitmapGenerationMode int mode) { RectF iconBounds = new RectF(); float[] scale = new float[1]; icon = normalizeAndWrapToAdaptiveIcon(icon, true, iconBounds, scale); return createIconBitmap(icon, - Math.min(scale[0], ShadowGenerator.getScaleForBounds(iconBounds))); + Math.min(scale[0], ShadowGenerator.getScaleForBounds(iconBounds)), mode); } /** * Sets the background color used for wrapped adaptive icon */ - public void setWrapperBackgroundColor(int color) { + public void setWrapperBackgroundColor(final int color) { mWrapperBackgroundColor = (Color.alpha(color) < 255) ? DEFAULT_WRAPPER_BACKGROUND : color; } - /** - * Disables the dominant color extraction for all icons loaded. - */ - public void disableColorExtraction() { - mDisableColorExtractor = true; - } - - private Drawable normalizeAndWrapToAdaptiveIcon(@NonNull Drawable icon, - boolean shrinkNonAdaptiveIcons, RectF outIconBounds, float[] outScale) { + @Nullable + protected Drawable normalizeAndWrapToAdaptiveIcon(@Nullable Drawable icon, + final boolean shrinkNonAdaptiveIcons, @Nullable final RectF outIconBounds, + @NonNull final float[] outScale) { if (icon == null) { return null; } @@ -312,46 +336,69 @@ public class BaseIconFactory implements AutoCloseable { return icon; } - private Bitmap createIconBitmap(Drawable icon, float scale) { - return createIconBitmap(icon, scale, mIconBitmapSize); - } - - /** - * @param icon drawable that should be flattened to a bitmap - * @param scale the scale to apply before drawing {@param icon} on the canvas - */ - public Bitmap createIconBitmap(@NonNull Drawable icon, float scale, int size) { - return createIconBitmap(icon, scale, size, Bitmap.Config.ARGB_8888); + @NonNull + protected Bitmap createIconBitmap(@Nullable final Drawable icon, final float scale) { + return createIconBitmap(icon, scale, MODE_DEFAULT); } - private Bitmap createIconBitmap(@NonNull Drawable icon, float scale, int size, - Bitmap.Config config) { - Bitmap bitmap = Bitmap.createBitmap(size, size, config); + @NonNull + protected Bitmap createIconBitmap(@Nullable final Drawable icon, final float scale, + @BitmapGenerationMode int bitmapGenerationMode) { + final int size = mIconBitmapSize; + final Bitmap bitmap; + switch (bitmapGenerationMode) { + case MODE_ALPHA: + bitmap = Bitmap.createBitmap(size, size, Config.ALPHA_8); + break; + case MODE_HARDWARE: + case MODE_HARDWARE_WITH_SHADOW: { + return BitmapRenderer.createHardwareBitmap(size, size, canvas -> + drawIconBitmap(canvas, icon, scale, bitmapGenerationMode, null)); + } + case MODE_WITH_SHADOW: + default: + bitmap = Bitmap.createBitmap(size, size, Config.ARGB_8888); + break; + } if (icon == null) { return bitmap; } mCanvas.setBitmap(bitmap); + drawIconBitmap(mCanvas, icon, scale, bitmapGenerationMode, bitmap); + mCanvas.setBitmap(null); + return bitmap; + } + + private void drawIconBitmap(@NonNull Canvas canvas, @Nullable final Drawable icon, + final float scale, @BitmapGenerationMode int bitmapGenerationMode, + @Nullable Bitmap targetBitmap) { + final int size = mIconBitmapSize; mOldBounds.set(icon.getBounds()); if (icon instanceof AdaptiveIconDrawable) { int offset = Math.max((int) Math.ceil(BLUR_FACTOR * size), - Math.round(size * (1 - scale) / 2 )); + Math.round(size * (1 - scale) / 2)); // b/211896569: AdaptiveIconDrawable do not work properly for non top-left bounds icon.setBounds(0, 0, size - offset - offset, size - offset - offset); - int count = mCanvas.save(); - mCanvas.translate(offset, offset); + int count = canvas.save(); + canvas.translate(offset, offset); + if (bitmapGenerationMode == MODE_WITH_SHADOW + || bitmapGenerationMode == MODE_HARDWARE_WITH_SHADOW) { + getShadowGenerator().addPathShadow( + ((AdaptiveIconDrawable) icon).getIconMask(), canvas); + } if (icon instanceof BitmapInfo.Extender) { - ((Extender) icon).drawForPersistence(mCanvas); + ((Extender) icon).drawForPersistence(canvas); } else { - icon.draw(mCanvas); + icon.draw(canvas); } - mCanvas.restoreToCount(count); + canvas.restoreToCount(count); } else { if (icon instanceof BitmapDrawable) { BitmapDrawable bitmapDrawable = (BitmapDrawable) icon; Bitmap b = bitmapDrawable.getBitmap(); - if (bitmap != null && b.getDensity() == Bitmap.DENSITY_NONE) { + if (b != null && b.getDensity() == Bitmap.DENSITY_NONE) { bitmapDrawable.setTargetDensity(mContext.getResources().getDisplayMetrics()); } } @@ -372,15 +419,24 @@ public class BaseIconFactory implements AutoCloseable { final int left = (size - width) / 2; final int top = (size - height) / 2; icon.setBounds(left, top, left + width, top + height); - mCanvas.save(); - mCanvas.scale(scale, scale, size / 2, size / 2); - icon.draw(mCanvas); - mCanvas.restore(); + canvas.save(); + canvas.scale(scale, scale, size / 2, size / 2); + icon.draw(canvas); + canvas.restore(); + + if (bitmapGenerationMode == MODE_WITH_SHADOW && targetBitmap != null) { + // Shadow extraction only works in software mode + getShadowGenerator().drawShadow(targetBitmap, canvas); + + // Draw the icon again on top: + canvas.save(); + canvas.scale(scale, scale, size / 2, size / 2); + icon.draw(canvas); + canvas.restore(); + } } icon.setBounds(mOldBounds); - mCanvas.setBitmap(null); - return bitmap; } @Override @@ -388,36 +444,45 @@ public class BaseIconFactory implements AutoCloseable { clear(); } + @NonNull public BitmapInfo makeDefaultIcon() { return createBadgedIconBitmap(getFullResDefaultActivityIcon(mFillResIconDpi)); } - public static Drawable getFullResDefaultActivityIcon(int iconDpi) { - return Resources.getSystem().getDrawableForDensity( - android.R.drawable.sym_def_app_icon, iconDpi); - } - - private int extractColor(Bitmap bitmap) { - return mDisableColorExtractor ? 0 : mColorExtractor.findDominantColorByHue(bitmap); + @NonNull + public static Drawable getFullResDefaultActivityIcon(final int iconDpi) { + return Objects.requireNonNull(Resources.getSystem().getDrawableForDensity( + android.R.drawable.sym_def_app_icon, iconDpi)); } /** * Returns the correct badge size given an icon size */ - public static int getBadgeSizeForIconSize(int iconSize) { + public static int getBadgeSizeForIconSize(final int iconSize) { return (int) (ICON_BADGE_SCALE * iconSize); } public static class IconOptions { boolean mShrinkNonAdaptiveIcons = true; + boolean mIsInstantApp; - UserHandle mUserHandle; + + boolean mIsCloneProfile; + + @BitmapGenerationMode + int mGenerationMode = MODE_WITH_SHADOW; + + @Nullable UserHandle mUserHandle; + + @ColorInt + @Nullable Integer mExtractedColor; /** * Set to false if non-adaptive icons should not be treated */ - public IconOptions setShrinkNonAdaptiveIcons(boolean shrink) { + @NonNull + public IconOptions setShrinkNonAdaptiveIcons(final boolean shrink) { mShrinkNonAdaptiveIcons = shrink; return this; } @@ -425,7 +490,8 @@ public class BaseIconFactory implements AutoCloseable { /** * User for this icon, in case of badging */ - public IconOptions setUser(UserHandle user) { + @NonNull + public IconOptions setUser(@Nullable final UserHandle user) { mUserHandle = user; return this; } @@ -433,10 +499,39 @@ public class BaseIconFactory implements AutoCloseable { /** * If this icon represents an instant app */ - public IconOptions setInstantApp(boolean instantApp) { + @NonNull + public IconOptions setInstantApp(final boolean instantApp) { mIsInstantApp = instantApp; return this; } + + /** + * Disables auto color extraction and overrides the color to the provided value + */ + @NonNull + public IconOptions setExtractedColor(@ColorInt int color) { + mExtractedColor = color; + return this; + } + + /** + * Sets the bitmap generation mode to use for the bitmap info. Note that some generation + * modes do not support color extraction, so consider setting a extracted color manually + * in those cases. + */ + public IconOptions setBitmapGenerationMode(@BitmapGenerationMode int generationMode) { + mGenerationMode = generationMode; + return this; + } + + /** + * Used to determine the badge type for this icon. + */ + @NonNull + public IconOptions setIsCloneProfile(boolean isCloneProfile) { + mIsCloneProfile = isCloneProfile; + return this; + } } /** @@ -446,7 +541,7 @@ public class BaseIconFactory implements AutoCloseable { */ private static class FixedSizeBitmapDrawable extends BitmapDrawable { - public FixedSizeBitmapDrawable(Bitmap bitmap) { + public FixedSizeBitmapDrawable(@Nullable final Bitmap bitmap) { super(null, bitmap); } @@ -473,11 +568,12 @@ public class BaseIconFactory implements AutoCloseable { } } - private static class ClippedMonoDrawable extends InsetDrawable { + protected static class ClippedMonoDrawable extends InsetDrawable { + @NonNull private final AdaptiveIconDrawable mCrop; - public ClippedMonoDrawable(Drawable base) { + public ClippedMonoDrawable(@Nullable final Drawable base) { super(base, -getExtraInsetFraction()); mCrop = new AdaptiveIconDrawable(new ColorDrawable(Color.BLACK), null); } @@ -491,4 +587,32 @@ public class BaseIconFactory implements AutoCloseable { canvas.restoreToCount(saveCount); } } + + private static class CenterTextDrawable extends ColorDrawable { + + @NonNull + private final Rect mTextBounds = new Rect(); + + @NonNull + private final Paint mTextPaint = new Paint(ANTI_ALIAS_FLAG | FILTER_BITMAP_FLAG); + + @NonNull + private final String mText; + + CenterTextDrawable(@NonNull final String text, final int color) { + mText = text; + mTextPaint.setColor(color); + } + + @Override + public void draw(Canvas canvas) { + Rect bounds = getBounds(); + mTextPaint.setTextSize(bounds.height() / 3f); + mTextPaint.getTextBounds(mText, 0, mText.length(), mTextBounds); + canvas.drawText(mText, + bounds.exactCenterX() - mTextBounds.exactCenterX(), + bounds.exactCenterY() - mTextBounds.exactCenterY(), + mTextPaint); + } + } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java index c3ca42e..d1ef6f7 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java +++ b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java @@ -16,6 +16,7 @@ package com.android.launcher3.icons; import android.content.Context; +import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; import android.graphics.Canvas; @@ -30,9 +31,11 @@ public class BitmapInfo { static final int FLAG_WORK = 1 << 0; static final int FLAG_INSTANT = 1 << 1; + static final int FLAG_CLONE = 1 << 2; @IntDef(flag = true, value = { FLAG_WORK, FLAG_INSTANT, + FLAG_CLONE }) @interface BitmapInfoFlags {} @@ -152,9 +155,17 @@ public class BitmapInfo { if (badgeInfo != null) { drawable.setBadge(badgeInfo.newIcon(context, creationFlags)); } else if ((flags & FLAG_INSTANT) != 0) { - drawable.setBadge(context.getDrawable(R.drawable.ic_instant_app_badge)); + drawable.setBadge(context.getDrawable(drawable.isThemed() + ? R.drawable.ic_instant_app_badge_themed + : R.drawable.ic_instant_app_badge)); } else if ((flags & FLAG_WORK) != 0) { - drawable.setBadge(context.getDrawable(R.drawable.ic_work_app_badge)); + drawable.setBadge(context.getDrawable(drawable.isThemed() + ? R.drawable.ic_work_app_badge_themed + : R.drawable.ic_work_app_badge)); + } else if ((flags & FLAG_CLONE) != 0) { + drawable.setBadge(context.getDrawable(drawable.isThemed() + ? R.drawable.ic_clone_app_badge_themed + : R.drawable.ic_clone_app_badge)); } } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/BubbleIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BubbleIconFactory.java new file mode 100644 index 0000000..a4ac812 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/BubbleIconFactory.java @@ -0,0 +1,160 @@ +package com.android.launcher3.icons; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.LauncherApps; +import android.content.pm.ShortcutInfo; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.drawable.AdaptiveIconDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Factory for creating normalized bubble icons and app badges. + */ +public class BubbleIconFactory extends BaseIconFactory { + + private final int mRingColor; + private final int mRingWidth; + + private final BaseIconFactory mBadgeFactory; + + /** + * Creates a bubble icon factory. + * + * @param context the context for the factory. + * @param iconSize the size of the bubble icon (i.e. the large icon for the bubble). + * @param badgeSize the size of the badge (i.e. smaller icon shown on top of the large icon). + * @param ringColor the color of the ring optionally shown around the badge. + * @param ringWidth the width of the ring optionally shown around the badge. + */ + public BubbleIconFactory(Context context, int iconSize, int badgeSize, int ringColor, + int ringWidth) { + super(context, context.getResources().getConfiguration().densityDpi, iconSize); + mRingColor = ringColor; + mRingWidth = ringWidth; + + mBadgeFactory = new BaseIconFactory(context, + context.getResources().getConfiguration().densityDpi, + badgeSize); + } + + /** + * Returns the drawable that the developer has provided to display in the bubble. + */ + public Drawable getBubbleDrawable(@NonNull final Context context, + @Nullable final ShortcutInfo shortcutInfo, @Nullable final Icon ic) { + if (shortcutInfo != null) { + LauncherApps launcherApps = context.getSystemService(LauncherApps.class); + int density = context.getResources().getConfiguration().densityDpi; + return launcherApps.getShortcutIconDrawable(shortcutInfo, density); + } else { + if (ic != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (ic.getType() == Icon.TYPE_URI + || ic.getType() == Icon.TYPE_URI_ADAPTIVE_BITMAP) { + context.grantUriPermission(context.getPackageName(), + ic.getUri(), + Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + return ic.loadDrawable(context); + } + return null; + } + } + + /** + * Creates the bitmap for the provided drawable and returns the scale used for + * drawing the actual drawable. This is used for the larger icon shown for the bubble. + */ + public Bitmap getBubbleBitmap(@NonNull Drawable icon, float[] outScale) { + if (outScale == null) { + outScale = new float[1]; + } + icon = normalizeAndWrapToAdaptiveIcon(icon, + true /* shrinkNonAdaptiveIcons */, + null /* outscale */, + outScale); + return createIconBitmap(icon, outScale[0], MODE_WITH_SHADOW); + } + + /** + * Returns a {@link BitmapInfo} for the app-badge that is shown on top of each bubble. This + * will include the workprofile indicator on the badge if appropriate. + */ + public BitmapInfo getBadgeBitmap(Drawable userBadgedAppIcon, boolean isImportantConversation) { + if (userBadgedAppIcon instanceof AdaptiveIconDrawable) { + AdaptiveIconDrawable ad = (AdaptiveIconDrawable) userBadgedAppIcon; + userBadgedAppIcon = new CircularAdaptiveIcon(ad.getBackground(), + ad.getForeground()); + } + if (isImportantConversation) { + userBadgedAppIcon = new CircularRingDrawable(userBadgedAppIcon); + } + Bitmap userBadgedBitmap = mBadgeFactory.createIconBitmap( + userBadgedAppIcon, 1, MODE_WITH_SHADOW); + return mBadgeFactory.createIconBitmap(userBadgedBitmap); + } + + private class CircularRingDrawable extends CircularAdaptiveIcon { + final Rect mInnerBounds = new Rect(); + + final Drawable mDr; + + CircularRingDrawable(Drawable dr) { + super(null, null); + mDr = dr; + } + + @Override + public void draw(Canvas canvas) { + int save = canvas.save(); + canvas.clipPath(getIconMask()); + canvas.drawColor(mRingColor); + mInnerBounds.set(getBounds()); + mInnerBounds.inset(mRingWidth, mRingWidth); + canvas.translate(mInnerBounds.left, mInnerBounds.top); + mDr.setBounds(0, 0, mInnerBounds.width(), mInnerBounds.height()); + mDr.draw(canvas); + canvas.restoreToCount(save); + } + } + + private static class CircularAdaptiveIcon extends AdaptiveIconDrawable { + + final Path mPath = new Path(); + + CircularAdaptiveIcon(Drawable bg, Drawable fg) { + super(bg, fg); + } + + @Override + public Path getIconMask() { + mPath.reset(); + Rect bounds = getBounds(); + mPath.addOval(bounds.left, bounds.top, bounds.right, bounds.bottom, Path.Direction.CW); + return mPath; + } + + @Override + public void draw(Canvas canvas) { + int save = canvas.save(); + canvas.clipPath(getIconMask()); + + Drawable d; + if ((d = getBackground()) != null) { + d.draw(canvas); + } + if ((d = getForeground()) != null) { + d.draw(canvas); + } + canvas.restoreToCount(save); + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java index d624805..252c0c3 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java +++ b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java @@ -214,7 +214,8 @@ public class ClockDrawableWrapper extends AdaptiveIconDrawable implements Bitmap BaseIconFactory iconFactory, float normalizationScale) { AdaptiveIconDrawable background = new AdaptiveIconDrawable( getBackground().getConstantState().newDrawable(), null); - Bitmap flattenBG = iconFactory.createScaledBitmapWithShadow(background); + Bitmap flattenBG = iconFactory.createScaledBitmap(background, + BaseIconFactory.MODE_HARDWARE_WITH_SHADOW); // Only pass theme info if mono-icon is enabled AnimationInfo themeInfo = iconFactory.mMonoIconEnabled ? mThemeInfo : null; @@ -387,7 +388,8 @@ public class ClockDrawableWrapper extends AdaptiveIconDrawable implements Bitmap mBgPaint.setColorFilter(cs.mBgFilter); mThemedFgColor = cs.mThemedFgColor; - mFullDrawable = (AdaptiveIconDrawable) mAnimInfo.baseDrawableState.newDrawable(); + mFullDrawable = + (AdaptiveIconDrawable) mAnimInfo.baseDrawableState.newDrawable().mutate(); mFG = (LayerDrawable) mFullDrawable.getForeground(); // Time needs to be applied here since drawInternal is NOT guaranteed to be called @@ -397,6 +399,13 @@ public class ClockDrawableWrapper extends AdaptiveIconDrawable implements Bitmap } @Override + public void setAlpha(int alpha) { + super.setAlpha(alpha); + mBgPaint.setAlpha(alpha); + mFG.setAlpha(alpha); + } + + @Override protected void onBoundsChange(Rect bounds) { super.onBoundsChange(bounds); @@ -434,8 +443,7 @@ public class ClockDrawableWrapper extends AdaptiveIconDrawable implements Bitmap protected void updateFilter() { super.updateFilter(); int alpha = mIsDisabled ? (int) (mDisabledAlpha * FULLY_OPAQUE) : FULLY_OPAQUE; - mBgPaint.setAlpha(alpha); - mFG.setAlpha(alpha); + setAlpha(alpha); mBgPaint.setColorFilter(mIsDisabled ? getDisabledColorFilter() : mBgFilter); mFG.setColorFilter(mIsDisabled ? getDisabledColorFilter() : null); } diff --git a/iconloaderlib/src/com/android/launcher3/icons/ColorExtractor.java b/iconloaderlib/src/com/android/launcher3/icons/ColorExtractor.java index 87bda82..5a5e7d0 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ColorExtractor.java +++ b/iconloaderlib/src/com/android/launcher3/icons/ColorExtractor.java @@ -18,6 +18,9 @@ package com.android.launcher3.icons; import android.graphics.Bitmap; import android.graphics.Color; import android.util.SparseArray; + +import androidx.annotation.NonNull; + import java.util.Arrays; /** @@ -26,16 +29,24 @@ import java.util.Arrays; public class ColorExtractor { private final int NUM_SAMPLES = 20; + + @NonNull private final float[] mTmpHsv = new float[3]; + + @NonNull private final float[] mTmpHueScoreHistogram = new float[360]; + + @NonNull private final int[] mTmpPixels = new int[NUM_SAMPLES]; + + @NonNull private final SparseArray<Float> mTmpRgbScores = new SparseArray<>(); /** * This picks a dominant color, looking for high-saturation, high-value, repeated hues. * @param bitmap The bitmap to scan */ - public int findDominantColorByHue(Bitmap bitmap) { + public int findDominantColorByHue(@NonNull final Bitmap bitmap) { return findDominantColorByHue(bitmap, NUM_SAMPLES); } @@ -43,7 +54,7 @@ public class ColorExtractor { * This picks a dominant color, looking for high-saturation, high-value, repeated hues. * @param bitmap The bitmap to scan */ - public int findDominantColorByHue(Bitmap bitmap, int samples) { + protected int findDominantColorByHue(@NonNull final Bitmap bitmap, final int samples) { final int height = bitmap.getHeight(); final int width = bitmap.getWidth(); int sampleStride = (int) Math.sqrt((height * width) / samples); diff --git a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java index 17b0016..3455dba 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java +++ b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java @@ -16,9 +16,11 @@ package com.android.launcher3.icons; import android.content.Context; +import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Bitmap; import android.graphics.Color; +import android.graphics.Matrix; import android.graphics.Path; import android.graphics.Rect; import android.graphics.Region; @@ -28,6 +30,8 @@ import android.graphics.drawable.ColorDrawable; import android.util.Log; import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.core.graphics.PathParser; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -35,6 +39,7 @@ import java.io.IOException; public class GraphicsUtils { private static final String TAG = "GraphicsUtils"; + private static final float MASK_SIZE = 100f; public static Runnable sOnNewBitmapRunnable = () -> { }; @@ -98,7 +103,20 @@ public class GraphicsUtils { /** * Returns the default path to be used by an icon */ - public static Path getShapePath(int size) { + public static Path getShapePath(@NonNull Context context, int size) { + if (IconProvider.CONFIG_ICON_MASK_RES_ID != Resources.ID_NULL) { + Path path = PathParser.createPathFromPathData( + context.getString(IconProvider.CONFIG_ICON_MASK_RES_ID)); + if (path != null) { + if (size != MASK_SIZE) { + Matrix m = new Matrix(); + float scale = ((float) size) / MASK_SIZE; + m.setScale(scale, scale); + path.transform(m); + } + return path; + } + } AdaptiveIconDrawable drawable = new AdaptiveIconDrawable( new ColorDrawable(Color.BLACK), new ColorDrawable(Color.BLACK)); drawable.setBounds(0, 0, size, size); diff --git a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java index 204651c..e8ce3b1 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java +++ b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java @@ -61,7 +61,7 @@ import java.util.function.Supplier; public class IconProvider { private final String ACTION_OVERLAY_CHANGED = "android.intent.action.OVERLAY_CHANGED"; - private static final int CONFIG_ICON_MASK_RES_ID = Resources.getSystem().getIdentifier( + static final int CONFIG_ICON_MASK_RES_ID = Resources.getSystem().getIdentifier( "config_icon_mask", "string", "android"); private static final String TAG = "IconProvider"; diff --git a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java index 5f3343e..71a80cb 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java +++ b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java @@ -40,7 +40,7 @@ public class PlaceHolderIconDrawable extends FastBitmapDrawable { public PlaceHolderIconDrawable(BitmapInfo info, Context context) { super(info); - mProgressPath = GraphicsUtils.getShapePath(100); + mProgressPath = GraphicsUtils.getShapePath(context, 100); mPaint.setColor(ColorUtils.compositeColors( GraphicsUtils.getAttrColor(context, R.attr.loadingIconColor), info.color)); } diff --git a/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java b/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java index 96dee3b..99f6813 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java +++ b/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java @@ -24,6 +24,7 @@ import android.graphics.BlurMaskFilter.Blur; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; +import android.graphics.Path; import android.graphics.PorterDuff; import android.graphics.PorterDuffXfermode; import android.graphics.RectF; @@ -57,30 +58,41 @@ public class ShadowGenerator { mDefaultBlurMaskFilter = new BlurMaskFilter(mIconSize * BLUR_FACTOR, Blur.NORMAL); } - public synchronized void recreateIcon(Bitmap icon, Canvas out) { - recreateIcon(icon, mDefaultBlurMaskFilter, AMBIENT_SHADOW_ALPHA, KEY_SHADOW_ALPHA, out); - } - - public synchronized void recreateIcon(Bitmap icon, BlurMaskFilter blurMaskFilter, - int ambientAlpha, int keyAlpha, Canvas out) { + public synchronized void drawShadow(Bitmap icon, Canvas out) { if (ENABLE_SHADOWS) { int[] offset = new int[2]; - mBlurPaint.setMaskFilter(blurMaskFilter); + mBlurPaint.setMaskFilter(mDefaultBlurMaskFilter); Bitmap shadow = icon.extractAlpha(mBlurPaint, offset); // Draw ambient shadow - mDrawPaint.setAlpha(ambientAlpha); + mDrawPaint.setAlpha(AMBIENT_SHADOW_ALPHA); out.drawBitmap(shadow, offset[0], offset[1], mDrawPaint); // Draw key shadow - mDrawPaint.setAlpha(keyAlpha); + mDrawPaint.setAlpha(KEY_SHADOW_ALPHA); out.drawBitmap(shadow, offset[0], offset[1] + KEY_SHADOW_DISTANCE * mIconSize, mDrawPaint); } + } + + /** package private **/ + void addPathShadow(Path path, Canvas out) { + if (ENABLE_SHADOWS) { + mDrawPaint.setMaskFilter(mDefaultBlurMaskFilter); - // Draw the icon - mDrawPaint.setAlpha(255); - out.drawBitmap(icon, 0, 0, mDrawPaint); + // Draw ambient shadow + mDrawPaint.setAlpha(AMBIENT_SHADOW_ALPHA); + out.drawPath(path, mDrawPaint); + + // Draw key shadow + int save = out.save(); + mDrawPaint.setAlpha(KEY_SHADOW_ALPHA); + out.translate(0, KEY_SHADOW_DISTANCE * mIconSize); + out.drawPath(path, mDrawPaint); + out.restoreToCount(save); + + mDrawPaint.setMaskFilter(null); + } } /** diff --git a/iconloaderlib/src/com/android/launcher3/icons/ThemedIconDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/ThemedIconDrawable.java index 494d657..6724d6b 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ThemedIconDrawable.java +++ b/iconloaderlib/src/com/android/launcher3/icons/ThemedIconDrawable.java @@ -24,6 +24,7 @@ import android.graphics.Bitmap; import android.graphics.BlendMode; import android.graphics.BlendModeColorFilter; import android.graphics.Canvas; +import android.graphics.Color; import android.graphics.ColorFilter; import android.graphics.Paint; import android.graphics.Rect; @@ -92,8 +93,11 @@ public class ThemedIconDrawable extends FastBitmapDrawable { return new ThemedConstantState(bitmapInfo, colorBg, colorFg); } - public void changeBackgroundColor(int colorBg){ + public void changeBackgroundColor(int colorBg) { + if (mIsDisabled) return; + mBgPaint.setColorFilter(new BlendModeColorFilter(colorBg, BlendMode.SRC_IN)); + invalidateSelf(); } static class ThemedConstantState extends FastBitmapConstantState { @@ -125,13 +129,8 @@ public class ThemedIconDrawable extends FastBitmapDrawable { public static int[] getColors(Context context) { Resources res = context.getResources(); int[] colors = new int[2]; - if ((res.getConfiguration().uiMode & UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES) { - colors[0] = res.getColor(android.R.color.system_neutral1_800); - colors[1] = res.getColor(android.R.color.system_accent1_100); - } else { - colors[0] = res.getColor(android.R.color.system_accent1_100); - colors[1] = res.getColor(android.R.color.system_neutral2_700); - } + colors[0] = res.getColor(R.color.themed_icon_background_color); + colors[1] = res.getColor(R.color.themed_icon_color); return colors; } diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java index 057bdc2..bdc4410 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java @@ -45,6 +45,7 @@ import android.os.Handler; import android.os.LocaleList; import android.os.Looper; import android.os.Process; +import android.os.SystemClock; import android.os.Trace; import android.os.UserHandle; import android.text.TextUtils; @@ -54,6 +55,7 @@ import android.util.SparseArray; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; import com.android.launcher3.icons.BaseIconFactory; import com.android.launcher3.icons.BaseIconFactory.IconOptions; @@ -78,6 +80,8 @@ public abstract class BaseIconCache { private static final boolean DEBUG = false; private static final int INITIAL_ICON_CACHE_CAPACITY = 50; + // A format string which returns the original string as is. + private static final String IDENTITY_FORMAT_STRING = "%1$s"; // Empty class name is used for storing package default entry. public static final String EMPTY_CLASS_NAME = "."; @@ -86,29 +90,52 @@ public abstract class BaseIconCache { @NonNull public BitmapInfo bitmap = BitmapInfo.LOW_RES_INFO; + @NonNull public CharSequence title = ""; + @NonNull public CharSequence contentDescription = ""; } + @NonNull protected final Context mContext; + + @NonNull protected final PackageManager mPackageManager; + @NonNull private final Map<ComponentKey, CacheEntry> mCache; + + @NonNull protected final Handler mWorkerHandler; protected int mIconDpi; + + @NonNull protected IconDB mIconDb; + + @NonNull protected LocaleList mLocaleList = LocaleList.getEmptyLocaleList(); + + @NonNull protected String mSystemState = ""; + @Nullable private BitmapInfo mDefaultIcon; + + @NonNull private final SparseArray<FlagOp> mUserFlagOpMap = new SparseArray<>(); + private final SparseArray<String> mUserFormatString = new SparseArray<>(); + + @Nullable private final String mDbFileName; + + @NonNull private final Looper mBgLooper; - public BaseIconCache(Context context, String dbFileName, Looper bgLooper, - int iconDpi, int iconPixelSize, boolean inMemoryCache) { + public BaseIconCache(@NonNull final Context context, @Nullable final String dbFileName, + @NonNull final Looper bgLooper, final int iconDpi, final int iconPixelSize, + final boolean inMemoryCache) { mContext = context; mDbFileName = dbFileName; mPackageManager = context.getPackageManager(); @@ -141,23 +168,24 @@ public abstract class BaseIconCache { * Returns the persistable serial number for {@param user}. Subclass should implement proper * caching strategy to avoid making binder call every time. */ - protected abstract long getSerialNumberForUser(UserHandle user); + protected abstract long getSerialNumberForUser(@NonNull final UserHandle user); /** * Return true if the given app is an instant app and should be badged appropriately. */ - protected abstract boolean isInstantApp(ApplicationInfo info); + protected abstract boolean isInstantApp(@NonNull final ApplicationInfo info); /** * Opens and returns an icon factory. The factory is recycled by the caller. */ + @NonNull public abstract BaseIconFactory getIconFactory(); - public void updateIconParams(int iconDpi, int iconPixelSize) { + public void updateIconParams(final int iconDpi, final int iconPixelSize) { mWorkerHandler.post(() -> updateIconParamsBg(iconDpi, iconPixelSize)); } - private synchronized void updateIconParamsBg(int iconDpi, int iconPixelSize) { + private synchronized void updateIconParamsBg(final int iconDpi, final int iconPixelSize) { mIconDpi = iconDpi; mDefaultIcon = null; mUserFlagOpMap.clear(); @@ -167,7 +195,8 @@ public abstract class BaseIconCache { mCache.clear(); } - private Drawable getFullResIcon(Resources resources, int iconId) { + @Nullable + private Drawable getFullResIcon(@Nullable final Resources resources, final int iconId) { if (resources != null && iconId != 0) { try { return resources.getDrawableForDensity(iconId, mIconDpi); @@ -176,14 +205,16 @@ public abstract class BaseIconCache { return getFullResDefaultActivityIcon(mIconDpi); } - public Drawable getFullResIcon(String packageName, int iconId) { + @Nullable + public Drawable getFullResIcon(@NonNull final String packageName, final int iconId) { try { return getFullResIcon(mPackageManager.getResourcesForApplication(packageName), iconId); } catch (PackageManager.NameNotFoundException e) { } return getFullResDefaultActivityIcon(mIconDpi); } - public Drawable getFullResIcon(ActivityInfo info) { + @Nullable + public Drawable getFullResIcon(@NonNull final ActivityInfo info) { try { return getFullResIcon(mPackageManager.getResourcesForApplication(info.applicationInfo), info.getIconResource()); @@ -194,14 +225,16 @@ public abstract class BaseIconCache { /** * Remove any records for the supplied ComponentName. */ - public synchronized void remove(ComponentName componentName, UserHandle user) { + public synchronized void remove(@NonNull final ComponentName componentName, + @NonNull final UserHandle user) { mCache.remove(new ComponentKey(componentName, user)); } /** * Remove any records for the supplied package name from memory. */ - private void removeFromMemCacheLocked(String packageName, UserHandle user) { + private void removeFromMemCacheLocked(@Nullable final String packageName, + @Nullable final UserHandle user) { HashSet<ComponentKey> forDeletion = new HashSet<>(); for (ComponentKey key: mCache.keySet()) { if (key.componentName.getPackageName().equals(packageName) @@ -217,7 +250,8 @@ public abstract class BaseIconCache { /** * Removes the entries related to the given package in memory and persistent DB. */ - public synchronized void removeIconsForPkg(String packageName, UserHandle user) { + public synchronized void removeIconsForPkg(@NonNull final String packageName, + @NonNull final UserHandle user) { removeFromMemCacheLocked(packageName, user); long userSerial = getSerialNumberForUser(user); mIconDb.delete( @@ -225,6 +259,7 @@ public abstract class BaseIconCache { new String[]{packageName + "/%", Long.toString(userSerial)}); } + @NonNull public IconCacheUpdateHandler getUpdateHandler() { updateSystemState(); return new IconCacheUpdateHandler(this); @@ -238,12 +273,30 @@ public abstract class BaseIconCache { private void updateSystemState() { mLocaleList = mContext.getResources().getConfiguration().getLocales(); mSystemState = mLocaleList.toLanguageTags() + "," + Build.VERSION.SDK_INT; + mUserFormatString.clear(); } - protected String getIconSystemState(String packageName) { + @NonNull + protected String getIconSystemState(@Nullable final String packageName) { return mSystemState; } + public CharSequence getUserBadgedLabel(CharSequence label, UserHandle user) { + int key = user.hashCode(); + int index = mUserFormatString.indexOfKey(key); + String format; + if (index < 0) { + format = mPackageManager.getUserBadgedLabel(IDENTITY_FORMAT_STRING, user).toString(); + if (TextUtils.equals(IDENTITY_FORMAT_STRING, format)) { + format = null; + } + mUserFormatString.put(key, format); + } else { + format = mUserFormatString.valueAt(index); + } + return format == null ? label : String.format(format, label); + } + /** * Adds an entry into the DB and the in-memory cache. * @param replaceExisting if true, it will recreate the bitmap even if it already exists in @@ -251,8 +304,9 @@ public abstract class BaseIconCache { * old data. */ @VisibleForTesting - public synchronized <T> void addIconToDBAndMemCache(T object, CachingLogic<T> cachingLogic, - PackageInfo info, long userSerial, boolean replaceExisting) { + public synchronized <T> void addIconToDBAndMemCache(@NonNull final T object, + @NonNull final CachingLogic<T> cachingLogic, @NonNull final PackageInfo info, + final long userSerial, final boolean replaceExisting) { UserHandle user = cachingLogic.getUser(object); ComponentName componentName = cachingLogic.getComponent(object); @@ -276,11 +330,12 @@ public abstract class BaseIconCache { CharSequence entryTitle = cachingLogic.getLabel(object); if (entryTitle == null) { - Log.d(TAG, "No label returned from caching logic instance: " + cachingLogic); + Log.wtf(TAG, "No label returned from caching logic instance: " + cachingLogic); + entryTitle = ""; } entry.title = entryTitle; - entry.contentDescription = mPackageManager.getUserBadgedLabel(entry.title, user); + entry.contentDescription = getUserBadgedLabel(entry.title, user); if (cachingLogic.addToMemCache()) mCache.put(key, entry); ContentValues values = newContentValues(entry.bitmap, entry.title.toString(), @@ -293,8 +348,8 @@ public abstract class BaseIconCache { * Updates {@param values} to contain versioning information and adds it to the DB. * @param values {@link ContentValues} containing icon & title */ - private void addIconToDB(ContentValues values, ComponentName key, - PackageInfo info, long userSerial, long lastUpdateTime) { + private void addIconToDB(@NonNull final ContentValues values, @NonNull final ComponentName key, + @NonNull final PackageInfo info, final long userSerial, final long lastUpdateTime) { values.put(IconDB.COLUMN_COMPONENT, key.flattenToString()); values.put(IconDB.COLUMN_USER, userSerial); values.put(IconDB.COLUMN_LAST_UPDATED, lastUpdateTime); @@ -302,7 +357,8 @@ public abstract class BaseIconCache { mIconDb.insertOrReplace(values); } - public synchronized BitmapInfo getDefaultIcon(UserHandle user) { + @NonNull + public synchronized BitmapInfo getDefaultIcon(@NonNull final UserHandle user) { if (mDefaultIcon == null) { try (BaseIconFactory li = getIconFactory()) { mDefaultIcon = li.makeDefaultIcon(); @@ -311,7 +367,8 @@ public abstract class BaseIconCache { return mDefaultIcon.withFlags(getUserFlagOpLocked(user)); } - protected FlagOp getUserFlagOpLocked(UserHandle user) { + @NonNull + protected FlagOp getUserFlagOpLocked(@NonNull final UserHandle user) { int key = user.hashCode(); int index; if ((index = mUserFlagOpMap.indexOfKey(key)) >= 0) { @@ -325,7 +382,7 @@ public abstract class BaseIconCache { } } - public boolean isDefaultIcon(BitmapInfo icon, UserHandle user) { + public boolean isDefaultIcon(@NonNull final BitmapInfo icon, @NonNull final UserHandle user) { return getDefaultIcon(user).icon == icon.icon; } @@ -333,10 +390,11 @@ public abstract class BaseIconCache { * Retrieves the entry from the cache. If the entry is not present, it creates a new entry. * This method is not thread safe, it must be called from a synchronized method. */ + @NonNull protected <T> CacheEntry cacheLocked( - @NonNull ComponentName componentName, @NonNull UserHandle user, - @NonNull Supplier<T> infoProvider, @NonNull CachingLogic<T> cachingLogic, - boolean usePackageIcon, boolean useLowResIcon) { + @NonNull final ComponentName componentName, @NonNull final UserHandle user, + @NonNull final Supplier<T> infoProvider, @NonNull final CachingLogic<T> cachingLogic, + final boolean usePackageIcon, final boolean useLowResIcon) { return cacheLocked( componentName, user, @@ -347,10 +405,12 @@ public abstract class BaseIconCache { useLowResIcon); } + @NonNull protected <T> CacheEntry cacheLocked( - @NonNull ComponentName componentName, @NonNull UserHandle user, - @NonNull Supplier<T> infoProvider, @NonNull CachingLogic<T> cachingLogic, - @Nullable Cursor cursor, boolean usePackageIcon, boolean useLowResIcon) { + @NonNull final ComponentName componentName, @NonNull final UserHandle user, + @NonNull final Supplier<T> infoProvider, @NonNull final CachingLogic<T> cachingLogic, + @Nullable final Cursor cursor, final boolean usePackageIcon, + final boolean useLowResIcon) { assertWorkerThread(); ComponentKey cacheKey = new ComponentKey(componentName, user); CacheEntry entry = mCache.get(cacheKey); @@ -396,30 +456,28 @@ public abstract class BaseIconCache { /** * Fallback method for loading an icon bitmap. */ - protected <T> void loadFallbackIcon( - T object, CacheEntry entry, @NonNull CachingLogic<T> cachingLogic, - boolean usePackageIcon, boolean usePackageTitle, @NonNull ComponentName componentName, - @NonNull UserHandle user) { + protected <T> void loadFallbackIcon(@Nullable final T object, @NonNull final CacheEntry entry, + @NonNull final CachingLogic<T> cachingLogic, final boolean usePackageIcon, + final boolean usePackageTitle, @NonNull final ComponentName componentName, + @NonNull final UserHandle user) { if (object != null) { entry.bitmap = cachingLogic.loadIcon(mContext, object); } else { if (usePackageIcon) { CacheEntry packageEntry = getEntryForPackageLocked( componentName.getPackageName(), user, false); - if (packageEntry != null) { - if (DEBUG) Log.d(TAG, "using package default icon for " + - componentName.toShortString()); - entry.bitmap = packageEntry.bitmap; - entry.contentDescription = packageEntry.contentDescription; - - if (usePackageTitle) { - entry.title = packageEntry.title; - } + if (DEBUG) Log.d(TAG, "using package default icon for " + + componentName.toShortString()); + entry.bitmap = packageEntry.bitmap; + entry.contentDescription = packageEntry.contentDescription; + + if (usePackageTitle) { + entry.title = packageEntry.title; } } if (entry.bitmap == null) { - if (DEBUG) Log.d(TAG, "using default icon for " + - componentName.toShortString()); + // TODO: entry.bitmap can never be null, so this should not happen at all. + Log.wtf(TAG, "using default icon for " + componentName.toShortString()); entry.bitmap = getDefaultIcon(user); } } @@ -429,10 +487,10 @@ public abstract class BaseIconCache { * Fallback method for loading an app title. */ protected <T> void loadFallbackTitle( - T object, CacheEntry entry, @NonNull CachingLogic<T> cachingLogic, - @NonNull UserHandle user) { + @NonNull final T object, @NonNull final CacheEntry entry, + @NonNull final CachingLogic<T> cachingLogic, @NonNull final UserHandle user) { entry.title = cachingLogic.getLabel(object); - entry.contentDescription = mPackageManager.getUserBadgedLabel( + entry.contentDescription = getUserBadgedLabel( cachingLogic.getDescription(object, entry.title), user); } @@ -445,8 +503,9 @@ public abstract class BaseIconCache { * Adds a default package entry in the cache. This entry is not persisted and will be removed * when the cache is flushed. */ - protected synchronized void cachePackageInstallInfo(String packageName, UserHandle user, - Bitmap icon, CharSequence title) { + protected synchronized void cachePackageInstallInfo(@NonNull final String packageName, + @NonNull final UserHandle user, @Nullable final Bitmap icon, + @Nullable final CharSequence title) { removeFromMemCacheLocked(packageName, user); ComponentKey cacheKey = getPackageKey(packageName, user); @@ -469,7 +528,9 @@ public abstract class BaseIconCache { } } - private static ComponentKey getPackageKey(String packageName, UserHandle user) { + @NonNull + private static ComponentKey getPackageKey(@NonNull final String packageName, + @NonNull final UserHandle user) { ComponentName cn = new ComponentName(packageName, packageName + EMPTY_CLASS_NAME); return new ComponentKey(cn, user); } @@ -478,8 +539,10 @@ public abstract class BaseIconCache { * Gets an entry for the package, which can be used as a fallback entry for various components. * This method is not thread safe, it must be called from a synchronized method. */ - protected CacheEntry getEntryForPackageLocked(String packageName, UserHandle user, - boolean useLowResIcon) { + @WorkerThread + @NonNull + protected CacheEntry getEntryForPackageLocked(@NonNull final String packageName, + @NonNull final UserHandle user, final boolean useLowResIcon) { assertWorkerThread(); ComponentKey cacheKey = getPackageKey(packageName, user); CacheEntry entry = mCache.get(cacheKey); @@ -508,7 +571,7 @@ public abstract class BaseIconCache { li.close(); entry.title = appInfo.loadLabel(mPackageManager); - entry.contentDescription = mPackageManager.getUserBadgedLabel(entry.title, user); + entry.contentDescription = getUserBadgedLabel(entry.title, user); entry.bitmap = BitmapInfo.of( useLowResIcon ? LOW_RES_ICON : iconInfo.icon, iconInfo.color); @@ -533,8 +596,8 @@ public abstract class BaseIconCache { return entry; } - protected boolean getEntryFromDBLocked( - ComponentKey cacheKey, CacheEntry entry, boolean lowRes) { + protected boolean getEntryFromDBLocked(@NonNull final ComponentKey cacheKey, + @NonNull final CacheEntry entry, final boolean lowRes) { Cursor c = null; Trace.beginSection("loadIconIndividually"); try { @@ -559,7 +622,8 @@ public abstract class BaseIconCache { } private boolean updateTitleAndIconLocked( - ComponentKey cacheKey, CacheEntry entry, Cursor c, boolean lowRes) { + @NonNull final ComponentKey cacheKey, @NonNull final CacheEntry entry, + @NonNull final Cursor c, final boolean lowRes) { // Set the alpha to be 255, so that we never have a wrong color entry.bitmap = BitmapInfo.of(LOW_RES_ICON, setColorAlphaBound(c.getInt(IconDB.INDEX_COLOR), 255)); @@ -568,8 +632,7 @@ public abstract class BaseIconCache { entry.title = ""; entry.contentDescription = ""; } else { - entry.contentDescription = mPackageManager.getUserBadgedLabel( - entry.title, cacheKey.user); + entry.contentDescription = getUserBadgedLabel(entry.title, cacheKey.user); } if (!lowRes) { @@ -678,8 +741,10 @@ public abstract class BaseIconCache { } } - private ContentValues newContentValues(BitmapInfo bitmapInfo, String label, - String packageName, @Nullable String keywords) { + @NonNull + private ContentValues newContentValues(@NonNull final BitmapInfo bitmapInfo, + @NonNull final String label, @NonNull final String packageName, + @Nullable final String keywords) { ContentValues values = new ContentValues(); if (bitmapInfo.canPersist()) { values.put(IconDB.COLUMN_ICON, flattenBitmap(bitmapInfo.icon)); diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/CachingLogic.java b/iconloaderlib/src/com/android/launcher3/icons/cache/CachingLogic.java index c12e9dc..8034d6e 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/CachingLogic.java +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/CachingLogic.java @@ -28,31 +28,36 @@ import com.android.launcher3.icons.BitmapInfo; public interface CachingLogic<T> { - ComponentName getComponent(T object); + @NonNull + ComponentName getComponent(@NonNull final T object); - UserHandle getUser(T object); + @NonNull + UserHandle getUser(@NonNull final T object); - CharSequence getLabel(T object); + @NonNull + CharSequence getLabel(@NonNull final T object); - default CharSequence getDescription(T object, CharSequence fallback) { + @NonNull + default CharSequence getDescription(@NonNull final T object, + @NonNull final CharSequence fallback) { return fallback; } @NonNull - BitmapInfo loadIcon(Context context, T object); + BitmapInfo loadIcon(@NonNull final Context context, @NonNull final T object); /** * Provides a option list of keywords to associate with this object */ @Nullable - default String getKeywords(T object, LocaleList localeList) { + default String getKeywords(@NonNull final T object, @NonNull final LocaleList localeList) { return null; } /** * Returns the timestamp the entry was last updated in cache. */ - default long getLastUpdatedTime(T object, PackageInfo info) { + default long getLastUpdatedTime(@Nullable final T object, @NonNull final PackageInfo info) { return info.lastUpdateTime; } diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/IconCacheUpdateHandler.java b/iconloaderlib/src/com/android/launcher3/icons/cache/IconCacheUpdateHandler.java index 9e1ad7b..aec1cdd 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/IconCacheUpdateHandler.java +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/IconCacheUpdateHandler.java @@ -180,7 +180,8 @@ public class IconCacheUpdateHandler { long updateTime = c.getLong(indexLastUpdate); int version = c.getInt(indexVersion); T app = componentMap.remove(component); - if (version == info.versionCode && updateTime == info.lastUpdateTime + if (version == info.versionCode + && updateTime == cachingLogic.getLastUpdatedTime(app, info) && TextUtils.equals(c.getString(systemStateIndex), mIconCache.getIconSystemState(info.packageName))) { diff --git a/iconloaderlib/src_full_lib/com/android/launcher3/icons/SimpleIconCache.java b/iconloaderlib/src_full_lib/com/android/launcher3/icons/SimpleIconCache.java index cc4ad7b..63ba887 100644 --- a/iconloaderlib/src_full_lib/com/android/launcher3/icons/SimpleIconCache.java +++ b/iconloaderlib/src_full_lib/com/android/launcher3/icons/SimpleIconCache.java @@ -32,6 +32,8 @@ import android.os.UserHandle; import android.os.UserManager; import android.util.SparseLongArray; +import androidx.annotation.NonNull; + import com.android.launcher3.icons.cache.BaseIconCache; /** @@ -64,7 +66,7 @@ public class SimpleIconCache extends BaseIconCache { } @Override - protected long getSerialNumberForUser(UserHandle user) { + protected long getSerialNumberForUser(@NonNull UserHandle user) { synchronized (mUserSerialMap) { int index = mUserSerialMap.indexOfKey(user.getIdentifier()); if (index >= 0) { @@ -83,10 +85,11 @@ public class SimpleIconCache extends BaseIconCache { } @Override - protected boolean isInstantApp(ApplicationInfo info) { + protected boolean isInstantApp(@NonNull ApplicationInfo info) { return info.isInstantApp(); } + @NonNull @Override public BaseIconFactory getIconFactory() { return IconFactory.obtain(mContext); diff --git a/searchuilib/.gitignore b/motiontoollib/.gitignore index 6213826..6213826 100644 --- a/searchuilib/.gitignore +++ b/motiontoollib/.gitignore diff --git a/motiontoollib/Android.bp b/motiontoollib/Android.bp new file mode 100644 index 0000000..6762d83 --- /dev/null +++ b/motiontoollib/Android.bp @@ -0,0 +1,80 @@ +// Copyright (C) 2022 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +java_library { + name: "motion_tool_proto", + srcs: ["src/com/android/app/motiontool/proto/*.proto"], + proto: { + type: "lite", + local_include_dirs:[ + "src/com/android/app/motiontool/proto" + ], + include_dirs: [ + "frameworks/libs/systemui/viewcapturelib/src/com/android/app/viewcapture/proto" + ], + }, + static_libs: [ + "libprotobuf-java-lite", + "view_capture_proto", + ], + java_version: "1.8", +} + +android_library { + name: "motion_tool_lib", + manifest: "AndroidManifest.xml", + platform_apis: true, + min_sdk_version: "26", + + static_libs: [ + "androidx.core_core", + "view_capture", + "motion_tool_proto", + ], + + srcs: [ + "src/**/*.java", + "src/**/*.kt" + ], +} + +android_test { + name: "motion_tool_lib_tests", + manifest: "tests/AndroidManifest.xml", + platform_apis: true, + min_sdk_version: "26", + + static_libs: [ + "androidx.core_core", + "view_capture", + "motion_tool_proto", + "androidx.test.ext.junit", + "androidx.test.rules", + "testables" + ], + srcs: [ + "**/*.java", + "**/*.kt" + ], + libs: [ + "android.test.runner", + "android.test.base", + ], + test_suites: ["device-tests"], +} + diff --git a/motiontoollib/AndroidManifest.xml b/motiontoollib/AndroidManifest.xml new file mode 100644 index 0000000..3b8a656 --- /dev/null +++ b/motiontoollib/AndroidManifest.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2022 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.app.motiontool"> +</manifest> diff --git a/motiontoollib/OWNERS b/motiontoollib/OWNERS new file mode 100644 index 0000000..fd22ee7 --- /dev/null +++ b/motiontoollib/OWNERS @@ -0,0 +1,3 @@ +gallmann@google.com +michschn@google.com +cinek@google.com
\ No newline at end of file diff --git a/motiontoollib/TEST_MAPPING b/motiontoollib/TEST_MAPPING new file mode 100644 index 0000000..22a9e6b --- /dev/null +++ b/motiontoollib/TEST_MAPPING @@ -0,0 +1,15 @@ +{ + "presubmit": [ + { + "name": "motion_tool_lib_tests", + "options": [ + { + "exclude-annotation": "org.junit.Ignore" + }, + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] + } + ] +} diff --git a/motiontoollib/build.gradle b/motiontoollib/build.gradle new file mode 100644 index 0000000..e3750ec --- /dev/null +++ b/motiontoollib/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'com.google.protobuf' +} + +final String PROTOS_DIR = "${ANDROID_TOP}/frameworks/libs/systemui/motiontoollib/src/com/android/app/motiontool/proto" + +android { + namespace = "com.android.app.motiontool" + testNamespace = "com.android.app.motiontool.tests" + defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + sourceSets { + main { + java.srcDirs = ['src'] + manifest.srcFile 'AndroidManifest.xml' + proto.srcDirs = ["${PROTOS_DIR}"] + } + androidTest { + java.srcDirs = ["tests"] + manifest.srcFile "tests/AndroidManifest.xml" + } + } + lint { + abortOnError false + } + +} + +dependencies { + implementation "androidx.core:core:1.9.0" + implementation "com.google.protobuf:protobuf-lite:${protobuf_lite_version}" + api project(":ViewCaptureLib") + androidTestImplementation project(':SharedTestLib') + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation "androidx.test:rules:1.4.0" +} + +protobuf { + // Configure the protoc executable + protoc { + artifact = "com.google.protobuf:protoc:${protobuf_version}${PROTO_ARCH_SUFFIX}" + } + plugins { + javalite { + // The codegen for lite comes as a separate artifact + artifact = "com.google.protobuf:protoc-gen-javalite:${protobuf_version}${PROTO_ARCH_SUFFIX}" + } + } + generateProtoTasks { + all().each { task -> + task.builtins { + remove java + } + task.plugins { + javalite { } + } + } + } +} diff --git a/motiontoollib/src/com/android/app/motiontool/DdmHandleMotionTool.kt b/motiontoollib/src/com/android/app/motiontool/DdmHandleMotionTool.kt new file mode 100644 index 0000000..c7a6b0d --- /dev/null +++ b/motiontoollib/src/com/android/app/motiontool/DdmHandleMotionTool.kt @@ -0,0 +1,165 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.app.motiontool + +import android.ddm.DdmHandle +import com.google.protobuf.InvalidProtocolBufferException +import org.apache.harmony.dalvik.ddmc.Chunk +import org.apache.harmony.dalvik.ddmc.ChunkHandler +import org.apache.harmony.dalvik.ddmc.DdmServer + +/** + * This class handles the 'MOTO' type DDM requests (defined in [motion_tool.proto]). + * + * It executes some validity checks and forwards valid requests to the [MotionToolManager]. It + * requires a [MotionToolsRequest] as parameter and returns a [MotionToolsResponse]. Failures will + * return a [MotionToolsResponse] with the [error][MotionToolsResponse.error] field set instead of + * the respective return value. + * + * To activate this server, call [register]. This will register the DdmHandleMotionTool with the + * [DdmServer]. The DdmHandleMotionTool can be registered once per process. To unregister from the + * DdmServer, call [unregister]. + */ +class DdmHandleMotionTool private constructor( + private val motionToolManager: MotionToolManager +) : DdmHandle() { + + companion object { + val CHUNK_MOTO = ChunkHandler.type("MOTO") + private const val SERVER_VERSION = 1 + + private var INSTANCE: DdmHandleMotionTool? = null + + @Synchronized + fun getInstance(motionToolManager: MotionToolManager): DdmHandleMotionTool { + return INSTANCE ?: DdmHandleMotionTool(motionToolManager).also { + INSTANCE = it + } + } + } + + fun register() { + DdmServer.registerHandler(CHUNK_MOTO, this) + } + + fun unregister() { + DdmServer.unregisterHandler(CHUNK_MOTO) + } + + override fun handleChunk(request: Chunk): Chunk { + val requestDataBuffer = wrapChunk(request) + val protoRequest = + try { + MotionToolsRequest.parseFrom(requestDataBuffer.array()) + } catch (e: InvalidProtocolBufferException) { + val responseData: ByteArray = MotionToolsResponse.newBuilder() + .setError(ErrorResponse.newBuilder() + .setCode(ErrorResponse.Code.INVALID_REQUEST) + .setMessage("Invalid request format (Protobuf parse exception)")) + .build() + .toByteArray() + return Chunk(CHUNK_MOTO, responseData, 0, responseData.size) + } + + val response = + when (protoRequest.typeCase.number) { + MotionToolsRequest.HANDSHAKE_FIELD_NUMBER -> + handleHandshakeRequest(protoRequest.handshake) + MotionToolsRequest.BEGIN_TRACE_FIELD_NUMBER -> + handleBeginTraceRequest(protoRequest.beginTrace) + MotionToolsRequest.POLL_TRACE_FIELD_NUMBER -> + handlePollTraceRequest(protoRequest.pollTrace) + MotionToolsRequest.END_TRACE_FIELD_NUMBER -> + handleEndTraceRequest(protoRequest.endTrace) + else -> + MotionToolsResponse.newBuilder().setError(ErrorResponse.newBuilder() + .setCode(ErrorResponse.Code.INVALID_REQUEST) + .setMessage("Unknown request type")).build() + } + + val responseData = response.toByteArray() + return Chunk(CHUNK_MOTO, responseData, 0, responseData.size) + } + + private fun handleBeginTraceRequest(beginTraceRequest: BeginTraceRequest): MotionToolsResponse = + MotionToolsResponse.newBuilder().apply { + tryCatchingMotionToolManagerExceptions { + setBeginTrace(BeginTraceResponse.newBuilder().setTraceId( + motionToolManager.beginTrace(beginTraceRequest.window.rootWindow))) + } + }.build() + + private fun handlePollTraceRequest(pollTraceRequest: PollTraceRequest): MotionToolsResponse = + MotionToolsResponse.newBuilder().apply { + tryCatchingMotionToolManagerExceptions { + setPollTrace(PollTraceResponse.newBuilder() + .setData(motionToolManager.pollTrace(pollTraceRequest.traceId))) + } + }.build() + + private fun handleEndTraceRequest(endTraceRequest: EndTraceRequest): MotionToolsResponse = + MotionToolsResponse.newBuilder().apply { + tryCatchingMotionToolManagerExceptions { + setEndTrace(EndTraceResponse.newBuilder() + .setData(motionToolManager.endTrace(endTraceRequest.traceId))) + } + }.build() + + private fun handleHandshakeRequest(handshakeRequest: HandshakeRequest): MotionToolsResponse { + val status = if (motionToolManager.hasWindow(handshakeRequest.window)) + HandshakeResponse.Status.OK + else + HandshakeResponse.Status.WINDOW_NOT_FOUND + + return MotionToolsResponse.newBuilder() + .setHandshake(HandshakeResponse.newBuilder() + .setServerVersion(SERVER_VERSION) + .setStatus(status)) + .build() + } + + /** + * Executes the [block] and catches all Exceptions thrown by [MotionToolManager]. In case of an + * exception being caught, the error response field of the [MotionToolsResponse] is being set + * with the according [ErrorResponse]. + */ + private fun MotionToolsResponse.Builder.tryCatchingMotionToolManagerExceptions(block: () -> Unit) { + try { + block() + } catch (e: UnknownTraceIdException) { + setError(createUnknownTraceIdResponse(e.traceId)) + } catch (e: WindowNotFoundException) { + setError(createWindowNotFoundResponse(e.windowId)) + } + } + + private fun createUnknownTraceIdResponse(traceId: Int) = + ErrorResponse.newBuilder().apply { + this.code = ErrorResponse.Code.UNKNOWN_TRACE_ID + this.message = "No running Trace found with traceId $traceId" + } + + private fun createWindowNotFoundResponse(windowId: String) = + ErrorResponse.newBuilder().apply { + this.code = ErrorResponse.Code.WINDOW_NOT_FOUND + this.message = "No window found with windowId $windowId" + } + + override fun onConnected() {} + + override fun onDisconnected() {} +} diff --git a/motiontoollib/src/com/android/app/motiontool/MotionToolManager.kt b/motiontoollib/src/com/android/app/motiontool/MotionToolManager.kt new file mode 100644 index 0000000..a98a588 --- /dev/null +++ b/motiontoollib/src/com/android/app/motiontool/MotionToolManager.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.app.motiontool + +import android.os.Process +import android.util.Log +import android.view.Choreographer +import android.view.View +import android.view.WindowManagerGlobal +import androidx.annotation.VisibleForTesting +import com.android.app.viewcapture.SimpleViewCapture +import com.android.app.viewcapture.ViewCapture +import com.android.app.viewcapture.data.MotionWindowData + +/** + * Singleton to manage motion tracing sessions. + * + * A motion tracing session captures motion-relevant data on a frame-by-frame basis for a given + * window, as long as the trace is running. + * + * To start a trace, use [beginTrace]. The returned handle must be used to terminate tracing and + * receive the data by calling [endTrace]. While the trace is active, data is buffered, however + * the buffer size is limited (@see [ViewCapture.mMemorySize]. Use [pollTrace] periodically to + * ensure no data is dropped. Both, [pollTrace] and [endTrace] only return data captured since the + * last call to either [beginTrace] or [endTrace]. + * + * NOTE: a running trace will incur some performance penalty. Only keep traces running while a user + * requested it. + * + * @see [DdmHandleMotionTool] + */ +class MotionToolManager private constructor(private val windowManagerGlobal: WindowManagerGlobal) { + private val viewCapture: ViewCapture = SimpleViewCapture("MTViewCapture") + + companion object { + private const val TAG = "MotionToolManager" + + private var INSTANCE: MotionToolManager? = null + + @Synchronized + fun getInstance(windowManagerGlobal: WindowManagerGlobal): MotionToolManager { + return INSTANCE ?: MotionToolManager(windowManagerGlobal).also { INSTANCE = it } + } + } + + private var traceIdCounter = 0 + private val traces = mutableMapOf<Int, TraceMetadata>() + + @Synchronized + fun hasWindow(windowId: WindowIdentifier): Boolean { + val rootView = getRootView(windowId.rootWindow) + return rootView != null + } + + /** Starts [ViewCapture] and returns a traceId. */ + @Synchronized + fun beginTrace(windowId: String): Int { + val traceId = ++traceIdCounter + Log.d(TAG, "Begin Trace for id: $traceId") + val rootView = getRootView(windowId) ?: throw WindowNotFoundException(windowId) + val autoCloseable = viewCapture.startCapture(rootView, windowId) + traces[traceId] = TraceMetadata(windowId, 0, autoCloseable::close) + return traceId + } + + /** + * Ends [ViewCapture] and returns the captured [MotionWindowData] since the [beginTrace] call or + * the last [pollTrace] call. + */ + @Synchronized + fun endTrace(traceId: Int): MotionWindowData { + Log.d(TAG, "End Trace for id: $traceId") + val traceMetadata = traces.getOrElse(traceId) { throw UnknownTraceIdException(traceId) } + val data = pollTrace(traceId) + traceMetadata.stopTrace() + traces.remove(traceId) + return data + } + + /** + * Returns the [MotionWindowData] captured since the [beginTrace] call or last [pollTrace] call. + * This function can only be used after [beginTrace] is called and before [endTrace] is called. + */ + @Synchronized + fun pollTrace(traceId: Int): MotionWindowData { + val traceMetadata = traces.getOrElse(traceId) { throw UnknownTraceIdException(traceId) } + val data = getDataFromViewCapture(traceMetadata) + traceMetadata.updateLastPolledTime(data) + return data + } + + /** + * Stops and deletes all active [traces] and resets the [traceIdCounter]. + */ + @VisibleForTesting + @Synchronized + fun reset() { + for (traceMetadata in traces.values) { + traceMetadata.stopTrace() + } + traces.clear() + traceIdCounter = 0 + } + + private fun getDataFromViewCapture(traceMetadata: TraceMetadata): MotionWindowData { + val rootView = + getRootView(traceMetadata.windowId) + ?: throw WindowNotFoundException(traceMetadata.windowId) + + val data: MotionWindowData = viewCapture + .getDumpTask(rootView).get() + ?.orElse(null) ?: return MotionWindowData.newBuilder().build() + val filteredFrameData = data.frameDataList.filter { + it.timestamp > traceMetadata.lastPolledTime + } + return data.toBuilder() + .clearFrameData() + .addAllFrameData(filteredFrameData) + .build() + } + + private fun getRootView(windowId: String): View? { + return windowManagerGlobal.getRootView(windowId) + } +} + +private data class TraceMetadata( + val windowId: String, + var lastPolledTime: Long, + var stopTrace: () -> Unit +) { + fun updateLastPolledTime(data: MotionWindowData?) { + data?.frameDataList?.maxOfOrNull { it.timestamp }?.let { + lastPolledTime = it + } + } +} + +class UnknownTraceIdException(val traceId: Int) : Exception() + +class WindowNotFoundException(val windowId: String) : Exception()
\ No newline at end of file diff --git a/motiontoollib/src/com/android/app/motiontool/proto/motion_tool.proto b/motiontoollib/src/com/android/app/motiontool/proto/motion_tool.proto new file mode 100644 index 0000000..04ee020 --- /dev/null +++ b/motiontoollib/src/com/android/app/motiontool/proto/motion_tool.proto @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +syntax = "proto2"; + +package com.android.app.motiontool; + +import "view_capture.proto"; + +option java_multiple_files = true; + +message MotionToolsRequest { + oneof type { + HandshakeRequest handshake = 1; + BeginTraceRequest begin_trace = 2; + EndTraceRequest end_trace = 3; + PollTraceRequest poll_trace = 4; + } +} + +// RPC response messages. +// +// Returns the result from the corresponding request. +message MotionToolsResponse { + oneof type { + // Contains error information whenever the request failed. + ErrorResponse error = 1; + + HandshakeResponse handshake = 2; + BeginTraceResponse begin_trace = 3; + EndTraceResponse end_trace = 4; + PollTraceResponse poll_trace = 5; + } +} + +message ErrorResponse { + enum Code { + UNKNOWN = 0; + INVALID_REQUEST = 1; + UNKNOWN_TRACE_ID = 2; + WINDOW_NOT_FOUND = 3; + } + + optional Code code = 1; + // Human readable error message. + optional string message = 2; +} + +// Identifies the window, in which context the motion tools are executed +message WindowIdentifier { + // An identifier for the root view, as accepted by + // WindowManagerGlobal#getRootView. This is formatted as + // `windowName/rootViewClassName@rootViewIdentityHashCode`, + // for example `NotificationShade/android.view.ViewRootImpl@bab6a53`. + optional string root_window = 1; +} + +// Verifies the motion tools are available for the specified window. +message HandshakeRequest { + optional WindowIdentifier window = 1; + optional int32 client_version = 2; +} + +message HandshakeResponse { + enum Status { + OK = 1; + WINDOW_NOT_FOUND = 2; + } + optional Status status = 1; + optional int32 server_version = 2; +} + +// Enables motion tracing for the specified window +message BeginTraceRequest { + optional WindowIdentifier window = 1; +} + +message BeginTraceResponse { + optional int32 trace_id = 1; +} + +// Disabled motion tracing for the specified window +message EndTraceRequest { + optional int32 trace_id = 1; +} + +message EndTraceResponse { + optional com.android.app.viewcapture.data.MotionWindowData data = 1; +} + +// Polls collected motion trace data collected since the last PollTraceRequest (or the +// BeginTraceRequest) +message PollTraceRequest { + optional int32 trace_id = 1; +} + +message PollTraceResponse { + optional com.android.app.viewcapture.data.MotionWindowData data = 1; +} + diff --git a/motiontoollib/tests/AndroidManifest.xml b/motiontoollib/tests/AndroidManifest.xml new file mode 100644 index 0000000..c16e25f --- /dev/null +++ b/motiontoollib/tests/AndroidManifest.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + Copyright (C) 2022 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.app.motiontool.tests"> + + <application + android:debuggable="true" + android:theme="@android:style/Theme.NoTitleBar"> + + <activity + android:name="com.android.app.motiontool.util.TestActivity" + android:exported="false" /> + + <uses-library android:name="android.test.runner" /> + + </application> + + <instrumentation + android:name="android.testing.TestableInstrumentation" + android:label="Tests for MotionTool Lib" + android:targetPackage="com.android.app.motiontool.tests"/> + +</manifest> diff --git a/motiontoollib/tests/com/android/app/motiontool/DdmHandleMotionToolTest.kt b/motiontoollib/tests/com/android/app/motiontool/DdmHandleMotionToolTest.kt new file mode 100644 index 0000000..f330980 --- /dev/null +++ b/motiontoollib/tests/com/android/app/motiontool/DdmHandleMotionToolTest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.app.motiontool + +import android.content.Intent +import android.testing.AndroidTestingRunner +import android.view.Choreographer +import android.view.View +import android.view.WindowManagerGlobal +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.app.motiontool.DdmHandleMotionTool.Companion.CHUNK_MOTO +import com.android.app.motiontool.util.TestActivity +import junit.framework.Assert +import junit.framework.Assert.assertEquals +import org.apache.harmony.dalvik.ddmc.Chunk +import org.apache.harmony.dalvik.ddmc.ChunkHandler.wrapChunk +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class DdmHandleMotionToolTest { + + private val windowManagerGlobal = WindowManagerGlobal.getInstance() + private val motionToolManager = MotionToolManager.getInstance(windowManagerGlobal) + private val ddmHandleMotionTool = DdmHandleMotionTool.getInstance(motionToolManager) + private val CLIENT_VERSION = 1 + + private val activityIntent = + Intent(InstrumentationRegistry.getInstrumentation().context, TestActivity::class.java) + + @get:Rule + val activityScenarioRule = ActivityScenarioRule<TestActivity>(activityIntent) + + @Before + fun setup() { + ddmHandleMotionTool.register() + } + + @After + fun cleanup() { + ddmHandleMotionTool.unregister() + } + + @Test + fun testHandshakeErrorWithInvalidWindowId() { + val handshakeResponse = performHandshakeRequest("InvalidWindowId") + assertEquals(HandshakeResponse.Status.WINDOW_NOT_FOUND, handshakeResponse.handshake.status) + } + + @Test + fun testHandshakeOkWithValidWindowId() { + val handshakeResponse = performHandshakeRequest(getActivityViewRootId()) + assertEquals(HandshakeResponse.Status.OK, handshakeResponse.handshake.status) + } + + @Test + fun testBeginFailsWithInvalidWindowId() { + val errorResponse = performBeginTraceRequest("InvalidWindowId") + assertEquals(ErrorResponse.Code.WINDOW_NOT_FOUND, errorResponse.error.code) + } + + @Test + fun testEndTraceFailsWithoutPrecedingBeginTrace() { + val errorResponse = performEndTraceRequest(0) + assertEquals(ErrorResponse.Code.UNKNOWN_TRACE_ID, errorResponse.error.code) + } + + @Test + fun testPollTraceFailsWithoutPrecedingBeginTrace() { + val errorResponse = performPollTraceRequest(0) + assertEquals(ErrorResponse.Code.UNKNOWN_TRACE_ID, errorResponse.error.code) + } + + @Test + fun testEndTraceFailsWithInvalidTraceId() { + val beginTraceResponse = performBeginTraceRequest(getActivityViewRootId()) + val endTraceResponse = performEndTraceRequest(beginTraceResponse.beginTrace.traceId + 1) + assertEquals(ErrorResponse.Code.UNKNOWN_TRACE_ID, endTraceResponse.error.code) + } + + @Test + fun testPollTraceFailsWithInvalidTraceId() { + val beginTraceResponse = performBeginTraceRequest(getActivityViewRootId()) + val endTraceResponse = performPollTraceRequest(beginTraceResponse.beginTrace.traceId + 1) + assertEquals(ErrorResponse.Code.UNKNOWN_TRACE_ID, endTraceResponse.error.code) + } + + @Test + fun testMalformedRequestFails() { + val requestBytes = ByteArray(9) + val requestChunk = Chunk(CHUNK_MOTO, requestBytes, 0, requestBytes.size) + val responseChunk = ddmHandleMotionTool.handleChunk(requestChunk) + val response = MotionToolsResponse.parseFrom(wrapChunk(responseChunk).array()).error + assertEquals(ErrorResponse.Code.INVALID_REQUEST, response.code) + } + + @Test + fun testNoOnDrawCallReturnsEmptyTrace() { + activityScenarioRule.scenario.onActivity { + val beginTraceResponse = performBeginTraceRequest(getActivityViewRootId()) + val endTraceResponse = performEndTraceRequest(beginTraceResponse.beginTrace.traceId) + Assert.assertTrue(endTraceResponse.endTrace.data.frameDataList.isEmpty()) + } + } + + @Test + fun testOneOnDrawCallReturnsOneFrameResponse() { + activityScenarioRule.scenario.onActivity { activity -> + val beginTraceResponse = performBeginTraceRequest(getActivityViewRootId()) + val traceId = beginTraceResponse.beginTrace.traceId + + Choreographer.getInstance().postFrameCallback { + activity.findViewById<View>(android.R.id.content).viewTreeObserver.dispatchOnDraw() + + val pollTraceResponse = performPollTraceRequest(traceId) + assertEquals(1, pollTraceResponse.pollTrace.data.frameDataList.size) + + // Verify that frameData is only included once and is not returned again + val endTraceResponse = performEndTraceRequest(traceId) + assertEquals(0, endTraceResponse.endTrace.data.frameDataList.size) + } + } + } + + private fun performPollTraceRequest(requestTraceId: Int): MotionToolsResponse { + val pollTraceRequest = MotionToolsRequest.newBuilder() + .setPollTrace(PollTraceRequest.newBuilder() + .setTraceId(requestTraceId)) + .build() + return performRequest(pollTraceRequest) + } + + private fun performEndTraceRequest(requestTraceId: Int): MotionToolsResponse { + val endTraceRequest = MotionToolsRequest.newBuilder() + .setEndTrace(EndTraceRequest.newBuilder() + .setTraceId(requestTraceId)) + .build() + return performRequest(endTraceRequest) + } + + private fun performBeginTraceRequest(windowId: String): MotionToolsResponse { + val beginTraceRequest = MotionToolsRequest.newBuilder() + .setBeginTrace(BeginTraceRequest.newBuilder() + .setWindow(WindowIdentifier.newBuilder() + .setRootWindow(windowId))) + .build() + return performRequest(beginTraceRequest) + } + + private fun performHandshakeRequest(windowId: String): MotionToolsResponse { + val handshakeRequest = MotionToolsRequest.newBuilder() + .setHandshake(HandshakeRequest.newBuilder() + .setWindow(WindowIdentifier.newBuilder() + .setRootWindow(windowId)) + .setClientVersion(CLIENT_VERSION)) + .build() + return performRequest(handshakeRequest) + } + + private fun performRequest(motionToolsRequest: MotionToolsRequest): MotionToolsResponse { + val requestBytes = motionToolsRequest.toByteArray() + val requestChunk = Chunk(CHUNK_MOTO, requestBytes, 0, requestBytes.size) + val responseChunk = ddmHandleMotionTool.handleChunk(requestChunk) + return MotionToolsResponse.parseFrom(wrapChunk(responseChunk).array()) + } + + private fun getActivityViewRootId(): String { + var activityViewRootId = "" + activityScenarioRule.scenario.onActivity { + activityViewRootId = WindowManagerGlobal.getInstance().viewRootNames.first() + } + return activityViewRootId + } + +} diff --git a/motiontoollib/tests/com/android/app/motiontool/MotionToolManagerTest.kt b/motiontoollib/tests/com/android/app/motiontool/MotionToolManagerTest.kt new file mode 100644 index 0000000..c522d0c --- /dev/null +++ b/motiontoollib/tests/com/android/app/motiontool/MotionToolManagerTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.app.motiontool + +import android.content.Intent +import android.testing.AndroidTestingRunner +import android.view.Choreographer +import android.view.View +import android.view.WindowManagerGlobal +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.app.motiontool.util.TestActivity +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class MotionToolManagerTest { + + private val windowManagerGlobal = WindowManagerGlobal.getInstance() + private val motionToolManager = MotionToolManager.getInstance(windowManagerGlobal) + + private val activityIntent = + Intent(InstrumentationRegistry.getInstrumentation().context, TestActivity::class.java) + + @get:Rule + val activityScenarioRule = ActivityScenarioRule<TestActivity>(activityIntent) + + @Test(expected = UnknownTraceIdException::class) + fun testEndTraceThrowsWithoutPrecedingBeginTrace() { + motionToolManager.endTrace(0) + } + + @Test(expected = UnknownTraceIdException::class) + fun testPollTraceThrowsWithoutPrecedingBeginTrace() { + motionToolManager.pollTrace(0) + } + + @Test(expected = UnknownTraceIdException::class) + fun testEndTraceThrowsWithInvalidTraceId() { + val traceId = motionToolManager.beginTrace(getActivityViewRootId()) + motionToolManager.endTrace(traceId + 1) + } + + @Test(expected = UnknownTraceIdException::class) + fun testPollTraceThrowsWithInvalidTraceId() { + val traceId = motionToolManager.beginTrace(getActivityViewRootId()) + motionToolManager.pollTrace(traceId + 1) + } + + @Test(expected = WindowNotFoundException::class) + fun testBeginTraceThrowsWithInvalidWindowId() { + motionToolManager.beginTrace("InvalidWindowId") + } + + @Test + fun testNoOnDrawCallReturnsEmptyResponse() { + activityScenarioRule.scenario.onActivity { + val traceId = motionToolManager.beginTrace(getActivityViewRootId()) + val result = motionToolManager.endTrace(traceId) + assertTrue(result.frameDataList.isEmpty()) + } + } + + @Test + fun testOneOnDrawCallReturnsOneFrameResponse() { + activityScenarioRule.scenario.onActivity { activity -> + val traceId = motionToolManager.beginTrace(getActivityViewRootId()) + Choreographer.getInstance().postFrameCallback { + activity.findViewById<View>(android.R.id.content).viewTreeObserver.dispatchOnDraw() + + val polledData = motionToolManager.pollTrace(traceId) + assertEquals(1, polledData.frameDataList.size) + + // Verify that frameData is only included once and is not returned again + val endData = motionToolManager.endTrace(traceId) + assertEquals(0, endData.frameDataList.size) + } + } + } + + private fun getActivityViewRootId(): String { + var activityViewRootId = "" + activityScenarioRule.scenario.onActivity { + activityViewRootId = WindowManagerGlobal.getInstance().viewRootNames.first() + } + return activityViewRootId + } +} diff --git a/motiontoollib/tests/com/android/app/motiontool/util/TestActivity.kt b/motiontoollib/tests/com/android/app/motiontool/util/TestActivity.kt new file mode 100644 index 0000000..a9d68ab --- /dev/null +++ b/motiontoollib/tests/com/android/app/motiontool/util/TestActivity.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.app.motiontool.util + +import android.app.Activity + +class TestActivity : Activity() diff --git a/searchuilib/Android.bp b/searchuilib/Android.bp deleted file mode 100644 index 2b25616..0000000 --- a/searchuilib/Android.bp +++ /dev/null @@ -1,28 +0,0 @@ -// 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 { - default_applicable_licenses: ["Android-Apache-2.0"], -} - -android_library { - name: "search_ui", - - sdk_version: "current", - min_sdk_version: "26", - - srcs: [ - "src/**/*.java", - ], -} diff --git a/searchuilib/build.gradle b/searchuilib/build.gradle deleted file mode 100644 index 3bf65fe..0000000 --- a/searchuilib/build.gradle +++ /dev/null @@ -1,35 +0,0 @@ -apply plugin: 'com.android.library' - -android { - compileSdkVersion COMPILE_SDK - buildToolsVersion BUILD_TOOLS_VERSION - - defaultConfig { - minSdkVersion 25 - targetSdkVersion 28 - } - - sourceSets { - main { - java.srcDirs = ['src'] - manifest.srcFile 'AndroidManifest.xml' - } - } - - lintOptions { - abortOnError false - } - - tasks.withType(JavaCompile) { - options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } -} - -dependencies { - implementation "androidx.core:core:${ANDROID_X_VERSION}" -} diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java deleted file mode 100644 index d7c28ab..0000000 --- a/searchuilib/src/com/android/app/search/LayoutType.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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.android.app.search; - -/** - * Constants to be used with {@link SearchTarget}. - */ -public class LayoutType { - - // ------ - // | icon | - // ------ - // text - public static final String ICON_SINGLE_VERTICAL_TEXT = "icon"; - - // Below three layouts (to be deprecated) and two layouts render - // {@link SearchTarget}s in following layout. - // ------ ------ ------ - // | | title |(opt)| |(opt)| - // | icon | subtitle (optional) | icon| | icon| - // ------ ------ ------ - @Deprecated - public static final String ICON_SINGLE_HORIZONTAL_TEXT = "icon_text_row"; - @Deprecated - public static final String ICON_DOUBLE_HORIZONTAL_TEXT = "icon_texts_row"; - @Deprecated - public static final String ICON_DOUBLE_HORIZONTAL_TEXT_BUTTON = "icon_texts_button"; - - // will replace ICON_DOUBLE_* ICON_SINGLE_* layouts - public static final String ICON_HORIZONTAL_TEXT = "icon_row"; - public static final String HORIZONTAL_MEDIUM_TEXT = "icon_row_medium"; - public static final String EXTRA_TALL_ICON_ROW = "extra_tall_icon_row"; - public static final String SMALL_ICON_HORIZONTAL_TEXT = "short_icon_row"; - public static final String SMALL_ICON_HORIZONTAL_TEXT_THUMBNAIL = "short_icon_row_thumbnail"; - - // This layout creates square thumbnail image (currently 3 column) - public static final String THUMBNAIL = "thumbnail"; - - // This layout contains an icon and slice - public static final String ICON_SLICE = "slice"; - - // Widget bitmap preview - public static final String WIDGET_PREVIEW = "widget_preview"; - - // Live widget search result - public static final String WIDGET_LIVE = "widget_live"; - - // Layout type used to display people tiles using shortcut info - public static final String PEOPLE_TILE = "people_tile"; - - // text based header to group various layouts in low confidence section of the results. - public static final String TEXT_HEADER = "header"; - - // horizontal bar to be inserted between fallback search results and low confidence section - public static final String DIVIDER = "divider"; - - // horizontal bar to be inserted between fallback search results and low confidence section - public static final String EMPTY_DIVIDER = "empty_divider"; - - // layout representing quick calculations - public static final String CALCULATOR = "calculator"; - - // layout for the section header - public static final String SECTION_HEADER = "section_header"; -} diff --git a/searchuilib/src/com/android/app/search/ResultType.java b/searchuilib/src/com/android/app/search/ResultType.java deleted file mode 100644 index 1e5ea12..0000000 --- a/searchuilib/src/com/android/app/search/ResultType.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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.android.app.search; - -/** - * Constants to be used with {@link android.app.search.SearchContext} and - * {@link android.app.search.SearchTarget}. - * - * Note, a result type could be a of two types. - * For example, unpublished settings result type could be in slices: - * <code> resultType = SETTING | SLICE </code> - */ -public class ResultType { - - // published corpus by 3rd party app, supported by SystemService - public static final int APPLICATION = 1 << 0; - public static final int SHORTCUT = 1 << 1; - public static final int SLICE = 1 << 6; - public static final int WIDGETS = 1 << 7; - - // Not extracted from any of the SystemService - public static final int PEOPLE = 1 << 2; - public static final int ACTION = 1 << 3; - public static final int SETTING = 1 << 4; - public static final int SCREENSHOT = 1 << 5; - public static final int PLAY = 1 << 8; - public static final int SUGGEST = 1 << 9; - public static final int ASSISTANT = 1 << 10; - public static final int CHROMETAB = 1 << 11; - public static final int NAVVYSITE = 1 << 12; - public static final int TIPS = 1 << 13; - public static final int PEOPLE_TILE = 1 << 14; - public static final int LEGACY_SHORTCUT = 1 << 15; - public static final int MEMORY = 1 << 16; - public static final int WEB_SUGGEST = 1 << 17; -} diff --git a/viewcapturelib/.gitignore b/viewcapturelib/.gitignore new file mode 100644 index 0000000..6213826 --- /dev/null +++ b/viewcapturelib/.gitignore @@ -0,0 +1,13 @@ +*.iml +.project +.classpath +.project.properties +gen/ +bin/ +.idea/ +.gradle/ +local.properties +gradle/ +build/ +gradlew* +.DS_Store diff --git a/viewcapturelib/Android.bp b/viewcapturelib/Android.bp new file mode 100644 index 0000000..33da2dd --- /dev/null +++ b/viewcapturelib/Android.bp @@ -0,0 +1,73 @@ +// Copyright (C) 2022 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +java_library { + name: "view_capture_proto", + srcs: ["src/com/android/app/viewcapture/proto/*.proto"], + proto: { + type: "lite", + local_include_dirs:[ + "src/com/android/app/viewcapture/proto" + ], + }, + static_libs: ["libprotobuf-java-lite"], + java_version: "1.8", +} + +android_library { + name: "view_capture", + manifest: "AndroidManifest.xml", + platform_apis: true, + min_sdk_version: "26", + + static_libs: [ + "androidx.core_core", + "view_capture_proto", + ], + + srcs: [ + "src/**/*.java", + "src/**/*.kt" + ], +} + +android_test { + name: "view_capture_tests", + manifest: "tests/AndroidManifest.xml", + platform_apis: true, + min_sdk_version: "26", + + static_libs: [ + "androidx.core_core", + "view_capture", + "androidx.test.ext.junit", + "androidx.test.rules", + "testables", + "mockito-target-extended-minus-junit4", + ], + srcs: [ + "**/*.java", + "**/*.kt" + ], + libs: [ + "android.test.runner", + "android.test.base", + "android.test.mock", + ], + test_suites: ["device-tests"], +} diff --git a/viewcapturelib/AndroidManifest.xml b/viewcapturelib/AndroidManifest.xml new file mode 100644 index 0000000..1da8129 --- /dev/null +++ b/viewcapturelib/AndroidManifest.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2022 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + package="com.android.app.viewcapture"> + <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" + tools:ignore="ProtectedPermissions" /> +</manifest> diff --git a/viewcapturelib/OWNERS b/viewcapturelib/OWNERS new file mode 100644 index 0000000..30bdc84 --- /dev/null +++ b/viewcapturelib/OWNERS @@ -0,0 +1,2 @@ +sunnygoyal@google.com +andonian@google.com diff --git a/viewcapturelib/README.md b/viewcapturelib/README.md new file mode 100644 index 0000000..4a6993f --- /dev/null +++ b/viewcapturelib/README.md @@ -0,0 +1,11 @@ +###ViewCapture Library Readme + +ViewCapture.java is extremely performance sensitive. Any changes should be carried out with great caution not to hurt performance. + +The following measurements should serve as a performance baseline (as of 02.10.2022): + + +The onDraw() function invocation time in WindowListener within ViewCapture is measured with System.nanoTime(). The following scenario was measured: + +1. Capturing the notification shade window root view on a freshly rebooted bluejay device (2 notifications present) -> avg. time = 204237ns (0.2ms) + diff --git a/viewcapturelib/TEST_MAPPING b/viewcapturelib/TEST_MAPPING new file mode 100644 index 0000000..ecd3e96 --- /dev/null +++ b/viewcapturelib/TEST_MAPPING @@ -0,0 +1,15 @@ +{ + "presubmit": [ + { + "name": "view_capture_tests", + "options": [ + { + "exclude-annotation": "org.junit.Ignore" + }, + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] + } + ] +} diff --git a/viewcapturelib/build.gradle b/viewcapturelib/build.gradle new file mode 100644 index 0000000..e5442cc --- /dev/null +++ b/viewcapturelib/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'com.google.protobuf' +} + +final String PROTOS_DIR = "${ANDROID_TOP}/frameworks/libs/systemui/viewcapturelib/src/com/android/app/viewcapture/proto" + +android { + namespace = "com.android.app.viewcapture" + testNamespace = "com.android.app.viewcapture.test" + defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + sourceSets { + main { + java.srcDirs = ['src'] + manifest.srcFile 'AndroidManifest.xml' + proto.srcDirs = ["${PROTOS_DIR}"] + } + androidTest { + java.srcDirs = ["tests"] + manifest.srcFile "tests/AndroidManifest.xml" + } + } + lint { + abortOnError false + } + +} + +dependencies { + implementation "androidx.core:core:1.9.0" + implementation "com.google.protobuf:protobuf-lite:${protobuf_lite_version}" + androidTestImplementation project(':SharedTestLib') + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation "androidx.test:rules:1.4.0" +} + +protobuf { + // Configure the protoc executable + protoc { + artifact = "com.google.protobuf:protoc:${protobuf_version}${PROTO_ARCH_SUFFIX}" + } + plugins { + javalite { + // The codegen for lite comes as a separate artifact + artifact = "com.google.protobuf:protoc-gen-javalite:${protobuf_version}${PROTO_ARCH_SUFFIX}" + } + } + generateProtoTasks { + all().each { task -> + task.builtins { + remove java + } + task.plugins { + javalite { } + } + } + } +}
\ No newline at end of file diff --git a/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java b/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java new file mode 100644 index 0000000..e3450f6 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.app.viewcapture; + +import android.os.Handler; +import android.os.Looper; + +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.RunnableFuture; + +/** + * Implementation of {@link Executor} which executes on a provided looper. + */ +public class LooperExecutor implements Executor { + + private final Handler mHandler; + + public LooperExecutor(Looper looper) { + mHandler = new Handler(looper); + } + + @Override + public void execute(Runnable runnable) { + if (mHandler.getLooper() == Looper.myLooper()) { + runnable.run(); + } else { + mHandler.post(runnable); + } + } + + /** + * @throws RejectedExecutionException {@inheritDoc} + * @throws NullPointerException {@inheritDoc} + */ + public <T> Future<T> submit(Callable<T> task) { + if (task == null) throw new NullPointerException(); + RunnableFuture<T> ftask = new FutureTask<T>(task); + execute(ftask); + return ftask; + } + +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt new file mode 100644 index 0000000..8a3cf1c --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2023 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.android.app.viewcapture + +import android.content.Context +import android.content.pm.LauncherApps +import android.database.ContentObserver +import android.os.Handler +import android.os.Looper +import android.os.ParcelFileDescriptor +import android.os.Process +import android.provider.Settings +import android.util.Log +import android.view.Choreographer +import android.window.IDumpCallback +import androidx.annotation.AnyThread +import androidx.annotation.VisibleForTesting +import java.util.concurrent.Executor + +private val TAG = SettingsAwareViewCapture::class.java.simpleName + +/** + * ViewCapture that listens to system updates and enables / disables attached ViewCapture + * WindowListeners accordingly. The Settings toggle is currently controlled by the Winscope + * developer tile in the System developer options. + */ +class SettingsAwareViewCapture +@VisibleForTesting +internal constructor(private val context: Context, choreographer: Choreographer, executor: Executor) + : ViewCapture(DEFAULT_MEMORY_SIZE, DEFAULT_INIT_POOL_SIZE, choreographer, executor) { + /** Dumps all the active view captures to the wm trace directory via LauncherAppService */ + private val mDumpCallback: IDumpCallback.Stub = object : IDumpCallback.Stub() { + override fun onDump(out: ParcelFileDescriptor) { + try { + ParcelFileDescriptor.AutoCloseOutputStream(out).use { os -> dumpTo(os, context) } + } catch (e: Exception) { + Log.e(TAG, "failed to dump data to wm trace", e) + } + } + } + + init { + enableOrDisableWindowListeners() + context.contentResolver.registerContentObserver( + Settings.Global.getUriFor(VIEW_CAPTURE_ENABLED), + false, + object : ContentObserver(Handler()) { + override fun onChange(selfChange: Boolean) { + enableOrDisableWindowListeners() + } + }) + } + + @AnyThread + private fun enableOrDisableWindowListeners() { + mBgExecutor.execute { + val isEnabled = Settings.Global.getInt(context.contentResolver, VIEW_CAPTURE_ENABLED, + 0) != 0 + MAIN_EXECUTOR.execute { + enableOrDisableWindowListeners(isEnabled) + } + val launcherApps = context.getSystemService(LauncherApps::class.java) + if (isEnabled) { + launcherApps?.registerDumpCallback(mDumpCallback) + } else { + launcherApps?.unRegisterDumpCallback(mDumpCallback) + } + } + } + + companion object { + @VisibleForTesting internal const val VIEW_CAPTURE_ENABLED = "view_capture_enabled" + + private var INSTANCE: ViewCapture? = null + + @JvmStatic + fun getInstance(context: Context): ViewCapture = when { + INSTANCE != null -> INSTANCE!! + Looper.myLooper() == Looper.getMainLooper() -> SettingsAwareViewCapture( + context.applicationContext, Choreographer.getInstance(), + createAndStartNewLooperExecutor("SAViewCapture", + Process.THREAD_PRIORITY_FOREGROUND)).also { INSTANCE = it } + else -> try { + MAIN_EXECUTOR.submit { getInstance(context) }.get() + } catch (e: Exception) { + throw e + } + } + + } +}
\ No newline at end of file diff --git a/viewcapturelib/src/com/android/app/viewcapture/SimpleViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/SimpleViewCapture.kt new file mode 100644 index 0000000..2773f6b --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/SimpleViewCapture.kt @@ -0,0 +1,8 @@ +package com.android.app.viewcapture + +import android.os.Process +import android.view.Choreographer + +open class SimpleViewCapture(threadName: String) : ViewCapture(DEFAULT_MEMORY_SIZE, DEFAULT_INIT_POOL_SIZE, + MAIN_EXECUTOR.submit { Choreographer.getInstance() }.get(), + createAndStartNewLooperExecutor(threadName, Process.THREAD_PRIORITY_FOREGROUND))
\ No newline at end of file diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java b/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java new file mode 100644 index 0000000..fb5abd6 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java @@ -0,0 +1,614 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.app.viewcapture; + +import android.content.Context; +import android.content.res.Resources; +import android.media.permission.SafeCloseable; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Trace; +import android.text.TextUtils; +import android.util.SparseArray; +import android.view.Choreographer; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.Window; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; + +import com.android.app.viewcapture.data.ExportedData; +import com.android.app.viewcapture.data.FrameData; +import com.android.app.viewcapture.data.MotionWindowData; +import com.android.app.viewcapture.data.ViewNode; +import com.android.app.viewcapture.data.WindowData; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.function.Predicate; + +/** + * Utility class for capturing view data every frame + */ +public abstract class ViewCapture { + + private static final String TAG = "ViewCapture"; + + // These flags are copies of two private flags in the View class. + private static final int PFLAG_INVALIDATED = 0x80000000; + private static final int PFLAG_DIRTY_MASK = 0x00200000; + + // Number of frames to keep in memory + private final int mMemorySize; + protected static final int DEFAULT_MEMORY_SIZE = 2000; + // Initial size of the reference pool. This is at least be 5 * total number of views in + // Launcher. This allows the first free frames avoid object allocation during view capture. + protected static final int DEFAULT_INIT_POOL_SIZE = 300; + + public static final LooperExecutor MAIN_EXECUTOR = new LooperExecutor(Looper.getMainLooper()); + + private final List<WindowListener> mListeners = new ArrayList<>(); + + protected final Executor mBgExecutor; + private final Choreographer mChoreographer; + + // Pool used for capturing view tree on the UI thread. + private ViewRef mPool = new ViewRef(); + private boolean mIsEnabled = true; + + protected ViewCapture(int memorySize, int initPoolSize, Choreographer choreographer, + Executor bgExecutor) { + mMemorySize = memorySize; + mChoreographer = choreographer; + mBgExecutor = bgExecutor; + mBgExecutor.execute(() -> initPool(initPoolSize)); + } + + public static LooperExecutor createAndStartNewLooperExecutor(String name, int priority) { + HandlerThread thread = new HandlerThread(name, priority); + thread.start(); + return new LooperExecutor(thread.getLooper()); + } + + @UiThread + private void addToPool(ViewRef start, ViewRef end) { + end.next = mPool; + mPool = start; + } + + @WorkerThread + private void initPool(int initPoolSize) { + ViewRef start = new ViewRef(); + ViewRef current = start; + + for (int i = 0; i < initPoolSize; i++) { + current.next = new ViewRef(); + current = current.next; + } + + ViewRef finalCurrent = current; + MAIN_EXECUTOR.execute(() -> addToPool(start, finalCurrent)); + } + + /** + * Attaches the ViewCapture to the provided window and returns a handle to detach the listener + */ + @NonNull + public SafeCloseable startCapture(Window window) { + String title = window.getAttributes().getTitle().toString(); + String name = TextUtils.isEmpty(title) ? window.toString() : title; + return startCapture(window.getDecorView(), name); + } + + /** + * Attaches the ViewCapture to the provided window and returns a handle to detach the listener. + * Verifies that ViewCapture is enabled before actually attaching an onDrawListener. + */ + @NonNull + public SafeCloseable startCapture(View view, String name) { + WindowListener listener = new WindowListener(view, name); + if (mIsEnabled) MAIN_EXECUTOR.execute(listener::attachToRoot); + mListeners.add(listener); + return () -> { + mListeners.remove(listener); + listener.detachFromRoot(); + }; + } + + /** + * Launcher checks for leaks in many spots during its instrumented tests. The WindowListeners + * appear to have leaks because they store mRoot views. In reality, attached views close their + * respective window listeners when they are destroyed. + * <p> + * This method deletes detaches and deletes mRoot views from windowListeners. This makes the + * WindowListeners unusable for anything except dumping previously captured information. They + * are still technically enabled to allow for dumping. + */ + @VisibleForTesting + public void stopCapture(@NonNull View rootView) { + mListeners.forEach(it -> { + if (rootView == it.mRoot) { + it.mRoot.getViewTreeObserver().removeOnDrawListener(it); + it.mRoot = null; + } + }); + } + + @UiThread + protected void enableOrDisableWindowListeners(boolean isEnabled) { + mIsEnabled = isEnabled; + mListeners.forEach(WindowListener::detachFromRoot); + if (mIsEnabled) mListeners.forEach(WindowListener::attachToRoot); + } + + @AnyThread + public void dumpTo(OutputStream os, Context context) + throws InterruptedException, ExecutionException, IOException { + if (!mIsEnabled) { + return; + } + ArrayList<Class> classList = new ArrayList<>(); + ExportedData.newBuilder() + .setPackage(context.getPackageName()) + .addAllWindowData(getWindowData(context, classList, l -> l.mIsActive).get()) + .addAllClassname(toStringList(classList)) + .build() + .writeTo(os); + } + + private static List<String> toStringList(List<Class> classList) { + return classList.stream().map(Class::getName).toList(); + } + + public CompletableFuture<Optional<MotionWindowData>> getDumpTask(View view) { + ArrayList<Class> classList = new ArrayList<>(); + return getWindowData(view.getContext().getApplicationContext(), classList, + l -> l.mRoot.equals(view)).thenApply(list -> list.stream().findFirst().map(w -> + MotionWindowData.newBuilder() + .addAllFrameData(w.getFrameDataList()) + .addAllClassname(toStringList(classList)) + .build())); + } + + @AnyThread + private CompletableFuture<List<WindowData>> getWindowData(Context context, + ArrayList<Class> outClassList, Predicate<WindowListener> filter) { + ViewIdProvider idProvider = new ViewIdProvider(context.getResources()); + return CompletableFuture.supplyAsync(() -> + mListeners.stream().filter(filter).toList(), MAIN_EXECUTOR).thenApplyAsync(it -> + it.stream().map(l -> l.dumpToProto(idProvider, outClassList)).toList(), + mBgExecutor); + } + + + /** + * Once this window listener is attached to a window's root view, it traverses the entire + * view tree on the main thread every time onDraw is called. It then saves the state of the view + * tree traversed in a local list of nodes, so that this list of nodes can be processed on a + * background thread, and prepared for being dumped into a bugreport. + * + * Since some of the work needs to be done on the main thread after every draw, this piece of + * code needs to be hyper optimized. That is why we are recycling ViewRef and ViewPropertyRef + * objects and storing the list of nodes as a flat LinkedList, rather than as a tree. This data + * structure allows recycling to happen in O(1) time via pointer assignment. Without this + * optimization, a lot of time is wasted creating ViewRef objects, or finding ViewRef objects to + * recycle. + * + * Another optimization is to only traverse view nodes on the main thread that have potentially + * changed since the last frame was drawn. This can be determined via a combination of private + * flags inside the View class. + * + * Another optimization is to not store or manipulate any string objects on the main thread. + * While this might seem trivial, using Strings in any form causes the ViewCapture to hog the + * main thread for up to an additional 6-7ms. It must be avoided at all costs. + * + * Another optimization is to only store the class names of the Views in the view hierarchy one + * time. They are then referenced via a classNameIndex value stored in each ViewPropertyRef. + * + * TODO: b/262585897: If further memory optimization is required, an effective one would be to + * only store the changes between frames, rather than the entire node tree for each frame. + * The go/web-hv UX already does this, and has reaped significant memory improves because of it. + * + * TODO: b/262585897: Another memory optimization could be to store all integer, float, and + * boolean information via single integer values via the Chinese remainder theorem, or a similar + * algorithm, which enables multiple numerical values to be stored inside 1 number. Doing this + * would allow each ViewProperty / ViewRef to slim down its memory footprint significantly. + * + * One important thing to remember is that bugs related to recycling will usually only appear + * after at least 2000 frames have been rendered. If that code is changed, the tester can + * use hard-coded logs to verify that recycling is happening, and test view capturing at least + * ~8000 frames or so to verify the recycling functionality is working properly. + */ + private class WindowListener implements ViewTreeObserver.OnDrawListener { + + @Nullable // Nullable in tests only + public View mRoot; + public final String name; + + private final ViewRef mViewRef = new ViewRef(); + + private int mFrameIndexBg = -1; + private boolean mIsFirstFrame = true; + private final long[] mFrameTimesNanosBg = new long[mMemorySize]; + private final ViewPropertyRef[] mNodesBg = new ViewPropertyRef[mMemorySize]; + + private boolean mIsActive = true; + private final Consumer<ViewRef> mCaptureCallback = this::captureViewPropertiesBg; + + WindowListener(View view, String name) { + mRoot = view; + this.name = name; + } + + /** + * Every time onDraw is called, it does the minimal set of work required on the main thread, + * i.e. capturing potentially dirty / invalidated views, and then immediately offloads the + * rest of the processing work (extracting the captured view properties) to a background + * thread via mExecutor. + */ + @Override + public void onDraw() { + Trace.beginSection("view_capture"); + captureViewTree(mRoot, mViewRef); + ViewRef captured = mViewRef.next; + if (captured != null) { + captured.callback = mCaptureCallback; + captured.choreographerTimeNanos = mChoreographer.getFrameTimeNanos(); + mBgExecutor.execute(captured); + } + mIsFirstFrame = false; + Trace.endSection(); + } + + /** + * Captures the View property on the background thread, and transfer all the ViewRef objects + * back to the pool + */ + @WorkerThread + private void captureViewPropertiesBg(ViewRef viewRefStart) { + long choreographerTimeNanos = viewRefStart.choreographerTimeNanos; + mFrameIndexBg++; + if (mFrameIndexBg >= mMemorySize) { + mFrameIndexBg = 0; + } + mFrameTimesNanosBg[mFrameIndexBg] = choreographerTimeNanos; + + ViewPropertyRef recycle = mNodesBg[mFrameIndexBg]; + + ViewPropertyRef resultStart = null; + ViewPropertyRef resultEnd = null; + + ViewRef viewRefEnd = viewRefStart; + while (viewRefEnd != null) { + ViewPropertyRef propertyRef = recycle; + if (propertyRef == null) { + propertyRef = new ViewPropertyRef(); + } else { + recycle = recycle.next; + propertyRef.next = null; + } + + ViewPropertyRef copy = null; + if (viewRefEnd.childCount < 0) { + copy = findInLastFrame(viewRefEnd.view.hashCode()); + viewRefEnd.childCount = (copy != null) ? copy.childCount : 0; + } + viewRefEnd.transferTo(propertyRef); + + if (resultStart == null) { + resultStart = propertyRef; + resultEnd = resultStart; + } else { + resultEnd.next = propertyRef; + resultEnd = resultEnd.next; + } + + if (copy != null) { + int pending = copy.childCount; + while (pending > 0) { + copy = copy.next; + pending = pending - 1 + copy.childCount; + + propertyRef = recycle; + if (propertyRef == null) { + propertyRef = new ViewPropertyRef(); + } else { + recycle = recycle.next; + propertyRef.next = null; + } + + copy.transferTo(propertyRef); + + resultEnd.next = propertyRef; + resultEnd = resultEnd.next; + } + } + + if (viewRefEnd.next == null) { + // The compiler will complain about using a non-final variable from + // an outer class in a lambda if we pass in viewRefEnd directly. + final ViewRef finalViewRefEnd = viewRefEnd; + MAIN_EXECUTOR.execute(() -> addToPool(viewRefStart, finalViewRefEnd)); + break; + } + viewRefEnd = viewRefEnd.next; + } + mNodesBg[mFrameIndexBg] = resultStart; + } + + private @Nullable ViewPropertyRef findInLastFrame(int hashCode) { + int lastFrameIndex = (mFrameIndexBg == 0) ? mMemorySize - 1 : mFrameIndexBg - 1; + ViewPropertyRef viewPropertyRef = mNodesBg[lastFrameIndex]; + while (viewPropertyRef != null && viewPropertyRef.hashCode != hashCode) { + viewPropertyRef = viewPropertyRef.next; + } + return viewPropertyRef; + } + + void attachToRoot() { + mIsActive = true; + if (mRoot.isAttachedToWindow()) { + safelyEnableOnDrawListener(); + } else { + mRoot.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + if (mIsActive) { + safelyEnableOnDrawListener(); + } + mRoot.removeOnAttachStateChangeListener(this); + } + + @Override + public void onViewDetachedFromWindow(View v) { + } + }); + } + } + + void detachFromRoot() { + mIsActive = false; + if (mRoot != null) { + mRoot.getViewTreeObserver().removeOnDrawListener(this); + } + } + + private void safelyEnableOnDrawListener() { + mRoot.getViewTreeObserver().removeOnDrawListener(this); + mRoot.getViewTreeObserver().addOnDrawListener(this); + } + + @WorkerThread + private WindowData dumpToProto(ViewIdProvider idProvider, ArrayList<Class> classList) { + WindowData.Builder builder = WindowData.newBuilder().setTitle(name); + int size = (mNodesBg[mMemorySize - 1] == null) ? mFrameIndexBg + 1 : mMemorySize; + for (int i = size - 1; i >= 0; i--) { + int index = (mMemorySize + mFrameIndexBg - i) % mMemorySize; + ViewNode.Builder nodeBuilder = ViewNode.newBuilder(); + mNodesBg[index].toProto(idProvider, classList, nodeBuilder); + FrameData.Builder frameDataBuilder = FrameData.newBuilder() + .setNode(nodeBuilder) + .setTimestamp(mFrameTimesNanosBg[index]); + builder.addFrameData(frameDataBuilder); + } + return builder.build(); + } + + private ViewRef captureViewTree(View view, ViewRef start) { + ViewRef ref; + if (mPool != null) { + ref = mPool; + mPool = mPool.next; + ref.next = null; + } else { + ref = new ViewRef(); + } + ref.view = view; + start.next = ref; + if (view instanceof ViewGroup) { + ViewGroup parent = (ViewGroup) view; + // If a view has not changed since the last frame, we will copy + // its children from the last processed frame's data. + if ((view.mPrivateFlags & (PFLAG_INVALIDATED | PFLAG_DIRTY_MASK)) == 0 + && !mIsFirstFrame) { + // A negative child count is the signal to copy this view from the last frame. + ref.childCount = -parent.getChildCount(); + return ref; + } + ViewRef result = ref; + int childCount = ref.childCount = parent.getChildCount(); + for (int i = 0; i < childCount; i++) { + result = captureViewTree(parent.getChildAt(i), result); + } + return result; + } else { + ref.childCount = 0; + return ref; + } + } + } + + private static class ViewPropertyRef { + // We store reference in memory to avoid generating and storing too many strings + public Class clazz; + public int hashCode; + public int childCount = 0; + + public int id; + public int left, top, right, bottom; + public int scrollX, scrollY; + + public float translateX, translateY; + public float scaleX, scaleY; + public float alpha; + public float elevation; + + public int visibility; + public boolean willNotDraw; + public boolean clipChildren; + + public ViewPropertyRef next; + + public void transferTo(ViewPropertyRef out) { + out.clazz = this.clazz; + out.hashCode = this.hashCode; + out.childCount = this.childCount; + out.id = this.id; + out.left = this.left; + out.top = this.top; + out.right = this.right; + out.bottom = this.bottom; + out.scrollX = this.scrollX; + out.scrollY = this.scrollY; + out.scaleX = this.scaleX; + out.scaleY = this.scaleY; + out.translateX = this.translateX; + out.translateY = this.translateY; + out.alpha = this.alpha; + out.visibility = this.visibility; + out.willNotDraw = this.willNotDraw; + out.clipChildren = this.clipChildren; + out.elevation = this.elevation; + } + + /** + * Converts the data to the proto representation and returns the next property ref + * at the end of the iteration. + */ + public ViewPropertyRef toProto(ViewIdProvider idProvider, ArrayList<Class> classList, + ViewNode.Builder viewNode) { + int classnameIndex = classList.indexOf(clazz); + if (classnameIndex < 0) { + classnameIndex = classList.size(); + classList.add(clazz); + } + + viewNode.setClassnameIndex(classnameIndex) + .setHashcode(hashCode) + .setId(idProvider.getName(id)) + .setLeft(left) + .setTop(top) + .setWidth(right - left) + .setHeight(bottom - top) + .setTranslationX(translateX) + .setTranslationY(translateY) + .setScrollX(scrollX) + .setScrollY(scrollY) + .setScaleX(scaleX) + .setScaleY(scaleY) + .setAlpha(alpha) + .setVisibility(visibility) + .setWillNotDraw(willNotDraw) + .setElevation(elevation) + .setClipChildren(clipChildren); + + ViewPropertyRef result = next; + for (int i = 0; (i < childCount) && (result != null); i++) { + ViewNode.Builder childViewNode = ViewNode.newBuilder(); + result = result.toProto(idProvider, classList, childViewNode); + viewNode.addChildren(childViewNode); + } + return result; + } + } + + + private static class ViewRef implements Runnable { + public View view; + public int childCount = 0; + public ViewRef next; + + public Consumer<ViewRef> callback = null; + public long choreographerTimeNanos = 0; + + public void transferTo(ViewPropertyRef out) { + out.childCount = this.childCount; + + View view = this.view; + this.view = null; + + out.clazz = view.getClass(); + out.hashCode = view.hashCode(); + out.id = view.getId(); + out.left = view.getLeft(); + out.top = view.getTop(); + out.right = view.getRight(); + out.bottom = view.getBottom(); + out.scrollX = view.getScrollX(); + out.scrollY = view.getScrollY(); + + out.translateX = view.getTranslationX(); + out.translateY = view.getTranslationY(); + out.scaleX = view.getScaleX(); + out.scaleY = view.getScaleY(); + out.alpha = view.getAlpha(); + out.elevation = view.getElevation(); + + out.visibility = view.getVisibility(); + out.willNotDraw = view.willNotDraw(); + } + + @Override + public void run() { + Consumer<ViewRef> oldCallback = callback; + callback = null; + if (oldCallback != null) { + oldCallback.accept(this); + } + } + } + + private static final class ViewIdProvider { + + private final SparseArray<String> mNames = new SparseArray<>(); + private final Resources mRes; + + ViewIdProvider(Resources res) { + mRes = res; + } + + String getName(int id) { + String name = mNames.get(id); + if (name == null) { + if (id >= 0) { + try { + name = mRes.getResourceTypeName(id) + '/' + mRes.getResourceEntryName(id); + } catch (Resources.NotFoundException e) { + name = "id/" + "0x" + Integer.toHexString(id).toUpperCase(); + } + } else { + name = "NO_ID"; + } + mNames.put(id, name); + } + return name; + } + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/proto/view_capture.proto b/viewcapturelib/src/com/android/app/viewcapture/proto/view_capture.proto new file mode 100644 index 0000000..d4df2ae --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/proto/view_capture.proto @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +syntax = "proto2"; + +package com.android.app.viewcapture.data; + +option java_multiple_files = true; + +message ExportedData { + repeated WindowData windowData = 1; + optional string package = 2; + repeated string classname = 3; +} + +message WindowData { + repeated FrameData frameData = 1; + optional string title = 2; +} + +message MotionWindowData { + repeated FrameData frameData = 1; + repeated string classname = 2; +} + +message FrameData { + optional int64 timestamp = 1; // choreographer timestamp in nanoseconds + optional ViewNode node = 2; +} + +message ViewNode { + optional int32 classname_index = 1; + optional int32 hashcode = 2; + + repeated ViewNode children = 3; + + optional string id = 4; + optional int32 left = 5; + optional int32 top = 6; + optional int32 width = 7; + optional int32 height = 8; + optional int32 scrollX = 9; + optional int32 scrollY = 10; + + optional float translationX = 11; + optional float translationY = 12; + optional float scaleX = 13 [default = 1]; + optional float scaleY = 14 [default = 1]; + optional float alpha = 15 [default = 1]; + + optional bool willNotDraw = 16; + optional bool clipChildren = 17; + optional int32 visibility = 18; + + optional float elevation = 19; +} diff --git a/viewcapturelib/tests/AndroidManifest.xml b/viewcapturelib/tests/AndroidManifest.xml new file mode 100644 index 0000000..8d31c0e --- /dev/null +++ b/viewcapturelib/tests/AndroidManifest.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 2022 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.app.viewcapture.test"> + <application + android:debuggable="true" + android:theme="@android:style/Theme.NoTitleBar"> + + <activity + android:name="com.android.app.viewcapture.TestActivity" + android:exported="false" /> + + <uses-library android:name="android.test.runner" /> + + </application> + + <instrumentation + android:name="android.testing.TestableInstrumentation" + android:label="Tests for MotionTool Lib" + android:targetPackage="com.android.app.viewcapture.test"/> + +</manifest> diff --git a/viewcapturelib/tests/com/android/app/viewcapture/SettingsAwareViewCaptureTest.kt b/viewcapturelib/tests/com/android/app/viewcapture/SettingsAwareViewCaptureTest.kt new file mode 100644 index 0000000..2157ee4 --- /dev/null +++ b/viewcapturelib/tests/com/android/app/viewcapture/SettingsAwareViewCaptureTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2023 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.android.app.viewcapture + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.media.permission.SafeCloseable +import android.provider.Settings +import android.testing.AndroidTestingRunner +import android.view.Choreographer +import android.view.View +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import com.android.app.viewcapture.SettingsAwareViewCapture.Companion.VIEW_CAPTURE_ENABLED +import com.android.app.viewcapture.ViewCapture.MAIN_EXECUTOR +import junit.framework.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class SettingsAwareViewCaptureTest { + private val context: Context = InstrumentationRegistry.getInstrumentation().context + private val activityIntent = Intent(context, TestActivity::class.java) + + @get:Rule val activityScenarioRule = ActivityScenarioRule<TestActivity>(activityIntent) + @get:Rule val grantPermissionRule = + GrantPermissionRule.grant(Manifest.permission.WRITE_SECURE_SETTINGS) + + @Test + fun do_not_capture_view_hierarchies_if_setting_is_disabled() { + Settings.Global.putInt(context.contentResolver, VIEW_CAPTURE_ENABLED, 0) + + activityScenarioRule.scenario.onActivity { activity -> + val viewCapture: ViewCapture = + SettingsAwareViewCapture(context, Choreographer.getInstance(), MAIN_EXECUTOR) + val rootView: View = activity.findViewById(android.R.id.content) + + val closeable: SafeCloseable = viewCapture.startCapture(rootView, "rootViewId") + Choreographer.getInstance().postFrameCallback { + rootView.viewTreeObserver.dispatchOnDraw() + + assertEquals(0, viewCapture.getDumpTask( + activity.findViewById(android.R.id.content)).get().get().frameDataList.size) + closeable.close() + } + } + } + + @Test + fun capture_view_hierarchies_if_setting_is_enabled() { + Settings.Global.putInt(context.contentResolver, VIEW_CAPTURE_ENABLED, 1) + + activityScenarioRule.scenario.onActivity { activity -> + val viewCapture: ViewCapture = + SettingsAwareViewCapture(context, Choreographer.getInstance(), MAIN_EXECUTOR) + val rootView: View = activity.findViewById(android.R.id.content) + + val closeable: SafeCloseable = viewCapture.startCapture(rootView, "rootViewId") + Choreographer.getInstance().postFrameCallback { + rootView.viewTreeObserver.dispatchOnDraw() + + assertEquals(1, viewCapture.getDumpTask(activity.findViewById( + android.R.id.content)).get().get().frameDataList.size) + + closeable.close() + } + } + } + + @Test + fun getInstance_calledTwiceInARow_returnsSameObject() { + assertEquals( + SettingsAwareViewCapture.getInstance(context).hashCode(), + SettingsAwareViewCapture.getInstance(context).hashCode() + ) + } +} diff --git a/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt b/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt new file mode 100644 index 0000000..749327e --- /dev/null +++ b/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2023 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.android.app.viewcapture + +import android.app.Activity +import android.os.Bundle +import android.widget.LinearLayout +import android.widget.TextView + +/** + * Activity with the content set to a [LinearLayout] with [TextView] children. + */ +class TestActivity : Activity() { + + companion object { + const val TEXT_VIEW_COUNT = 1000 + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(createContentView()) + } + + private fun createContentView(): LinearLayout { + val root = LinearLayout(this) + for (i in 0 until TEXT_VIEW_COUNT) { + root.addView(TextView(this)) + } + return root + } +}
\ No newline at end of file diff --git a/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt new file mode 100644 index 0000000..b341fe9 --- /dev/null +++ b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.app.viewcapture + +import android.content.Intent +import android.media.permission.SafeCloseable +import android.testing.AndroidTestingRunner +import android.view.Choreographer +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.app.viewcapture.TestActivity.Companion.TEXT_VIEW_COUNT +import com.android.app.viewcapture.data.MotionWindowData +import junit.framework.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class ViewCaptureTest { + + private val memorySize = 100 + private val initPoolSize = 15 + private val viewCapture by lazy { + object : + ViewCapture(memorySize, initPoolSize, Choreographer.getInstance(), MAIN_EXECUTOR) {} + } + + private val activityIntent = + Intent(InstrumentationRegistry.getInstrumentation().context, TestActivity::class.java) + + @get:Rule val activityScenarioRule = ActivityScenarioRule<TestActivity>(activityIntent) + + @Test + fun testWindowListenerDumpsOneFrameAfterInvalidate() { + activityScenarioRule.scenario.onActivity { activity -> + Choreographer.getInstance().postFrameCallback { + val closeable = startViewCaptureAndInvalidateNTimes(1, activity) + val rootView = activity.findViewById<View>(android.R.id.content) + val data = viewCapture.getDumpTask(rootView).get().get() + + assertEquals(1, data.frameDataList.size) + verifyTestActivityViewHierarchy(data) + closeable.close() + } + } + } + + @Test + fun testWindowListenerDumpsCorrectlyAfterRecyclingStarted() { + activityScenarioRule.scenario.onActivity { activity -> + Choreographer.getInstance().postFrameCallback { + val closeable = startViewCaptureAndInvalidateNTimes(memorySize + 5, activity) + val rootView = activity.findViewById<View>(android.R.id.content) + val data = viewCapture.getDumpTask(rootView).get().get() + + // since ViewCapture MEMORY_SIZE is [viewCaptureMemorySize], only + // [viewCaptureMemorySize] frames are exported, although the view is invalidated + // [viewCaptureMemorySize + 5] times + assertEquals(memorySize, data.frameDataList.size) + verifyTestActivityViewHierarchy(data) + closeable.close() + } + } + } + + private fun startViewCaptureAndInvalidateNTimes(n: Int, activity: TestActivity): SafeCloseable { + val rootView: View = activity.findViewById(android.R.id.content) + val closeable: SafeCloseable = viewCapture.startCapture(rootView, "rootViewId") + dispatchOnDraw(rootView, times = n) + return closeable + } + + private fun dispatchOnDraw(view: View, times: Int) { + if (times > 0) { + view.viewTreeObserver.dispatchOnDraw() + dispatchOnDraw(view, times - 1) + } + } + + private fun verifyTestActivityViewHierarchy(exportedData: MotionWindowData) { + for (frame in exportedData.frameDataList) { + val testActivityRoot = + frame.node // FrameLayout (android.R.id.content) + .childrenList + .first() // LinearLayout (set by setContentView()) + assertEquals(TEXT_VIEW_COUNT, testActivityRoot.childrenList.size) + assertEquals( + LinearLayout::class.qualifiedName, + exportedData.getClassname(testActivityRoot.classnameIndex) + ) + assertEquals( + TextView::class.qualifiedName, + exportedData.getClassname(testActivityRoot.childrenList.first().classnameIndex) + ) + } + } +} |