summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorXin Li <delphij@google.com>2024-03-06 09:29:59 -0800
committerXin Li <delphij@google.com>2024-03-06 09:29:59 -0800
commit20d38f57513366d547b9d2329272c8a8ce0f77b5 (patch)
tree55d0869daa7b360cc5a22f8c59b357fea1402c8c
parent403fa809197014e2342ef1a75ae0aedc14b303ce (diff)
parent276c2d44833711ffe9b205e4d04031454a47b07a (diff)
downloadsystemui-20d38f57513366d547b9d2329272c8a8ce0f77b5.tar.gz
Merge Android 14 QPR2 to AOSP main
Bug: 319669529 Merged-In: I34cfad83c8c9878894bc7e5d0c2acaf2796c7861 Change-Id: Iffa25c40f74c7c541ddb39ba709cb110ca5be630
-rw-r--r--aconfig/Android.bp39
-rw-r--r--aconfig/biometrics.aconfig10
-rw-r--r--aconfig/systemui.aconfig8
-rw-r--r--animationlib/Android.bp45
-rw-r--r--animationlib/build.gradle9
-rw-r--r--animationlib/src/com/android/app/animation/Interpolators.java20
-rw-r--r--animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java20
-rw-r--r--animationlib/tests/robolectric/config/robolectric.properties2
-rw-r--r--animationlib/tests/robolectric/src/com/android/app/animation/robolectric/ShadowAnimationUtils2.kt12
-rw-r--r--animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt6
-rw-r--r--animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt4
-rw-r--r--iconloaderlib/res/drawable/ic_clone_app_badge.xml21
-rw-r--r--iconloaderlib/res/drawable/ic_clone_app_badge_themed.xml43
-rw-r--r--iconloaderlib/res/drawable/ic_instant_app_badge.xml9
-rw-r--r--iconloaderlib/res/drawable/ic_instant_app_badge_themed.xml30
-rw-r--r--iconloaderlib/res/drawable/ic_private_profile_app_badge.xml26
-rw-r--r--iconloaderlib/res/drawable/ic_work_app_badge.xml17
-rw-r--r--iconloaderlib/res/drawable/ic_work_app_badge_themed.xml39
-rw-r--r--iconloaderlib/res/values/colors.xml1
-rw-r--r--iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java139
-rw-r--r--iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java50
-rw-r--r--iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java23
-rw-r--r--iconloaderlib/src/com/android/launcher3/icons/FixedScaleDrawable.java53
-rw-r--r--iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java3
-rw-r--r--iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java135
-rw-r--r--iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java23
-rw-r--r--iconloaderlib/src/com/android/launcher3/icons/cache/IconCacheUpdateHandler.java2
-rw-r--r--iconloaderlib/src/com/android/launcher3/util/UserIconInfo.java81
-rw-r--r--toruslib/Android.bp53
-rw-r--r--toruslib/OWNERS4
-rw-r--r--toruslib/build.gradle92
-rw-r--r--toruslib/gradle.properties24
-rw-r--r--toruslib/lib-torus/build.gradle61
-rw-r--r--toruslib/lib-torus/gradle.properties34
-rw-r--r--toruslib/lib-torus/src/main/AndroidManifest.xml17
-rw-r--r--toruslib/settings.gradle47
-rw-r--r--toruslib/torus-core/build.gradle13
-rw-r--r--toruslib/torus-core/consumer-rules.pro0
-rw-r--r--toruslib/torus-core/proguard-rules.pro21
-rw-r--r--toruslib/torus-core/src/main/AndroidManifest.xml17
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/activity/TorusViewerActivity.kt120
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/app/KeyguardLockController.kt101
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/content/ConfigurationChangeListener.kt26
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/engine/TorusEngine.kt62
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/engine/listener/TorusTouchListener.kt33
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/extensions/ConfigurationExt.kt28
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/geometry/Vertex.kt46
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/power/FpsThrottler.kt135
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/time/TimeController.kt79
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/LiveWallpaper.kt441
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/listener/LiveWallpaperEventListener.kt110
-rw-r--r--toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/listener/LiveWallpaperKeyguardEventListener.kt24
-rw-r--r--toruslib/torus-framework-canvas/build.gradle18
-rw-r--r--toruslib/torus-framework-canvas/src/main/AndroidManifest.xml17
-rw-r--r--toruslib/torus-framework-canvas/src/main/java/com/google/android/torus/canvas/engine/CanvasWallpaperEngine.kt329
-rw-r--r--toruslib/torus-math/build.gradle13
-rw-r--r--toruslib/torus-math/src/main/AndroidManifest.xml17
-rw-r--r--toruslib/torus-math/src/main/java/com/google/android/torus/math/AffineTransform.kt177
-rw-r--r--toruslib/torus-math/src/main/java/com/google/android/torus/math/MathUtils.kt181
-rw-r--r--toruslib/torus-math/src/main/java/com/google/android/torus/math/MatrixTransform.kt38
-rw-r--r--toruslib/torus-math/src/main/java/com/google/android/torus/math/RotationQuaternion.kt198
-rw-r--r--toruslib/torus-math/src/main/java/com/google/android/torus/math/SphericalTransform.kt313
-rw-r--r--toruslib/torus-math/src/main/java/com/google/android/torus/math/Vector2.kt197
-rw-r--r--toruslib/torus-math/src/main/java/com/google/android/torus/math/Vector3.kt226
-rw-r--r--toruslib/torus-utils/build.gradle17
-rw-r--r--toruslib/torus-utils/src/main/AndroidManifest.xml15
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/BitmapUtils.kt71
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/animation/EasingUtils.kt66
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/broadcast/BroadcastEventController.kt92
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/broadcast/PowerSaveController.kt79
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/content/ResourcesManager.kt93
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplayOrientationController.kt124
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplaySizeType.kt93
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplayUtils.kt139
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/ActivityExt.kt56
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/AssetManagerExt.kt71
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/SizeExt.kt52
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/interaction/Gyro2dController.kt343
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/interaction/HingeController.kt160
-rw-r--r--toruslib/torus-utils/src/main/java/com/google/android/torus/utils/wallpaper/WallpaperUtils.kt57
-rw-r--r--toruslib/torus-wallpaper-settings/build.gradle19
-rw-r--r--toruslib/torus-wallpaper-settings/src/main/AndroidManifest.xml17
-rwxr-xr-xtoruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/BaseSliceConfigProvider.kt64
-rwxr-xr-xtoruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/ColorChipsRowBuilder.kt233
-rw-r--r--toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/InputRangeRowBuilder.kt70
-rw-r--r--toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/SingleSelectionRowConfigProvider.kt101
-rw-r--r--toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/SliceConfigController.kt52
-rwxr-xr-xtoruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/storage/CustomizedSharedPreferences.kt250
-rwxr-xr-xtoruslib/torus-wallpaper-settings/src/main/res/values/dimens.xml11
-rw-r--r--toruslib/torus-wallpaper-settings/src/main/res/values/strings.xml4
-rw-r--r--tracinglib/Android.bp28
-rw-r--r--tracinglib/src/com/android/app/tracing/FlowTracing.kt33
-rw-r--r--tracinglib/src/com/android/app/tracing/ListenersTracing.kt39
-rw-r--r--tracinglib/src/com/android/app/tracing/TraceContextElement.kt69
-rw-r--r--tracinglib/src/com/android/app/tracing/TraceData.kt122
-rw-r--r--tracinglib/src/com/android/app/tracing/TraceSection.kt35
-rw-r--r--tracinglib/src/com/android/app/tracing/TraceStateLogger.kt60
-rw-r--r--tracinglib/src/com/android/app/tracing/TraceUtils.kt452
-rw-r--r--viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt21
-rw-r--r--viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt1
-rw-r--r--viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java80
-rw-r--r--weathereffects/Android.bp95
-rw-r--r--weathereffects/AndroidManifest.xml53
-rw-r--r--weathereffects/TEST_MAPPING7
-rw-r--r--weathereffects/assets/shaders/color_grading_lut.agsl82
-rw-r--r--weathereffects/assets/shaders/constants.agsl19
-rw-r--r--weathereffects/assets/shaders/fog_effect.agsl69
-rw-r--r--weathereffects/assets/shaders/glass_rain.agsl147
-rw-r--r--weathereffects/assets/shaders/rain.agsl96
-rw-r--r--weathereffects/assets/shaders/rain_effect.agsl185
-rw-r--r--weathereffects/assets/shaders/simplex2d.agsl109
-rw-r--r--weathereffects/assets/shaders/simplex3d.agsl110
-rw-r--r--weathereffects/assets/shaders/snow.agsl116
-rw-r--r--weathereffects/assets/shaders/snow_accumulation.agsl42
-rw-r--r--weathereffects/assets/shaders/snow_effect.agsl98
-rw-r--r--weathereffects/assets/shaders/utils.agsl73
-rw-r--r--weathereffects/assets/textures/lut_rain_and_fog.pngbin0 -> 24530 bytes
-rw-r--r--weathereffects/build.gradle161
-rw-r--r--weathereffects/debug/AndroidManifest.xml41
-rw-r--r--weathereffects/debug/assets/test-background.pngbin0 -> 62714 bytes
-rw-r--r--weathereffects/debug/assets/test-foreground.pngbin0 -> 55354 bytes
-rw-r--r--weathereffects/debug/res/drawable/ic_baseline_check_24.xml25
-rw-r--r--weathereffects/debug/res/drawable/ic_baseline_image_search_24.xml22
-rw-r--r--weathereffects/debug/res/layout/debug_activity.xml109
-rw-r--r--weathereffects/debug/res/values/colors.xml21
-rw-r--r--weathereffects/debug/res/values/strings.xml27
-rw-r--r--weathereffects/debug/res/values/themes.xml23
-rw-r--r--weathereffects/debug/src/com/google/android/wallpaper/weathereffects/WallpaperEffectsDebugActivity.kt241
-rw-r--r--weathereffects/debug/src/com/google/android/wallpaper/weathereffects/WallpaperEffectsDebugApplication.kt37
-rw-r--r--weathereffects/debug/src/com/google/android/wallpaper/weathereffects/dagger/DebugApplicationComponent.kt33
-rw-r--r--weathereffects/gradle.properties18
-rw-r--r--weathereffects/includes.gradle18
-rw-r--r--weathereffects/res/drawable/ic_launcher_background.xml185
-rw-r--r--weathereffects/res/drawable/ic_launcher_foreground.xml45
-rw-r--r--weathereffects/res/mipmap-anydpi-v26/ic_launcher.xml (renamed from iconloaderlib/res/drawable-v26/adaptive_icon_drawable_wrapper.xml)10
-rw-r--r--weathereffects/res/values/strings.xml21
-rw-r--r--weathereffects/res/xml/weather_wallpaper.xml21
-rw-r--r--weathereffects/settings.gradle17
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherEffect.kt53
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherEngine.kt162
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherWallpaperService.kt43
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/ApplicationComponent.kt24
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/DependencyProvider.kt65
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/Qualifiers.kt35
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/data/repository/WallpaperFileUtils.kt136
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/data/repository/WeatherEffectsRepository.kt133
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/domain/WeatherEffectsInteractor.kt41
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/fog/FogEffect.kt143
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/fog/FogEffectConfig.kt77
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/graphics/FrameBuffer.kt105
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/none/NoEffect.kt67
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/provider/WallpaperInfoContract.kt90
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/provider/WeatherEffectsContentProvider.kt115
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/rain/RainEffect.kt143
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/rain/RainEffectConfig.kt71
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/shared/model/WallpaperFileModel.kt46
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/snow/SnowEffect.kt176
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/snow/SnowEffectConfig.kt76
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/utils/GraphicsUtils.kt114
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/utils/ImageCrop.kt73
-rw-r--r--weathereffects/src/com/google/android/wallpaper/weathereffects/utils/MatrixUtils.kt41
-rw-r--r--weathereffects/tests/src/com/google/android/wallpaper/weathereffects/provider/WeatherEffectsContentProviderTest.kt114
162 files changed, 11680 insertions, 310 deletions
diff --git a/aconfig/Android.bp b/aconfig/Android.bp
new file mode 100644
index 0000000..6c62e87
--- /dev/null
+++ b/aconfig/Android.bp
@@ -0,0 +1,39 @@
+//
+// 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.
+//
+
+/*****
+ * These flags are meant for SystemUI-owned flags that can be read by non-systemui
+ * processes.
+ */
+
+package {
+ default_visibility: [
+ "//visibility:public",
+ ]
+}
+
+aconfig_declarations {
+ name: "com_android_systemui_shared_flags",
+ package: "com.android.systemui.shared",
+ srcs: [
+ "*.aconfig",
+ ],
+}
+
+java_aconfig_library {
+ name: "com_android_systemui_shared_flags_lib",
+ aconfig_declarations: "com_android_systemui_shared_flags",
+}
diff --git a/aconfig/biometrics.aconfig b/aconfig/biometrics.aconfig
new file mode 100644
index 0000000..1472799
--- /dev/null
+++ b/aconfig/biometrics.aconfig
@@ -0,0 +1,10 @@
+package: "com.android.systemui.shared"
+
+# Note: for shared flags across SystemUI & framework
+
+flag {
+ name: "sidefps_controller_refactor"
+ namespace: "biometrics_framework"
+ description: "flag for SideFpsController refactor"
+ bug: "288175061"
+}
diff --git a/aconfig/systemui.aconfig b/aconfig/systemui.aconfig
new file mode 100644
index 0000000..96f4076
--- /dev/null
+++ b/aconfig/systemui.aconfig
@@ -0,0 +1,8 @@
+package: "com.android.systemui.shared"
+
+flag {
+ name: "example_shared_flag"
+ namespace: "systemui"
+ description: "An Example Flag"
+ bug: "308482106"
+}
diff --git a/animationlib/Android.bp b/animationlib/Android.bp
index a61d539..6c1620a 100644
--- a/animationlib/Android.bp
+++ b/animationlib/Android.bp
@@ -37,23 +37,52 @@ android_library {
kotlincflags: ["-Xjvm-default=all"],
}
-android_test {
- name: "animationlib_tests",
- manifest: "tests/AndroidManifest.xml",
-
+android_library {
+ name: "animationlib-tests-base",
+ libs: [
+ "android.test.base",
+ "androidx.test.core",
+ ],
static_libs: [
"animationlib",
"androidx.test.ext.junit",
"androidx.test.rules",
"testables",
+ ]
+}
+
+android_app {
+ name: "TestAnimationLibApp",
+ platform_apis: true,
+ static_libs: [
+ "animationlib-tests-base",
+ ]
+}
+
+android_robolectric_test {
+ enabled: true,
+ name: "animationlib_robo_tests",
+ srcs: [
+ "tests/src/**/*.kt",
+ "tests/robolectric/src/**/*.kt"
],
- libs: [
- "android.test.base",
+ java_resource_dirs: ["tests/robolectric/config"],
+ instrumentation_for: "TestAnimationLibApp",
+ upstream: true,
+}
+
+android_test {
+ name: "animationlib_tests",
+ manifest: "tests/AndroidManifest.xml",
+
+ static_libs: [
+ "animationlib-tests-base",
],
srcs: [
- "**/*.java",
- "**/*.kt"
+ "tests/src/**/*.java",
+ "tests/src/**/*.kt"
],
kotlincflags: ["-Xjvm-default=all"],
test_suites: ["general-tests"],
}
+
diff --git a/animationlib/build.gradle b/animationlib/build.gradle
index 18ae0e1..bd5c575 100644
--- a/animationlib/build.gradle
+++ b/animationlib/build.gradle
@@ -15,16 +15,10 @@ android {
manifest.srcFile 'AndroidManifest.xml'
}
androidTest {
- java.srcDirs = ["tests/src"]
+ java.srcDirs = ["tests/src", "tests/robolectric/src"]
manifest.srcFile 'tests/AndroidManifest.xml'
}
}
- compileSdk 33
-
- defaultConfig {
- minSdk 33
- targetSdk 33
- }
lintOptions {
abortOnError false
@@ -43,6 +37,7 @@ 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 libs.robolectric
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
index aac4627..d667ada 100644
--- a/animationlib/src/com/android/app/animation/Interpolators.java
+++ b/animationlib/src/com/android/app/animation/Interpolators.java
@@ -46,6 +46,14 @@ public class Interpolators {
public static final Interpolator EMPHASIZED = createEmphasizedInterpolator();
/**
+ * Complement to {@link #EMPHASIZED}. Used when animating hero movement in two dimensions to
+ * create a smooth, emphasized, curved movement.
+ * <br>
+ * Example usage: Animate y-movement with {@link #EMPHASIZED} and x-movement with this.
+ */
+ public static final Interpolator EMPHASIZED_COMPLEMENT = createEmphasizedComplement();
+
+ /**
* The accelerated emphasized interpolator. Used for hero / emphasized movement of content that
* is disappearing e.g. when moving off screen.
*/
@@ -312,6 +320,18 @@ public class Interpolators {
}
/**
+ * Creates a complement to {@link #createEmphasizedInterpolator()} for use when animating in
+ * two dimensions.
+ */
+ private static PathInterpolator createEmphasizedComplement() {
+ Path path = new Path();
+ path.moveTo(0f, 0f);
+ path.cubicTo(0.1217f, 0.0462f, 0.15f, 0.4686f, 0.1667f, 0.66f);
+ path.cubicTo(0.1834f, 0.8878f, 0.1667f, 1f, 1f, 1f);
+ return new PathInterpolator(path);
+ }
+
+ /**
* Returns a function that runs the given interpolator such that the entire progress is set
* between the given bounds. That is, we set the interpolation to 0 until lowerBound and reach
* 1 by upperBound.
diff --git a/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java b/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java
index 2ace0a3..6313475 100644
--- a/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java
+++ b/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java
@@ -53,6 +53,14 @@ public class InterpolatorsAndroidX {
public static final Interpolator EMPHASIZED = createEmphasizedInterpolator();
/**
+ * Complement to {@link #EMPHASIZED}. Used when animating hero movement in two dimensions to
+ * create a smooth, emphasized, curved movement.
+ * <br>
+ * Example usage: Animate y-movement with {@link #EMPHASIZED} and x-movement with this.
+ */
+ public static final Interpolator EMPHASIZED_COMPLEMENT = createEmphasizedComplement();
+
+ /**
* The accelerated emphasized interpolator. Used for hero / emphasized movement of content that
* is disappearing e.g. when moving off screen.
*/
@@ -318,6 +326,18 @@ public class InterpolatorsAndroidX {
}
/**
+ * Creates a complement to {@link #createEmphasizedInterpolator()} for use when animating in
+ * two dimensions.
+ */
+ private static PathInterpolator createEmphasizedComplement() {
+ Path path = new Path();
+ path.moveTo(0f, 0f);
+ path.cubicTo(0.1217f, 0.0462f, 0.15f, 0.4686f, 0.1667f, 0.66f);
+ path.cubicTo(0.1834f, 0.8878f, 0.1667f, 1f, 1f, 1f);
+ return new PathInterpolator(path);
+ }
+
+ /**
* Returns a function that runs the given interpolator such that the entire progress is set
* between the given bounds. That is, we set the interpolation to 0 until lowerBound and reach
* 1 by upperBound.
diff --git a/animationlib/tests/robolectric/config/robolectric.properties b/animationlib/tests/robolectric/config/robolectric.properties
new file mode 100644
index 0000000..527eab6
--- /dev/null
+++ b/animationlib/tests/robolectric/config/robolectric.properties
@@ -0,0 +1,2 @@
+sdk=NEWEST_SDK
+shadows=com.android.app.animation.robolectric.ShadowAnimationUtils2
diff --git a/animationlib/tests/robolectric/src/com/android/app/animation/robolectric/ShadowAnimationUtils2.kt b/animationlib/tests/robolectric/src/com/android/app/animation/robolectric/ShadowAnimationUtils2.kt
new file mode 100644
index 0000000..c3e74ee
--- /dev/null
+++ b/animationlib/tests/robolectric/src/com/android/app/animation/robolectric/ShadowAnimationUtils2.kt
@@ -0,0 +1,12 @@
+package com.android.app.animation.robolectric
+
+import android.view.animation.AnimationUtils
+import org.robolectric.annotation.Implements
+import org.robolectric.shadows.ShadowAnimationUtils
+
+/**
+ * This shadow overwrites [ShadowAnimationUtils] and ensures that the real implementation of
+ * [AnimationUtils] is used in tests.
+ */
+@Implements(AnimationUtils::class)
+class ShadowAnimationUtils2
diff --git a/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt b/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt
index ed4670e..f54493e 100644
--- a/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt
+++ b/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt
@@ -3,23 +3,23 @@ package com.android.app.animation
import android.annotation.InterpolatorRes
import android.content.Context
import android.view.animation.AnimationUtils
+import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import junit.framework.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
-import org.junit.runners.JUnit4
@SmallTest
-@RunWith(JUnit4::class)
+@RunWith(AndroidJUnit4::class)
class InterpolatorResourcesTest {
private lateinit var context: Context
@Before
fun setup() {
- context = InstrumentationRegistry.getInstrumentation().context
+ context = InstrumentationRegistry.getInstrumentation().targetContext
}
@Test
diff --git a/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt b/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt
index ffa706e..bed06cd 100644
--- a/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt
+++ b/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt
@@ -16,18 +16,18 @@
package com.android.app.animation
+import androidx.test.ext.junit.runners.AndroidJUnit4
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
private const val ANDROIDX_ANIM_PACKAGE_NAME = "androidx.core.animation."
private const val ANDROID_ANIM_PACKAGE_NAME = "android.view.animation."
@SmallTest
-@RunWith(JUnit4::class)
+@RunWith(AndroidJUnit4::class)
class InterpolatorsAndroidXTest {
@Test
diff --git a/iconloaderlib/res/drawable/ic_clone_app_badge.xml b/iconloaderlib/res/drawable/ic_clone_app_badge.xml
index 9f0876d..f81a960 100644
--- a/iconloaderlib/res/drawable/ic_clone_app_badge.xml
+++ b/iconloaderlib/res/drawable/ic_clone_app_badge.xml
@@ -17,27 +17,16 @@
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" />
+ android:viewportHeight="24"
+ android:tint="#ff3C4043">
<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:fillColor="#FFFFFFFF"/>
+ <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:fillColor="#FFFFFFFF"
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
deleted file mode 100644
index 3a59e3d..0000000
--- a/iconloaderlib/res/drawable/ic_clone_app_badge_themed.xml
+++ /dev/null
@@ -1,43 +0,0 @@
-<?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 e6b5701..0e36b30 100644
--- a/iconloaderlib/res/drawable/ic_instant_app_badge.xml
+++ b/iconloaderlib/res/drawable/ic_instant_app_badge.xml
@@ -17,14 +17,11 @@
android:width="@dimen/profile_badge_size"
android:height="@dimen/profile_badge_size"
android:viewportWidth="18"
- android:viewportHeight="18">
+ android:viewportHeight="18"
+ android:tint="@android:color/black">
<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:fillColor="#FFFFFFFF"
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
deleted file mode 100644
index 6e19339..0000000
--- a/iconloaderlib/res/drawable/ic_instant_app_badge_themed.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-<?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_private_profile_app_badge.xml b/iconloaderlib/res/drawable/ic_private_profile_app_badge.xml
new file mode 100644
index 0000000..1f2967e
--- /dev/null
+++ b/iconloaderlib/res/drawable/ic_private_profile_app_badge.xml
@@ -0,0 +1,26 @@
+<!--
+ ~ 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"
+ android:tint="#001A41">
+ <path
+ android:pathData="M11.033,14H12.967L12.6,11.85C12.789,11.75 12.933,11.606 13.033,11.417C13.144,11.228 13.2,11.022 13.2,10.8C13.2,10.467 13.083,10.183 12.85,9.95C12.617,9.717 12.333,9.6 12,9.6C11.667,9.6 11.383,9.717 11.15,9.95C10.917,10.183 10.8,10.467 10.8,10.8C10.8,11.022 10.85,11.228 10.95,11.417C11.061,11.606 11.211,11.75 11.4,11.85L11.033,14ZM12,18.4C10.5,18.033 9.256,17.183 8.267,15.85C7.289,14.517 6.8,13.039 6.8,11.417V7.6L12,5.6L17.2,7.6V11.417C17.2,13.039 16.706,14.517 15.717,15.85C14.739,17.183 13.5,18.033 12,18.4ZM12,17.15C13.156,16.794 14.111,16.078 14.867,15C15.622,13.922 16,12.728 16,11.417V8.417L12,6.883L8,8.417V11.417C8,12.728 8.378,13.922 9.133,15C9.889,16.078 10.844,16.794 12,17.15Z"
+ android:fillColor="#FFFFFFFF"/>
+</vector>
+
diff --git a/iconloaderlib/res/drawable/ic_work_app_badge.xml b/iconloaderlib/res/drawable/ic_work_app_badge.xml
index 1599489..b4d99dd 100644
--- a/iconloaderlib/res/drawable/ic_work_app_badge.xml
+++ b/iconloaderlib/res/drawable/ic_work_app_badge.xml
@@ -17,23 +17,12 @@
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" />
+ android:viewportHeight="24"
+ android:tint="#1A73E8">
<group android:scaleX=".6" android:scaleY=".6" android:pivotX="12" android:pivotY="12">
<path
- android:fillColor="#1A73E8"
+ android:fillColor="#FFFFFFFF"
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/drawable/ic_work_app_badge_themed.xml b/iconloaderlib/res/drawable/ic_work_app_badge_themed.xml
deleted file mode 100644
index 6866d2f..0000000
--- a/iconloaderlib/res/drawable/ic_work_app_badge_themed.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-<?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/colors.xml b/iconloaderlib/res/values/colors.xml
index 8eeafb4..3abaaa1 100644
--- a/iconloaderlib/res/values/colors.xml
+++ b/iconloaderlib/res/values/colors.xml
@@ -21,7 +21,6 @@
<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 -->
<color name="important_conversation">#f9ab00</color>
diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java
index 704df6f..7c112da 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java
@@ -7,6 +7,7 @@ import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFracti
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_PRIVATE;
import static com.android.launcher3.icons.BitmapInfo.FLAG_WORK;
import static com.android.launcher3.icons.ShadowGenerator.BLUR_FACTOR;
@@ -29,10 +30,11 @@ import android.graphics.drawable.AdaptiveIconDrawable;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
+import android.graphics.drawable.DrawableWrapper;
import android.graphics.drawable.InsetDrawable;
import android.os.Build;
import android.os.UserHandle;
-import android.util.SparseBooleanArray;
+import android.util.SparseArray;
import androidx.annotation.ColorInt;
import androidx.annotation.IntDef;
@@ -41,6 +43,7 @@ import androidx.annotation.Nullable;
import com.android.launcher3.icons.BitmapInfo.Extender;
import com.android.launcher3.util.FlagOp;
+import com.android.launcher3.util.UserIconInfo;
import java.lang.annotation.Retention;
import java.util.Objects;
@@ -52,6 +55,7 @@ import java.util.Objects;
public class BaseIconFactory implements AutoCloseable {
private static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE;
+ private static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction()));
public static final int MODE_DEFAULT = 0;
public static final int MODE_ALPHA = 1;
@@ -69,7 +73,7 @@ public class BaseIconFactory implements AutoCloseable {
private final Rect mOldBounds = new Rect();
@NonNull
- private final SparseBooleanArray mIsUserBadged = new SparseBooleanArray();
+ private final SparseArray<UserIconInfo> mCachedUserInfo = new SparseArray<>();
@NonNull
protected final Context mContext;
@@ -255,28 +259,36 @@ public class BaseIconFactory implements AutoCloseable {
op = op.addFlag(FLAG_INSTANT);
}
- if (options.mUserHandle != null) {
- int key = options.mUserHandle.hashCode();
- boolean isBadged;
- int index;
- if ((index = mIsUserBadged.indexOfKey(key)) >= 0) {
- isBadged = mIsUserBadged.valueAt(index);
- } else {
- // Check packageManager if the provided user needs a badge
- NoopDrawable d = new NoopDrawable();
- isBadged = (d != mPm.getUserBadgedIcon(d, options.mUserHandle));
- mIsUserBadged.put(key, 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);
+ UserIconInfo info = options.mUserIconInfo;
+ if (info == null && options.mUserHandle != null) {
+ info = getUserInfo(options.mUserHandle);
+ }
+ if (info != null) {
+ op = info.applyBitmapInfoFlags(op);
}
}
return op;
}
@NonNull
+ protected UserIconInfo getUserInfo(@NonNull UserHandle user) {
+ int key = user.hashCode();
+ UserIconInfo info = mCachedUserInfo.get(key);
+ /*
+ * We do not have the ability to distinguish between different badged users here.
+ * As such all badged users will have the work profile badge applied.
+ */
+ if (info == null) {
+ // Simple check to check if the provided user is work profile or not based on badging
+ NoopDrawable d = new NoopDrawable();
+ boolean isWork = (d != mPm.getUserBadgedIcon(d, user));
+ info = new UserIconInfo(user, isWork ? UserIconInfo.TYPE_WORK : UserIconInfo.TYPE_MAIN);
+ mCachedUserInfo.put(key, info);
+ }
+ return info;
+ }
+
+ @NonNull
public Bitmap getWhiteShadowLayer() {
if (mWhiteShadowLayer == null) {
mWhiteShadowLayer = createScaledBitmap(
@@ -309,24 +321,19 @@ public class BaseIconFactory implements AutoCloseable {
if (icon == null) {
return null;
}
- float scale = 1f;
+ float scale;
if (shrinkNonAdaptiveIcons && !(icon instanceof AdaptiveIconDrawable)) {
- if (mWrapperIcon == null) {
- mWrapperIcon = mContext.getDrawable(R.drawable.adaptive_icon_drawable_wrapper)
- .mutate();
- }
- AdaptiveIconDrawable dr = (AdaptiveIconDrawable) mWrapperIcon;
+ EmptyWrapper foreground = new EmptyWrapper();
+ AdaptiveIconDrawable dr = new AdaptiveIconDrawable(
+ new ColorDrawable(mWrapperBackgroundColor), foreground);
dr.setBounds(0, 0, 1, 1);
boolean[] outShape = new boolean[1];
scale = getNormalizer().getScale(icon, outIconBounds, dr.getIconMask(), outShape);
if (!outShape[0]) {
- FixedScaleDrawable fsd = ((FixedScaleDrawable) dr.getForeground());
- fsd.setDrawable(icon);
- fsd.setScale(scale);
+ foreground.setDrawable(createScaledDrawable(icon, scale * LEGACY_ICON_SCALE));
icon = dr;
scale = getNormalizer().getScale(icon, outIconBounds, null, null);
- ((ColorDrawable) dr.getBackground()).setColor(mWrapperBackgroundColor);
}
} else {
scale = getNormalizer().getScale(icon, outIconBounds, null, null);
@@ -336,6 +343,46 @@ public class BaseIconFactory implements AutoCloseable {
return icon;
}
+ /**
+ * Returns a drawable which draws the original drawable at a fixed scale
+ */
+ private Drawable createScaledDrawable(@NonNull Drawable main, float scale) {
+ float h = main.getIntrinsicHeight();
+ float w = main.getIntrinsicWidth();
+ float scaleX = scale;
+ float scaleY = scale;
+ if (h > w && w > 0) {
+ scaleX *= w / h;
+ } else if (w > h && h > 0) {
+ scaleY *= h / w;
+ }
+ scaleX = (1 - scaleX) / 2;
+ scaleY = (1 - scaleY) / 2;
+ return new InsetDrawable(main, scaleX, scaleY, scaleX, scaleY);
+ }
+
+ /**
+ * Wraps the provided icon in an adaptive icon drawable
+ */
+ public AdaptiveIconDrawable wrapToAdaptiveIcon(@NonNull Drawable icon) {
+ if (icon instanceof AdaptiveIconDrawable aid) {
+ return aid;
+ } else {
+ EmptyWrapper foreground = new EmptyWrapper();
+ AdaptiveIconDrawable dr = new AdaptiveIconDrawable(
+ new ColorDrawable(mWrapperBackgroundColor), foreground);
+ dr.setBounds(0, 0, 1, 1);
+ boolean[] outShape = new boolean[1];
+ float scale = getNormalizer().getScale(icon, null, dr.getIconMask(), outShape);
+ if (!outShape[0]) {
+ foreground.setDrawable(createScaledDrawable(icon, scale * LEGACY_ICON_SCALE));
+ } else {
+ foreground.setDrawable(createScaledDrawable(icon, 1 - getExtraInsetFraction()));
+ }
+ return dr;
+ }
+ }
+
@NonNull
protected Bitmap createIconBitmap(@Nullable final Drawable icon, final float scale) {
return createIconBitmap(icon, scale, MODE_DEFAULT);
@@ -376,6 +423,7 @@ public class BaseIconFactory implements AutoCloseable {
mOldBounds.set(icon.getBounds());
if (icon instanceof AdaptiveIconDrawable) {
+ // We are ignoring KEY_SHADOW_DISTANCE because regular icons ignore this at the moment b/298203449
int offset = Math.max((int) Math.ceil(BLUR_FACTOR * size),
Math.round(size * (1 - scale) / 2));
// b/211896569: AdaptiveIconDrawable do not work properly for non top-left bounds
@@ -468,12 +516,12 @@ public class BaseIconFactory implements AutoCloseable {
boolean mIsInstantApp;
- boolean mIsCloneProfile;
-
@BitmapGenerationMode
int mGenerationMode = MODE_WITH_SHADOW;
@Nullable UserHandle mUserHandle;
+ @Nullable
+ UserIconInfo mUserIconInfo;
@ColorInt
@Nullable Integer mExtractedColor;
@@ -497,6 +545,15 @@ public class BaseIconFactory implements AutoCloseable {
}
/**
+ * User for this icon, in case of badging
+ */
+ @NonNull
+ public IconOptions setUser(@Nullable final UserIconInfo user) {
+ mUserIconInfo = user;
+ return this;
+ }
+
+ /**
* If this icon represents an instant app
*/
@NonNull
@@ -523,15 +580,6 @@ public class BaseIconFactory implements AutoCloseable {
mGenerationMode = generationMode;
return this;
}
-
- /**
- * Used to determine the badge type for this icon.
- */
- @NonNull
- public IconOptions setIsCloneProfile(boolean isCloneProfile) {
- mIsCloneProfile = isCloneProfile;
- return this;
- }
}
/**
@@ -615,4 +663,17 @@ public class BaseIconFactory implements AutoCloseable {
mTextPaint);
}
}
+
+ private static class EmptyWrapper extends DrawableWrapper {
+
+ EmptyWrapper() {
+ super(new ColorDrawable());
+ }
+
+ @Override
+ public ConstantState getConstantState() {
+ Drawable d = getDrawable();
+ return d == null ? null : d.getConstantState();
+ }
+ }
}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java
index d1ef6f7..86db5b8 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java
@@ -16,10 +16,10 @@
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;
+import android.graphics.drawable.Drawable;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
@@ -29,13 +29,15 @@ import com.android.launcher3.util.FlagOp;
public class BitmapInfo {
- static final int FLAG_WORK = 1 << 0;
- static final int FLAG_INSTANT = 1 << 1;
- static final int FLAG_CLONE = 1 << 2;
+ public static final int FLAG_WORK = 1 << 0;
+ public static final int FLAG_INSTANT = 1 << 1;
+ public static final int FLAG_CLONE = 1 << 2;
+ public static final int FLAG_PRIVATE = 1 << 3;
@IntDef(flag = true, value = {
FLAG_WORK,
FLAG_INSTANT,
- FLAG_CLONE
+ FLAG_CLONE,
+ FLAG_PRIVATE
})
@interface BitmapInfoFlags {}
@@ -151,25 +153,35 @@ public class BitmapInfo {
protected void applyFlags(Context context, FastBitmapDrawable drawable,
@DrawableCreationFlags int creationFlags) {
drawable.mDisabledAlpha = GraphicsUtils.getFloat(context, R.attr.disabledIconAlpha, 1f);
+ drawable.mCreationFlags = creationFlags;
if ((creationFlags & FLAG_NO_BADGE) == 0) {
- if (badgeInfo != null) {
- drawable.setBadge(badgeInfo.newIcon(context, creationFlags));
- } else if ((flags & FLAG_INSTANT) != 0) {
- 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(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));
+ Drawable badge = getBadgeDrawable(context, (creationFlags & FLAG_THEMED) != 0);
+ if (badge != null) {
+ drawable.setBadge(badge);
}
}
}
+ /**
+ * Returns a drawable representing the badge for this info
+ */
+ @Nullable
+ public Drawable getBadgeDrawable(Context context, boolean isThemed) {
+ if (badgeInfo != null) {
+ return badgeInfo.newIcon(context, isThemed ? FLAG_THEMED : 0);
+ } else if ((flags & FLAG_INSTANT) != 0) {
+ return new UserBadgeDrawable(context, R.drawable.ic_instant_app_badge, isThemed);
+ } else if ((flags & FLAG_WORK) != 0) {
+ return new UserBadgeDrawable(context, R.drawable.ic_work_app_badge, isThemed);
+ } else if ((flags & FLAG_CLONE) != 0) {
+ return new UserBadgeDrawable(context, R.drawable.ic_clone_app_badge, isThemed);
+ } else if ((flags & FLAG_PRIVATE) != 0) {
+ return new UserBadgeDrawable(
+ context, R.drawable.ic_private_profile_app_badge, isThemed);
+ }
+ return null;
+ }
+
public static BitmapInfo fromBitmap(@NonNull Bitmap bitmap) {
return of(bitmap, 0);
}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java
index 0ee9db9..613fca8 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java
@@ -17,6 +17,8 @@
package com.android.launcher3.icons;
import static com.android.launcher3.icons.BaseIconFactory.getBadgeSizeForIconSize;
+import static com.android.launcher3.icons.BitmapInfo.FLAG_NO_BADGE;
+import static com.android.launcher3.icons.BitmapInfo.FLAG_THEMED;
import static com.android.launcher3.icons.GraphicsUtils.setColorAlphaBound;
import android.animation.ObjectAnimator;
@@ -40,6 +42,8 @@ import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.graphics.ColorUtils;
+import com.android.launcher3.icons.BitmapInfo.DrawableCreationFlags;
+
public class FastBitmapDrawable extends Drawable implements Drawable.Callback {
private static final Interpolator ACCEL = new AccelerateInterpolator();
@@ -71,6 +75,8 @@ public class FastBitmapDrawable extends Drawable implements Drawable.Callback {
protected boolean mIsDisabled;
float mDisabledAlpha = 1f;
+ @DrawableCreationFlags int mCreationFlags = 0;
+
// Animator and properties for the fast bitmap drawable's scale
@VisibleForTesting protected static final FloatProperty<FastBitmapDrawable> SCALE
= new FloatProperty<FastBitmapDrawable>("scale") {
@@ -155,6 +161,14 @@ public class FastBitmapDrawable extends Drawable implements Drawable.Callback {
return false;
}
+ /**
+ * Returns true if the drawable was created with theme, even if it doesn't
+ * support theming itself.
+ */
+ public boolean isCreatedForTheme() {
+ return isThemed() || (mCreationFlags & FLAG_THEMED) != 0;
+ }
+
@Override
public void setColorFilter(ColorFilter cf) {
mColorFilter = cf;
@@ -320,6 +334,7 @@ public class FastBitmapDrawable extends Drawable implements Drawable.Callback {
if (mBadge != null) {
cs.mBadgeConstantState = mBadge.getConstantState();
}
+ cs.mCreationFlags = mCreationFlags;
return cs;
}
@@ -327,6 +342,11 @@ public class FastBitmapDrawable extends Drawable implements Drawable.Callback {
return getDisabledColorFilter(1);
}
+ // Returns if the FastBitmapDrawable contains a badge.
+ public boolean hasBadge() {
+ return (mCreationFlags & FLAG_NO_BADGE) == 0;
+ }
+
private static ColorFilter getDisabledColorFilter(float disabledAlpha) {
ColorMatrix tempBrightnessMatrix = new ColorMatrix();
ColorMatrix tempFilterMatrix = new ColorMatrix();
@@ -398,6 +418,8 @@ public class FastBitmapDrawable extends Drawable implements Drawable.Callback {
protected boolean mIsDisabled;
private ConstantState mBadgeConstantState;
+ @DrawableCreationFlags int mCreationFlags = 0;
+
public FastBitmapConstantState(Bitmap bitmap, int color) {
mBitmap = bitmap;
mIconColor = color;
@@ -414,6 +436,7 @@ public class FastBitmapDrawable extends Drawable implements Drawable.Callback {
if (mBadgeConstantState != null) {
drawable.setBadge(mBadgeConstantState.newDrawable());
}
+ drawable.mCreationFlags = mCreationFlags;
return drawable;
}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/FixedScaleDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/FixedScaleDrawable.java
deleted file mode 100644
index 516965e..0000000
--- a/iconloaderlib/src/com/android/launcher3/icons/FixedScaleDrawable.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.android.launcher3.icons;
-
-import android.content.res.Resources;
-import android.content.res.Resources.Theme;
-import android.graphics.Canvas;
-import android.graphics.drawable.ColorDrawable;
-import android.graphics.drawable.DrawableWrapper;
-import android.util.AttributeSet;
-
-import org.xmlpull.v1.XmlPullParser;
-
-/**
- * Extension of {@link DrawableWrapper} which scales the child drawables by a fixed amount.
- */
-public class FixedScaleDrawable extends DrawableWrapper {
-
- // TODO b/33553066 use the constant defined in MaskableIconDrawable
- private static final float LEGACY_ICON_SCALE = .7f * .6667f;
- private float mScaleX, mScaleY;
-
- public FixedScaleDrawable() {
- super(new ColorDrawable());
- mScaleX = LEGACY_ICON_SCALE;
- mScaleY = LEGACY_ICON_SCALE;
- }
-
- @Override
- public void draw(Canvas canvas) {
- int saveCount = canvas.save();
- canvas.scale(mScaleX, mScaleY,
- getBounds().exactCenterX(), getBounds().exactCenterY());
- super.draw(canvas);
- canvas.restoreToCount(saveCount);
- }
-
- @Override
- public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs) { }
-
- @Override
- public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs, Theme theme) { }
-
- public void setScale(float scale) {
- float h = getIntrinsicHeight();
- float w = getIntrinsicWidth();
- mScaleX = scale * LEGACY_ICON_SCALE;
- mScaleY = scale * LEGACY_ICON_SCALE;
- if (h > w && w > 0) {
- mScaleX *= w / h;
- } else if (w > h && h > 0) {
- mScaleY *= h / w;
- }
- }
-}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java b/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java
index 99f6813..7aab47c 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java
@@ -109,7 +109,8 @@ public class ShadowGenerator {
scale = (HALF_DISTANCE - BLUR_FACTOR) / (HALF_DISTANCE - minSide);
}
- float bottomSpace = BLUR_FACTOR + KEY_SHADOW_DISTANCE;
+ // We are ignoring KEY_SHADOW_DISTANCE because regular icons ignore this at the moment b/298203449
+ float bottomSpace = BLUR_FACTOR;
if (bounds.bottom < bottomSpace) {
scale = Math.min(scale,
(HALF_DISTANCE - bottomSpace) / (HALF_DISTANCE - bounds.bottom));
diff --git a/iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java
new file mode 100644
index 0000000..96d8cc4
--- /dev/null
+++ b/iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java
@@ -0,0 +1,135 @@
+/*
+ * 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.launcher3.icons;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.Resources.Theme;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.DrawableWrapper;
+
+import androidx.annotation.NonNull;
+
+/**
+ * A drawable used for drawing user badge. It draws a circle around the actual badge,
+ * and has support for theming.
+ */
+public class UserBadgeDrawable extends DrawableWrapper {
+
+ private static final float VIEWPORT_SIZE = 24;
+ private static final float CENTER = VIEWPORT_SIZE / 2;
+
+ private static final float BG_RADIUS = 11;
+ private static final float SHADOW_RADIUS = 11.5f;
+ private static final float SHADOW_OFFSET_Y = 0.25f;
+
+ private static final int SHADOW_COLOR = 0x11000000;
+
+ private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private final int mBgColor;
+ private boolean mShouldDrawBackground = true;
+
+ public UserBadgeDrawable(Context context, int badgeRes, boolean isThemed) {
+ super(context.getDrawable(badgeRes));
+
+ if (isThemed) {
+ mutate();
+ setTint(context.getColor(R.color.themed_badge_icon_color));
+ mBgColor = context.getColor(R.color.themed_badge_icon_background_color);
+ } else {
+ mBgColor = Color.WHITE;
+ }
+ }
+
+ private UserBadgeDrawable(Drawable base, int bgColor, boolean shouldDrawBackground) {
+ super(base);
+ mBgColor = bgColor;
+ mShouldDrawBackground = shouldDrawBackground;
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ if (mShouldDrawBackground) {
+ Rect b = getBounds();
+ int saveCount = canvas.save();
+ canvas.translate(b.left, b.top);
+ canvas.scale(b.width() / VIEWPORT_SIZE, b.height() / VIEWPORT_SIZE);
+
+ mPaint.setColor(SHADOW_COLOR);
+ canvas.drawCircle(CENTER, CENTER + SHADOW_OFFSET_Y, SHADOW_RADIUS, mPaint);
+
+ mPaint.setColor(mBgColor);
+ canvas.drawCircle(CENTER, CENTER, BG_RADIUS, mPaint);
+
+ canvas.restoreToCount(saveCount);
+ }
+ super.draw(canvas);
+ }
+
+ public void setShouldDrawBackground(boolean shouldDrawBackground) {
+ mutate();
+ mShouldDrawBackground = shouldDrawBackground;
+ }
+
+ @Override
+ public ConstantState getConstantState() {
+ return new MyConstantState(
+ getDrawable().getConstantState(), mBgColor, mShouldDrawBackground);
+ }
+
+ private static class MyConstantState extends ConstantState {
+
+ private final ConstantState mBase;
+ private final int mBgColor;
+ private final boolean mShouldDrawBackground;
+
+ public MyConstantState(ConstantState base, int bgColor, boolean shouldDrawBackground) {
+ mBase = base;
+ mBgColor = bgColor;
+ mShouldDrawBackground = shouldDrawBackground;
+ }
+
+ @Override
+ public int getChangingConfigurations() {
+ return mBase.getChangingConfigurations();
+ }
+
+ @Override
+ @NonNull
+ public Drawable newDrawable() {
+ return new UserBadgeDrawable(mBase.newDrawable(), mBgColor, mShouldDrawBackground);
+ }
+
+ @Override
+ @NonNull
+ public Drawable newDrawable(Resources res) {
+ return new UserBadgeDrawable(mBase.newDrawable(res), mBgColor, mShouldDrawBackground);
+ }
+
+ @Override
+ @NonNull
+ public Drawable newDrawable(Resources res, Theme theme) {
+ return new UserBadgeDrawable(mBase.newDrawable(res, theme),
+ mBgColor, mShouldDrawBackground);
+ }
+ }
+}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java
index bdc4410..d6cd0f2 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java
@@ -21,6 +21,7 @@ import static com.android.launcher3.icons.BaseIconFactory.getFullResDefaultActiv
import static com.android.launcher3.icons.BitmapInfo.LOW_RES_ICON;
import static com.android.launcher3.icons.GraphicsUtils.flattenBitmap;
import static com.android.launcher3.icons.GraphicsUtils.setColorAlphaBound;
+import static com.android.launcher3.icons.cache.IconCacheUpdateHandler.ICON_UPDATE_TOKEN;
import static java.util.Objects.requireNonNull;
@@ -329,9 +330,11 @@ public abstract class BaseIconCache {
if (entry.bitmap.isNullOrLowRes()) return;
CharSequence entryTitle = cachingLogic.getLabel(object);
- if (entryTitle == null) {
- Log.wtf(TAG, "No label returned from caching logic instance: " + cachingLogic);
- entryTitle = "";
+ if (TextUtils.isEmpty(entryTitle)) {
+ if (entryTitle == null) {
+ Log.wtf(TAG, "No label returned from caching logic instance: " + cachingLogic);
+ }
+ entryTitle = componentName.getPackageName();;
}
entry.title = entryTitle;
@@ -490,13 +493,23 @@ public abstract class BaseIconCache {
@NonNull final T object, @NonNull final CacheEntry entry,
@NonNull final CachingLogic<T> cachingLogic, @NonNull final UserHandle user) {
entry.title = cachingLogic.getLabel(object);
+ if (TextUtils.isEmpty(entry.title)) {
+ entry.title = cachingLogic.getComponent(object).getPackageName();
+ }
entry.contentDescription = getUserBadgedLabel(
cachingLogic.getDescription(object, entry.title), user);
}
- public synchronized void clear() {
+ public synchronized void clearMemoryCache() {
assertWorkerThread();
- mIconDb.clear();
+ mCache.clear();
+ }
+
+ /**
+ * Returns true if an icon update is in progress
+ */
+ public boolean isIconUpdateInProgress() {
+ return mWorkerHandler.hasMessages(0, ICON_UPDATE_TOKEN);
}
/**
diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/IconCacheUpdateHandler.java b/iconloaderlib/src/com/android/launcher3/icons/cache/IconCacheUpdateHandler.java
index aec1cdd..7e09bd6 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/cache/IconCacheUpdateHandler.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/cache/IconCacheUpdateHandler.java
@@ -57,7 +57,7 @@ public class IconCacheUpdateHandler {
*/
private static final boolean MODE_CLEAR_VALID_ITEMS = false;
- private static final Object ICON_UPDATE_TOKEN = new Object();
+ static final Object ICON_UPDATE_TOKEN = new Object();
private final HashMap<String, PackageInfo> mPkgInfoMap;
private final BaseIconCache mIconCache;
diff --git a/iconloaderlib/src/com/android/launcher3/util/UserIconInfo.java b/iconloaderlib/src/com/android/launcher3/util/UserIconInfo.java
new file mode 100644
index 0000000..f1d753d
--- /dev/null
+++ b/iconloaderlib/src/com/android/launcher3/util/UserIconInfo.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.util;
+
+import static com.android.launcher3.icons.BitmapInfo.FLAG_CLONE;
+import static com.android.launcher3.icons.BitmapInfo.FLAG_PRIVATE;
+import static com.android.launcher3.icons.BitmapInfo.FLAG_WORK;
+
+import android.os.UserHandle;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+
+/**
+ * Data class which stores various properties of a {@link android.os.UserHandle}
+ * which affects rendering
+ */
+public class UserIconInfo {
+
+ public static final int TYPE_MAIN = 0;
+ public static final int TYPE_WORK = 1;
+ public static final int TYPE_CLONED = 2;
+
+ public static final int TYPE_PRIVATE = 3;
+
+ @IntDef({TYPE_MAIN, TYPE_WORK, TYPE_CLONED, TYPE_PRIVATE})
+ public @interface UserType { }
+
+ public final UserHandle user;
+ @UserType
+ public final int type;
+
+ public final long userSerial;
+
+ public UserIconInfo(UserHandle user, @UserType int type) {
+ this(user, type, 0);
+ }
+
+ public UserIconInfo(UserHandle user, @UserType int type, long userSerial) {
+ this.user = user;
+ this.type = type;
+ this.userSerial = userSerial;
+ }
+
+ public boolean isMain() {
+ return type == TYPE_MAIN;
+ }
+
+ public boolean isWork() {
+ return type == TYPE_WORK;
+ }
+
+ public boolean isCloned() {
+ return type == TYPE_CLONED;
+ }
+
+ public boolean isPrivate() {
+ return type == TYPE_PRIVATE;
+ }
+
+ @NonNull
+ public FlagOp applyBitmapInfoFlags(@NonNull FlagOp op) {
+ return op.setFlag(FLAG_WORK, isWork())
+ .setFlag(FLAG_CLONE, isCloned())
+ .setFlag(FLAG_PRIVATE, isPrivate());
+ }
+}
diff --git a/toruslib/Android.bp b/toruslib/Android.bp
new file mode 100644
index 0000000..e42f205
--- /dev/null
+++ b/toruslib/Android.bp
@@ -0,0 +1,53 @@
+// 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 {
+ default_applicable_licenses: [
+ "Android-Apache-2.0",
+ ],
+}
+
+android_library {
+ name: "toruslib",
+ srcs: [
+ "torus-core/src/**/*.java",
+ "torus-core/src/**/*.kt",
+ "torus-framework-canvas/**/*.java",
+ "torus-framework-canvas/**/*.kt",
+ "torus-math/src/**/*.java",
+ "torus-math/src/**/*.kt",
+ "torus-utils/src/**/*.java",
+ "torus-utils/src/**/*.kt",
+ "torus-wallpaper-settings/src/**/*.java",
+ "torus-wallpaper-settings/src/**/*.kt",
+ ],
+ static_libs: [
+ "androidx.slice_slice-core",
+ "androidx.slice_slice-builders",
+ "androidx.core_core-ktx",
+ "androidx.appcompat_appcompat",
+ ],
+ resource_dirs: [
+ "torus-wallpaper-settings/src/main/res",
+ ],
+ asset_dirs: [
+ ],
+ manifest: "lib-torus/src/main/AndroidManifest.xml",
+ optimize: {
+ enabled: true,
+ },
+ min_sdk_version: "31",
+ sdk_version: "system_current",
+}
+
diff --git a/toruslib/OWNERS b/toruslib/OWNERS
new file mode 100644
index 0000000..5b9b5c0
--- /dev/null
+++ b/toruslib/OWNERS
@@ -0,0 +1,4 @@
+yeinj@google.com
+michelcomin@google.com
+shanh@google.com
+dupin@google.com \ No newline at end of file
diff --git a/toruslib/build.gradle b/toruslib/build.gradle
new file mode 100644
index 0000000..f97cb03
--- /dev/null
+++ b/toruslib/build.gradle
@@ -0,0 +1,92 @@
+// 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.
+
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+buildscript {
+ ext.versions = [
+ 'minSdk' : 31,
+ 'targetSdk' : 34,
+ 'compileSdk' : 34,
+ 'buildTools' : '29.0.3',
+ 'kotlin' : '1.6.21',
+ 'ktx' : '1.5.0-beta02',
+ 'material' : '1.2.1',
+ 'appcompat' : '1.3.0',
+ 'androidXLib': '1.1.0-alpha02'
+ ]
+
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath "com.android.tools.build:gradle:7.4.2"
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin"
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+subprojects {
+ if (name.startsWith("torus")) {
+ version = VERSION_NAME
+ group = GROUP
+
+ apply plugin: 'com.android.library'
+ apply plugin: 'kotlin-android'
+
+ android {
+ namespace "com.google.android.torus"
+
+ compileSdkVersion versions.compileSdk
+ buildToolsVersion versions.buildTools
+
+ defaultConfig {
+ minSdkVersion versions.minSdk
+ targetSdkVersion versions.targetSdk
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ consumerProguardFiles 'lib-proguard-rules.txt'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+ }
+
+ dependencies {
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin"
+ implementation "androidx.core:core-ktx:$versions.ktx"
+ implementation "androidx.appcompat:appcompat:$versions.appcompat"
+ }
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
diff --git a/toruslib/gradle.properties b/toruslib/gradle.properties
new file mode 100644
index 0000000..0fd6d84
--- /dev/null
+++ b/toruslib/gradle.properties
@@ -0,0 +1,24 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Automatically convert third-party libraries to use AndroidX
+android.enableJetifier=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+
+VERSION_NAME=1.1.5
+GROUP=com.google.android.libraries.graphics.torus \ No newline at end of file
diff --git a/toruslib/lib-torus/build.gradle b/toruslib/lib-torus/build.gradle
new file mode 100644
index 0000000..70f9316
--- /dev/null
+++ b/toruslib/lib-torus/build.gradle
@@ -0,0 +1,61 @@
+// 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.
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ compileSdkVersion versions.compileSdk
+ buildToolsVersion versions.buildTools
+
+ defaultConfig {
+ minSdkVersion versions.minSdk
+ targetSdkVersion versions.targetSdk
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled true
+ consumerProguardFiles 'lib-proguard-rules.txt'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+
+ sourceSets {
+ main {
+ java.srcDirs += '../torus-core/src/main/java'
+ java.srcDirs += '../torus-framework-canvas/src/main/java'
+ java.srcDirs += '../torus-math/src/main/java'
+ java.srcDirs += '../torus-utils/src/main/java'
+ java.srcDirs += '../torus-wallpaper-settings/src/main/java'
+ java.srcDirs += '../torus-wallpaper-settings/src/main/gen'
+
+ res.srcDirs += '../torus-wallpaper-settings/src/main/res'
+ }
+ }
+}
+
+dependencies {
+ implementation "androidx.appcompat:appcompat:$versions.appcompat"
+ implementation "androidx.core:core-ktx:$versions.ktx"
+ implementation "androidx.slice:slice-builders:$versions.androidXLib"
+ implementation "androidx.slice:slice-core:$versions.androidXLib"
+}
diff --git a/toruslib/lib-torus/gradle.properties b/toruslib/lib-torus/gradle.properties
new file mode 100644
index 0000000..1605871
--- /dev/null
+++ b/toruslib/lib-torus/gradle.properties
@@ -0,0 +1,34 @@
+# 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.
+
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+# Default value: -Xmx10248m -XX:MaxPermSize=256m
+# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# Convert third-party libraries to use AndroidX
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/toruslib/lib-torus/src/main/AndroidManifest.xml b/toruslib/lib-torus/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3c68a96
--- /dev/null
+++ b/toruslib/lib-torus/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+<?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 package="com.google.android.torus" />
diff --git a/toruslib/settings.gradle b/toruslib/settings.gradle
new file mode 100644
index 0000000..74e760b
--- /dev/null
+++ b/toruslib/settings.gradle
@@ -0,0 +1,47 @@
+// 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.
+
+include ':torus-wallpaper-settings'
+include ':torus-math'
+include ':torus-utils'
+include ':torus-framework-canvas'
+include ':torus-core'
+include ':lib-torus'
+
+rootProject.name = "Live Wallpapers Framework"
+
+// Reads wallpaper.build.properties from the project and loads the build
+// settings if it exists
+def wallpaperBuildProperties = file("wallpaper.build.properties")
+def localProperties = new Properties()
+if (wallpaperBuildProperties.canRead())
+ wallpaperBuildProperties.withInputStream { localProperties.load(it) }
+
+def getWallpaperProperty = { name, defaultValue ->
+ if (localProperties.getProperty(name)) {
+ return localProperties.getProperty(name)
+ }
+ return defaultValue
+}
+
+gradle.ext.wallpaperMinSdkVersion = getWallpaperProperty('wallpaperMinSdkVersion', 28)
+gradle.ext.wallpaperTargetSdkVersion = getWallpaperProperty('wallpaperTargetSdkVersion', 30)
+gradle.ext.compileSdkVersion = getWallpaperProperty('compileSdkVersion', 30)
+gradle.ext.buildToolsVersion = getWallpaperProperty('buildToolsVersion', '29.0.3')
+gradle.ext.kotlinVersion = getWallpaperProperty('kotlinVersion', '1.4.32')
+gradle.ext.coreKtxVersion = getWallpaperProperty('coreKtxVersion', '1.6.0')
+gradle.ext.androidXLibVersion_alpha = getWallpaperProperty(
+ 'androidXLibVersion_alpha', '1.1.0-alpha01')
+
+gradle.ext.isPixel = false
diff --git a/toruslib/torus-core/build.gradle b/toruslib/torus-core/build.gradle
new file mode 100644
index 0000000..7dcad1d
--- /dev/null
+++ b/toruslib/torus-core/build.gradle
@@ -0,0 +1,13 @@
+// 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.
diff --git a/toruslib/torus-core/consumer-rules.pro b/toruslib/torus-core/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/toruslib/torus-core/consumer-rules.pro
diff --git a/toruslib/torus-core/proguard-rules.pro b/toruslib/torus-core/proguard-rules.pro
new file mode 100644
index 0000000..cdc313f
--- /dev/null
+++ b/toruslib/torus-core/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile \ No newline at end of file
diff --git a/toruslib/torus-core/src/main/AndroidManifest.xml b/toruslib/torus-core/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3c68a96
--- /dev/null
+++ b/toruslib/torus-core/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+<?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 package="com.google.android.torus" />
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/activity/TorusViewerActivity.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/activity/TorusViewerActivity.kt
new file mode 100644
index 0000000..b784545
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/activity/TorusViewerActivity.kt
@@ -0,0 +1,120 @@
+/*
+ * 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.google.android.torus.core.activity
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.ActivityInfo
+import android.content.res.Configuration
+import android.os.Bundle
+import android.view.SurfaceHolder
+import android.view.SurfaceView
+import androidx.appcompat.app.AppCompatActivity
+import com.google.android.torus.core.content.ConfigurationChangeListener
+import com.google.android.torus.core.engine.TorusEngine
+import com.google.android.torus.core.engine.listener.TorusTouchListener
+
+/**
+ * Helper activity to show a [TorusEngine] into a [SurfaceView]. To use it, you should override
+ * [getWallpaperEngine] and return an instance of your[TorusEngine] that will draw inside the
+ * given surface.
+ *
+ * Note: [TorusViewerActivity] subclasses must include the following attribute/s
+ * in the AndroidManifest.xml:
+ * - android:configChanges="uiMode"
+ */
+abstract class TorusViewerActivity : AppCompatActivity() {
+ private lateinit var wallpaperEngine: TorusEngine
+ private lateinit var surfaceView: SurfaceView
+
+ /**
+ * Must be implemented to return a new instance of [TorusEngine].
+ */
+ abstract fun getWallpaperEngine(context: Context, surfaceView: SurfaceView): TorusEngine
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Check that class includes the proper attributes in the AndroidManifest.xml
+ checkManifestAttributes()
+
+ surfaceView = SurfaceView(this).apply { setContentView(this) }
+ wallpaperEngine = getWallpaperEngine(this, surfaceView)
+ wallpaperEngine.create()
+ surfaceView.holder.addCallback(object : SurfaceHolder.Callback {
+ override fun surfaceChanged(
+ holder: SurfaceHolder,
+ format: Int,
+ width: Int,
+ height: Int
+ ) {
+ wallpaperEngine.resize(width, height)
+ }
+
+ override fun surfaceDestroyed(holder: SurfaceHolder) {
+ }
+
+ override fun surfaceCreated(holder: SurfaceHolder) {
+ }
+ })
+
+ // Pass the touch events.
+ if (wallpaperEngine is TorusTouchListener) {
+ surfaceView.setOnTouchListener { _, event ->
+ (wallpaperEngine as TorusTouchListener).onTouchEvent(event)
+ true
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ wallpaperEngine.resume()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ wallpaperEngine.pause()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ wallpaperEngine.destroy()
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+
+ if (wallpaperEngine is ConfigurationChangeListener) {
+ (wallpaperEngine as ConfigurationChangeListener).onConfigurationChanged(newConfig)
+ }
+ }
+
+ private fun checkManifestAttributes() {
+ val configChange = packageManager.getActivityInfo(componentName, 0).configChanges
+
+ // Check if Activity sets android:configChanges="uiMode" in the manifest.
+ if ((configChange and ActivityInfo.CONFIG_UI_MODE) != ActivityInfo.CONFIG_UI_MODE) {
+ throw RuntimeException(
+ "${TorusViewerActivity::class.simpleName} " +
+ "has to include the attribute android:configChanges=\"uiMode\" " +
+ "in the AndroidManifest.xml"
+ )
+ }
+ }
+}
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/app/KeyguardLockController.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/app/KeyguardLockController.kt
new file mode 100644
index 0000000..cfa378c
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/app/KeyguardLockController.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.google.android.torus.core.app
+
+import android.app.KeyguardManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+
+/**
+ * Listens to keyguard lock state changes.
+ *
+ * @constructor Creates a new [KeyguardLockController].
+ * @param lockStateListener a listener that we receive Keyguard Lock state changes.
+ */
+class KeyguardLockController(
+ private val context: Context,
+ private val lockStateListener: LockStateListener? = null
+) {
+ @Volatile
+ var locked: Boolean = false
+ private set
+
+ private val userPresentReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_USER_PRESENT) onChange(false)
+ }
+ }
+
+ private val userPresentIntentFilter: IntentFilter = IntentFilter(Intent.ACTION_USER_PRESENT)
+ private val keyguardManager =
+ context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager?
+ private var isRegistered: Boolean = false
+
+ init {
+ keyguardManager?.let { locked = it.isKeyguardLocked }
+ }
+
+ /**
+ * Starts listening for [Intent.ACTION_USER_PRESENT] state changes. This should be used
+ * together with [KeyguardLockController.updateLockState] to detect lock state changes. Using a
+ * broadcast listener is not ideal, but there isn't an alternative event to detect lock state
+ * changes.
+ */
+ fun start() {
+ context.registerReceiver(userPresentReceiver, userPresentIntentFilter)
+ isRegistered = true
+ }
+
+ /**
+ * Stops listening for [Intent.ACTION_USER_PRESENT] state changes. This should be used
+ * together with [KeyguardLockController.updateLockState] to detect lock state changes. Using a
+ * broadcast listener is not ideal, but there isn't an alternative event to detect lock state
+ * changes.
+ */
+ fun stop() {
+ if (isRegistered) {
+ context.unregisterReceiver(userPresentReceiver)
+ isRegistered = false
+ }
+ }
+
+ /**
+ * Reads the [KeyguardManager.isKeyguardLocked] new value to know the current Lock state.
+ * This function should be also called on Screen state changes (i.e. [Intent.ACTION_SCREEN_ON],
+ * [Intent.ACTION_SCREEN_OFF]). This function can be used also to do polling of the lock state.
+ */
+ fun updateLockState() = keyguardManager?.let { onChange(it.isKeyguardLocked) }
+
+ private fun onChange(locked: Boolean) {
+ if (this.locked != locked) {
+ this.locked = locked
+ lockStateListener?.onLockStateChanged(locked)
+ }
+ }
+
+ /** Interface to listen to Keyguard lock changes. */
+ interface LockStateListener {
+ /**
+ * Called when the Keyguard lock state has changed.
+ *
+ * @param locked true if the keyguard is currently locked.
+ */
+ fun onLockStateChanged(locked: Boolean)
+ }
+}
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/content/ConfigurationChangeListener.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/content/ConfigurationChangeListener.kt
new file mode 100644
index 0000000..e6c479a
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/content/ConfigurationChangeListener.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.google.android.torus.core.content
+
+import android.content.res.Configuration
+
+/**
+ * Interface to listen to configuration changes.
+ */
+interface ConfigurationChangeListener {
+ fun onConfigurationChanged(newConfig: Configuration)
+} \ No newline at end of file
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/engine/TorusEngine.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/engine/TorusEngine.kt
new file mode 100644
index 0000000..9685816
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/engine/TorusEngine.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.google.android.torus.core.engine
+
+import com.google.android.torus.core.wallpaper.LiveWallpaper
+
+/**
+ * Interface that defines a Live Wallpaper Engine and its different states. You need to implement
+ * this class to render using [LiveWallpaper].
+ */
+interface TorusEngine {
+ /**
+ * Called when the engine is created. You should load the assets and initialize the
+ * resources here.
+ *
+ * IMPORTANT: When this function is called, the surface used to render the engine has to be
+ * ready.
+ *
+ * @param isFirstActiveInstance Whether this is the first Engine instance (since the last time
+ * that all instances were destroyed).
+ */
+ fun create(isFirstActiveInstance: Boolean = true)
+
+ /**
+ * Called when the [TorusEngine] resumes.
+ */
+ fun resume()
+
+ /**
+ * Called when the [TorusEngine] is paused.
+ */
+ fun pause()
+
+ /**
+ * Called when the surface holding the [TorusEngine] has changed its size.
+ *
+ * @param width The new width of the surface holding the [TorusEngine].
+ * @param height The new height of the surface holding the [TorusEngine].
+ */
+ fun resize(width: Int, height: Int)
+
+ /**
+ * Called when we need to destroy the surface.
+ *
+ * @param isLastActiveInstance Whether this was the last Engine instance in our Service.
+ */
+ fun destroy(isLastActiveInstance: Boolean = true)
+}
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/engine/listener/TorusTouchListener.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/engine/listener/TorusTouchListener.kt
new file mode 100644
index 0000000..4ceba4a
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/engine/listener/TorusTouchListener.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.google.android.torus.core.engine.listener
+
+import android.view.MotionEvent
+import com.google.android.torus.core.engine.TorusEngine
+
+/**
+ * Allows to receive Touch events.
+ * The Interface must be implemented by a [TorusEngine] instance,
+ */
+interface TorusTouchListener {
+ /**
+ * Called when a touch event has been triggered.
+ *
+ * @param event The new [MotionEvent].
+ */
+ fun onTouchEvent(event: MotionEvent)
+} \ No newline at end of file
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/extensions/ConfigurationExt.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/extensions/ConfigurationExt.kt
new file mode 100644
index 0000000..2dd043b
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/extensions/ConfigurationExt.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.google.android.torus.core.extensions
+
+import android.content.res.Configuration
+
+/**
+ * Extension function that serves as a shortcut to know if we are currently in Dark Mode.
+ *
+ * @return true if we are in Dark Mode; false otherwise.
+ */
+fun Configuration.isDarkMode(): Boolean {
+ return (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
+} \ No newline at end of file
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/geometry/Vertex.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/geometry/Vertex.kt
new file mode 100644
index 0000000..57453bf
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/geometry/Vertex.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.google.android.torus.core.geometry
+
+import java.nio.ByteBuffer
+
+/**
+ * Defines the information a vertex, as an array of different numbers.
+ */
+class Vertex(vararg input: Number) {
+ private val vertexValues: ArrayList<Number> = ArrayList()
+
+ init {
+ for (item in input) {
+ vertexValues.add(item)
+ }
+ }
+
+ /**
+ * Function that will help to add each vertex into a ByteBuffer.
+ * @param buffer The [ByteBuffer] where we are adding the current vertex.
+ */
+ fun putInto(buffer: ByteBuffer) {
+ for (vertexValue in vertexValues) {
+ when (vertexValue) {
+ is Float -> buffer.putFloat(vertexValue.toFloat())
+ is Int -> buffer.putInt(vertexValue.toInt())
+ is Short -> buffer.putShort(vertexValue.toShort())
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/power/FpsThrottler.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/power/FpsThrottler.kt
new file mode 100644
index 0000000..873327d
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/power/FpsThrottler.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.google.android.torus.core.power
+
+/**
+ * This class determines ready-to-render conditions for the engine's main loop in order to target a
+ * requested frame rate.
+ */
+class FpsThrottler {
+ companion object {
+ private const val NANO_TO_MILLIS = 1 / 1E6
+
+ const val FPS_120 = 120f
+ const val FPS_60 = 60f
+ const val FPS_30 = 30f
+ const val FPS_18 = 18f
+
+ @Deprecated(message = "Use FPS_60 instead.")
+ const val HIGH_FPS = 60f
+ @Deprecated(message = "Use FPS_30 instead.")
+ const val MED_FPS = 30f
+ @Deprecated(message = "Use FPS_18 instead.")
+ const val LOW_FPS = 18f
+ }
+
+ private var fps: Float = FPS_60
+
+ @Volatile
+ private var frameTimeMillis: Double = 1000.0 / fps.toDouble()
+ private var lastFrameTimeNanos: Long = -1
+
+ @Volatile
+ private var continuousRenderingMode: Boolean = true
+
+ @Volatile
+ private var requestRendering: Boolean = false
+
+ private fun updateFrameTime() {
+ frameTimeMillis = 1000.0 / fps.toDouble()
+ }
+
+ /**
+ * If [fps] is non-zero, update the requested FPS and calculate the frame time
+ * for the requested FPS. Otherwise disable continuous rendering (on demand rendering)
+ * without changing the frame rate.
+ *
+ * @param fps The requested FPS value.
+ */
+ fun updateFps(fps: Float) {
+ if (fps <= 0f) {
+ setContinuousRenderingMode(false)
+ } else {
+ setContinuousRenderingMode(true)
+ this.fps = fps
+ updateFrameTime()
+ }
+ }
+
+ /**
+ * Sets rendering mode to continuous or on demand.
+ *
+ * @param continuousRenderingMode When true enable continuous rendering. When false disable
+ * continuous rendering (on demand).
+ */
+ fun setContinuousRenderingMode(continuousRenderingMode: Boolean) {
+ this.continuousRenderingMode = continuousRenderingMode
+ }
+
+ /** Request a new render frame (in on demand rendering mode). */
+ fun requestRendering() {
+ requestRendering = true
+ }
+
+ /**
+ * Calculates whether we can render the next frame. In continuous mode return true only
+ * if enough time has passed since the last render to maintain requested FPS.
+ * In on demand mode, return true only if [requestRendering] was called to render
+ * the next frame.
+ *
+ * @param frameTimeNanos The time in nanoseconds when the current frame started.
+ *
+ * @return true if we can render the next frame.
+ */
+ fun canRender(frameTimeNanos: Long): Boolean {
+ return if (continuousRenderingMode) {
+ // continuous rendering
+ if (lastFrameTimeNanos == -1L) {
+ true
+ } else {
+ val deltaMillis = (frameTimeNanos - lastFrameTimeNanos) * NANO_TO_MILLIS
+ return (deltaMillis >= frameTimeMillis) && (fps > 0f)
+ }
+ } else {
+ // on demand rendering
+ requestRendering
+ }
+ }
+
+ /**
+ * Attempt to render a frame, if throttling permits it at this time. The delegate
+ * [onRenderPermitted] will be called to handle the rendering if so. The delegate may decide to
+ * skip the frame for any other reason, and then should return false. If the frame is actually
+ * rendered, the delegate must return true to ensure that the next frame will be scheduled for
+ * the correct time.
+ *
+ * @param frameTimeNanos The time in nanoseconds when the current frame started.
+ * @param onRenderPermitted The client delegate to dispatch if rendering is permitted at this
+ * time.
+ *
+ * @return true if a frame is permitted and then actually rendered.
+ */
+ fun tryRender(frameTimeNanos: Long, onRenderPermitted: () -> Boolean): Boolean {
+ if (canRender(frameTimeNanos) && onRenderPermitted()) {
+ // For pacing, record the time when the frame *started*, not when it finished rendering.
+ lastFrameTimeNanos = frameTimeNanos
+ requestRendering = false
+ return true
+ }
+ return false
+ }
+}
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/time/TimeController.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/time/TimeController.kt
new file mode 100644
index 0000000..c8647f8
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/time/TimeController.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.google.android.torus.core.time
+
+import android.os.SystemClock
+
+/**
+ * Class in charge of controlling the delta time and the elapsed time. This will help with a
+ * common scenario in any Computer Graphics engine.
+ */
+class TimeController {
+ companion object {
+ private val MIN_THRESHOLD_OVERFLOW = Float.MAX_VALUE / 1E20f
+ private const val MILLIS_TO_SECONDS = 1 / 1000f
+ }
+
+ /**
+ * The elapsed time, since the last time it was reset, in seconds.
+ */
+ var elapsedTime = 0f
+ private set
+
+ /**
+ * The delta time from the last frame, in milliseconds.
+ */
+ var deltaTimeMillis: Long = 0
+ private set
+
+ private var lastTimeMillis: Long = 0
+
+ init {
+ resetDeltaTime()
+ }
+
+ /**
+ * Resets the delta time and last time since previous frame, and sets last time to
+ * [currentTimeMillis] and increases the elapsed time.
+ *
+ * @property currentTimeMillis The last known frame time, in milliseconds.
+ */
+ fun resetDeltaTime(currentTimeMillis: Long = SystemClock.elapsedRealtime()) {
+ lastTimeMillis = currentTimeMillis
+ elapsedTime += deltaTimeMillis * MILLIS_TO_SECONDS
+ deltaTimeMillis = 0
+ }
+
+ /**
+ * Resets elapse time in case is bigger than the max value (to avoid overflow)
+ */
+ fun resetElapsedTimeIfNeeded() {
+ if (elapsedTime > MIN_THRESHOLD_OVERFLOW) {
+ elapsedTime = 0f
+ }
+ }
+
+ /**
+ * Calculates the delta time (in milliseconds) based on the current time
+ * and the last saved time.
+ *
+ * @property currentTimeMillis The last known frame time, in milliseconds.
+ */
+ fun updateDeltaTime(currentTimeMillis: Long = SystemClock.elapsedRealtime()) {
+ deltaTimeMillis = currentTimeMillis - lastTimeMillis
+ }
+} \ No newline at end of file
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/LiveWallpaper.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/LiveWallpaper.kt
new file mode 100644
index 0000000..9a5d96e
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/LiveWallpaper.kt
@@ -0,0 +1,441 @@
+/*
+ * 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.google.android.torus.core.wallpaper
+
+import android.app.WallpaperColors
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.res.Configuration
+import android.graphics.PixelFormat
+import android.os.Build
+import android.os.Bundle
+import android.service.wallpaper.WallpaperService
+import android.view.MotionEvent
+import android.view.SurfaceHolder
+import androidx.annotation.RequiresApi
+import com.google.android.torus.core.content.ConfigurationChangeListener
+import com.google.android.torus.core.engine.TorusEngine
+import com.google.android.torus.core.engine.listener.TorusTouchListener
+import com.google.android.torus.core.wallpaper.listener.LiveWallpaperEventListener
+import com.google.android.torus.core.wallpaper.listener.LiveWallpaperKeyguardEventListener
+import java.lang.ref.WeakReference
+
+/**
+ * Implements [WallpaperService] using Filament to render the wallpaper.
+ * An instance of this class should only implement [getWallpaperEngine]
+ *
+ * Note: [LiveWallpaper] subclasses must include the following attribute/s
+ * in the AndroidManifest.xml:
+ * - android:configChanges="uiMode"
+ */
+abstract class LiveWallpaper : WallpaperService() {
+ private companion object {
+ const val COMMAND_REAPPLY = "android.wallpaper.reapply"
+ const val COMMAND_WAKING_UP = "android.wallpaper.wakingup"
+ const val COMMAND_KEYGUARD_GOING_AWAY = "android.wallpaper.keyguardgoingaway"
+ const val COMMAND_GOING_TO_SLEEP = "android.wallpaper.goingtosleep"
+ const val COMMAND_PREVIEW_INFO = "android.wallpaper.previewinfo"
+ const val WALLPAPER_FLAG_NOT_FOUND = -1
+ }
+
+ // Holds the number of concurrent engines.
+ private var numEngines = 0
+
+ // We can have multiple ConfigurationChangeListener because we can have multiple engines.
+ private val configChangeListeners: ArrayList<WeakReference<ConfigurationChangeListener>> =
+ ArrayList()
+
+ // This is only needed for <= android R.
+ private val wakeStateChangeListeners: ArrayList<WeakReference<LiveWallpaperEngineWrapper>> =
+ ArrayList()
+ private lateinit var wakeStateReceiver: BroadcastReceiver
+
+ override fun onCreate() {
+ super.onCreate()
+
+ val wakeStateChangeIntentFilter = IntentFilter()
+ wakeStateChangeIntentFilter.addAction(Intent.ACTION_SCREEN_ON)
+ wakeStateChangeIntentFilter.addAction(Intent.ACTION_SCREEN_OFF)
+
+ /*
+ * Only For Android R (SDK 30) or lower. Starting from S we can get wake/sleep events
+ * through WallpaperService.Engine.onCommand events that should be more accurate.
+ */
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
+ wakeStateReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val positionExtras = Bundle()
+ when (intent.action) {
+ Intent.ACTION_SCREEN_ON -> {
+ positionExtras.putInt(
+ LiveWallpaperEventListener.WAKE_ACTION_LOCATION_X,
+ -1
+ )
+ positionExtras.putInt(
+ LiveWallpaperEventListener.WAKE_ACTION_LOCATION_Y,
+ -1
+ )
+ wakeStateChangeListeners.forEach {
+ it.get()?.onWake(positionExtras)
+ }
+ }
+
+ Intent.ACTION_SCREEN_OFF -> {
+ positionExtras.putInt(
+ LiveWallpaperEventListener.SLEEP_ACTION_LOCATION_X,
+ -1
+ )
+ positionExtras.putInt(
+ LiveWallpaperEventListener.SLEEP_ACTION_LOCATION_Y,
+ -1
+ )
+ wakeStateChangeListeners.forEach {
+ it.get()?.onSleep(positionExtras)
+ }
+ }
+ }
+ }
+ }
+ registerReceiver(wakeStateReceiver, wakeStateChangeIntentFilter)
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) unregisterReceiver(wakeStateReceiver)
+ }
+
+ /**
+ * Must be implemented to return a new instance of [TorusEngine].
+ * If you want it to subscribe to wallpaper interactions (offset, preview, zoom...) the engine
+ * should also implement [LiveWallpaperEventListener]. If you want it to subscribe to touch
+ * events, it should implement [TorusTouchListener].
+ *
+ * Note: You might have multiple Engines running at the same time (when the wallpaper is set as
+ * the active wallpaper and the user is in the wallpaper picker viewing a preview of it
+ * as well). You can track the lifecycle when *any* Engine is active using the
+ * is{First/Last}ActiveInstance parameters of the create/destroy methods.
+ *
+ */
+ abstract fun getWallpaperEngine(context: Context, surfaceHolder: SurfaceHolder): TorusEngine
+
+ /**
+ * returns a new instance of [LiveWallpaperEngineWrapper].
+ * Caution: This function should not be override when extending [LiveWallpaper] class.
+ */
+ override fun onCreateEngine(): Engine {
+ val wrapper = LiveWallpaperEngineWrapper()
+ wakeStateChangeListeners.add(WeakReference(wrapper))
+ return wrapper
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+
+ for (reference in configChangeListeners) {
+ reference.get()?.onConfigurationChanged(newConfig)
+ }
+ }
+
+ private fun addConfigChangeListener(configChangeListener: ConfigurationChangeListener) {
+ var containsListener = false
+
+ for (reference in configChangeListeners) {
+ if (configChangeListener == reference.get()) {
+ containsListener = true
+ break
+ }
+ }
+
+ if (!containsListener) {
+ configChangeListeners.add(WeakReference(configChangeListener))
+ }
+ }
+
+ private fun removeConfigChangeListener(configChangeListener: ConfigurationChangeListener) {
+ for (reference in configChangeListeners) {
+ if (configChangeListener == reference.get()) {
+ configChangeListeners.remove(reference)
+ break
+ }
+ }
+ }
+
+ /**
+ * Class that enables to connect a [TorusEngine] with some [WallpaperService.Engine] functions.
+ * The class that you use to render in a [LiveWallpaper] needs to inherit from
+ * [LiveWallpaperConnector] and implement [TorusEngine].
+ */
+ open class LiveWallpaperConnector {
+ private var wallpaperServiceEngine: WallpaperService.Engine? = null
+
+ /**
+ * Returns the information if the wallpaper is in preview mode. This value doesn't change
+ * during a [TorusEngine] lifecycle, so you can know if the wallpaper is set checking that
+ * on create isPreview == false.
+ */
+ fun isPreview(): Boolean {
+ this.wallpaperServiceEngine?.let {
+ return it.isPreview
+ }
+ return false
+ }
+
+ /**
+ * Triggers the [WallpaperService] to recompute the Wallpaper Colors.
+ */
+ fun notifyWallpaperColorsChanged() {
+ this.wallpaperServiceEngine?.notifyColorsChanged()
+ }
+
+ /** Returns the current Engine [SurfaceHolder]. */
+ fun getEngineSurfaceHolder(): SurfaceHolder? = this.wallpaperServiceEngine?.surfaceHolder
+
+ /** Returns the wallpaper flags indicating which screen this Engine is rendering to. */
+ fun getWallpaperFlags(): Int {
+ if (Build.VERSION.SDK_INT >= 34) {
+ this.wallpaperServiceEngine?.let {
+ return it.wallpaperFlags
+ }
+ }
+ return WALLPAPER_FLAG_NOT_FOUND
+ }
+
+ internal fun setServiceEngineReference(wallpaperServiceEngine: WallpaperService.Engine) {
+ this.wallpaperServiceEngine = wallpaperServiceEngine
+ }
+ }
+
+ /**
+ * Implementation of [WallpaperService.Engine] that works as a wrapper. If we used a
+ * [WallpaperService.Engine] instance as the framework engine, we would find the problem
+ * that the engine will be created for preview, then destroyed and recreated again when the
+ * wallpaper is set. This behavior may cause to load assets multiple time for every time the
+ * Rendering engine is created. Also, wrapping our [TorusEngine] inside
+ * [WallpaperService.Engine] allow us to reuse [TorusEngine] in other places, like Activities.
+ */
+ private inner class LiveWallpaperEngineWrapper : WallpaperService.Engine() {
+ private lateinit var wallpaperEngine: TorusEngine
+
+ override fun onCreate(surfaceHolder: SurfaceHolder) {
+ super.onCreate(surfaceHolder)
+ // Use RGBA_8888 format.
+ surfaceHolder.setFormat(PixelFormat.RGBA_8888)
+
+ /*
+ * For Android 10 (SDK 29).
+ * This is needed for Foldables and multiple display devices.
+ */
+ val context = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ displayContext ?: this@LiveWallpaper
+ } else {
+ this@LiveWallpaper
+ }
+
+ wallpaperEngine = getWallpaperEngine(context, surfaceHolder)
+ numEngines++
+
+ /*
+ * It is important to call setTouchEventsEnabled in onCreate for it to work. Calling it
+ * in onSurfaceCreated instead will cause the engine to be stuck in an instantiation
+ * loop.
+ */
+ if (wallpaperEngine is TorusTouchListener) setTouchEventsEnabled(true)
+ }
+
+ override fun onSurfaceCreated(holder: SurfaceHolder) {
+ super.onSurfaceCreated(holder)
+
+ if (wallpaperEngine is ConfigurationChangeListener) {
+ addConfigChangeListener(wallpaperEngine as ConfigurationChangeListener)
+ }
+
+ if (wallpaperEngine is LiveWallpaperConnector) {
+ (wallpaperEngine as LiveWallpaperConnector).setServiceEngineReference(this)
+ }
+
+ wallpaperEngine.create(numEngines == 1)
+ }
+
+ override fun onSurfaceDestroyed(holder: SurfaceHolder?) {
+ super.onSurfaceDestroyed(holder)
+ numEngines--
+
+ if (wallpaperEngine is ConfigurationChangeListener) {
+ removeConfigChangeListener(wallpaperEngine as ConfigurationChangeListener)
+ }
+
+ var isLastInstance = false
+ if (numEngines <= 0) {
+ numEngines = 0
+ isLastInstance = true
+ }
+
+ if (isVisible) wallpaperEngine.pause()
+ wallpaperEngine.destroy(isLastInstance)
+ }
+
+ override fun onSurfaceChanged(
+ holder: SurfaceHolder?,
+ format: Int,
+ width: Int,
+ height: Int
+ ) {
+ super.onSurfaceChanged(holder, format, width, height)
+ wallpaperEngine.resize(width, height)
+ }
+
+ override fun onOffsetsChanged(
+ xOffset: Float,
+ yOffset: Float,
+ xOffsetStep: Float,
+ yOffsetStep: Float,
+ xPixelOffset: Int,
+ yPixelOffset: Int
+ ) {
+ super.onOffsetsChanged(
+ xOffset,
+ yOffset,
+ xOffsetStep,
+ yOffsetStep,
+ xPixelOffset,
+ yPixelOffset
+ )
+
+ if (wallpaperEngine is LiveWallpaperEventListener) {
+ (wallpaperEngine as LiveWallpaperEventListener).onOffsetChanged(
+ xOffset,
+ if (xOffsetStep.compareTo(0f) == 0) {
+ 1.0f
+ } else {
+ xOffsetStep
+ }
+ )
+ }
+ }
+
+ override fun onZoomChanged(zoom: Float) {
+ super.onZoomChanged(zoom)
+ if (wallpaperEngine is LiveWallpaperEventListener) {
+ (wallpaperEngine as LiveWallpaperEventListener).onZoomChanged(zoom)
+ }
+ }
+
+ override fun onVisibilityChanged(visible: Boolean) {
+ super.onVisibilityChanged(visible)
+ if (visible) {
+ wallpaperEngine.resume()
+ } else {
+ wallpaperEngine.pause()
+ }
+ }
+
+ override fun onComputeColors(): WallpaperColors? {
+ if (wallpaperEngine is LiveWallpaperEventListener) {
+ val colors =
+ (wallpaperEngine as LiveWallpaperEventListener).computeWallpaperColors()
+
+ if (colors != null) {
+ return colors
+ }
+ }
+
+ return super.onComputeColors()
+ }
+
+ override fun onCommand(
+ action: String?,
+ x: Int,
+ y: Int,
+ z: Int,
+ extras: Bundle?,
+ resultRequested: Boolean
+ ): Bundle? {
+ when (action) {
+ COMMAND_REAPPLY -> onWallpaperReapplied()
+ COMMAND_WAKING_UP -> {
+ val positionExtras = extras ?: Bundle()
+ positionExtras.putInt(LiveWallpaperEventListener.WAKE_ACTION_LOCATION_X, x)
+ positionExtras.putInt(LiveWallpaperEventListener.WAKE_ACTION_LOCATION_Y, y)
+ onWake(positionExtras)
+ }
+ COMMAND_GOING_TO_SLEEP -> {
+ val positionExtras = extras ?: Bundle()
+ positionExtras.putInt(LiveWallpaperEventListener.SLEEP_ACTION_LOCATION_X, x)
+ positionExtras.putInt(LiveWallpaperEventListener.SLEEP_ACTION_LOCATION_Y, y)
+ onSleep(positionExtras)
+ }
+ COMMAND_KEYGUARD_GOING_AWAY -> onKeyguardGoingAway()
+ COMMAND_PREVIEW_INFO -> onPreviewInfoReceived(extras)
+ }
+
+ if (resultRequested) return extras
+
+ return super.onCommand(action, x, y, z, extras, resultRequested)
+ }
+
+ override fun onTouchEvent(event: MotionEvent) {
+ super.onTouchEvent(event)
+
+ if (wallpaperEngine is TorusTouchListener) {
+ (wallpaperEngine as TorusTouchListener).onTouchEvent(event)
+ }
+ }
+
+ /**
+ * This is overriding a hidden API [WallpaperService.shouldZoomOutWallpaper].
+ */
+ fun shouldZoomOutWallpaper(): Boolean {
+ if (wallpaperEngine is LiveWallpaperEventListener) {
+ return (wallpaperEngine as LiveWallpaperEventListener).shouldZoomOutWallpaper()
+ }
+ return false
+ }
+
+ fun onWake(extras: Bundle) {
+ if (wallpaperEngine is LiveWallpaperEventListener) {
+ (wallpaperEngine as LiveWallpaperEventListener).onWake(extras)
+ }
+ }
+
+ fun onSleep(extras: Bundle) {
+ if (wallpaperEngine is LiveWallpaperEventListener) {
+ (wallpaperEngine as LiveWallpaperEventListener).onSleep(extras)
+ }
+ }
+
+ fun onWallpaperReapplied() {
+ if (wallpaperEngine is LiveWallpaperEventListener) {
+ (wallpaperEngine as LiveWallpaperEventListener).onWallpaperReapplied()
+ }
+ }
+
+ fun onKeyguardGoingAway() {
+ if (wallpaperEngine is LiveWallpaperKeyguardEventListener) {
+ (wallpaperEngine as LiveWallpaperKeyguardEventListener).onKeyguardGoingAway()
+ }
+ }
+
+ fun onPreviewInfoReceived(extras: Bundle?) {
+ if (wallpaperEngine is LiveWallpaperEventListener) {
+ (wallpaperEngine as LiveWallpaperEventListener).onPreviewInfoReceived(extras)
+ }
+ }
+ }
+}
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/listener/LiveWallpaperEventListener.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/listener/LiveWallpaperEventListener.kt
new file mode 100644
index 0000000..6803bd0
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/listener/LiveWallpaperEventListener.kt
@@ -0,0 +1,110 @@
+/*
+ * 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.google.android.torus.core.wallpaper.listener
+
+import android.app.WallpaperColors
+import android.os.Bundle
+
+/**
+ * Interface that is used to implement specific wallpaper callbacks like offset change (user swipes
+ * between home pages), when the preview state has changed or when the zoom state has changed.
+ */
+interface LiveWallpaperEventListener {
+ companion object {
+ const val WAKE_ACTION_LOCATION_X: String = "WAKE_ACTION_LOCATION_X"
+ const val WAKE_ACTION_LOCATION_Y: String = "WAKE_ACTION_LOCATION_Y"
+ const val SLEEP_ACTION_LOCATION_X: String = "SLEEP_ACTION_LOCATION_X"
+ const val SLEEP_ACTION_LOCATION_Y: String = "SLEEP_ACTION_LOCATION_Y"
+ }
+
+ /**
+ * Called when the wallpaper has been scrolled (usually when the user scroll between pages in
+ * the home of the launcher). This only tracts the horizontal scroll.
+ *
+ * @param xOffset The current offset of the scroll. The value is normalize between [0,1].
+ * @param xOffsetStep How is stepped the scroll. If you invert [xOffsetStep] you get the
+ * number of pages in the scrolling area.
+ */
+ fun onOffsetChanged(xOffset: Float, xOffsetStep: Float)
+
+ /**
+ * Called when the zoom level of the wallpaper is changing.
+ *
+ * @param zoomLevel A value between 0 and 1 that tells how much the wallpaper should be zoomed
+ * out: if 0, the wallpaper should be in normal state; if 1 the wallpaper should be zoomed out.
+ */
+ fun onZoomChanged(zoomLevel: Float)
+
+ /**
+ * Call when the wallpaper was set, and then is reapplied. This means that the wallpaper was
+ * set and is being set again. This is useful to know if the wallpaper settings have to be
+ * reapplied again (i.e. if the user enters the wallpaper picker and picks the same wallpaper,
+ * changes the settings and sets the wallpaper again).
+ */
+ fun onWallpaperReapplied()
+
+ /**
+ * Called when the Wallpaper colors need to be computed you can create a [WallpaperColors]
+ * instance using the [WallpaperColors.fromBitmap] function and passing a bitmap that
+ * represents the wallpaper (i.e. the gallery thumbnail) or use the [WallpaperColors]
+ * constructor and pass the primary, secondary and tertiary colors. This method is specially
+ * important since the UI will change their colors based on what is returned here.
+ *
+ * @return The colors that represent the wallpaper; null if you want the System to take
+ * care of the colors.
+ */
+ fun computeWallpaperColors(): WallpaperColors?
+
+ /**
+ * Called when the wallpaper receives the preview information (asynchronous call).
+ *
+ * @param extras the bundle of the preview information. The key "which_preview" can be used to
+ * retrieve a string value (ex. main_preview_home) that specifies which preview the engine
+ * is referring to.
+ */
+ fun onPreviewInfoReceived(extras: Bundle?) {}
+
+ /**
+ * Called when the device is activated from a sleep/AOD state.
+ *
+ * @param extras contains the location of the action that caused the wake event:
+ * - [LiveWallpaperEventListener.WAKE_ACTION_LOCATION_X]: the X screen location (in Pixels). if
+ * the value is not included or is -1, the X screen location is unknown.
+ * - [LiveWallpaperEventListener.WAKE_ACTION_LOCATION_Y]: the Y screen location (in Pixels). if
+ * the value is not included or is -1, the Y screen location is unknown.
+ */
+ fun onWake(extras: Bundle)
+
+ /**
+ * Called when the device enters a sleep/AOD state.
+ *
+ * @param extras contains the location of the action that caused the sleep event:
+ * - [LiveWallpaperEventListener.SLEEP_ACTION_LOCATION_X]: the X screen location (in Pixels). if
+ * the value is not included or is -1, the X screen location is unknown.
+ * - [LiveWallpaperEventListener.SLEEP_ACTION_LOCATION_Y]: the Y screen location (in Pixels). if
+ * the value is not included or is -1, the Y screen location is unknown.
+ */
+ fun onSleep(extras: Bundle)
+
+ /**
+ * Indicates whether the zoom animation should be handled in WindowManager. Preferred to be set
+ * to true to avoid pressuring GPU.
+ *
+ * See [WallpaperService.shouldZoomOutWallpaper].
+ */
+ fun shouldZoomOutWallpaper() = false
+}
diff --git a/toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/listener/LiveWallpaperKeyguardEventListener.kt b/toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/listener/LiveWallpaperKeyguardEventListener.kt
new file mode 100644
index 0000000..70b15e5
--- /dev/null
+++ b/toruslib/torus-core/src/main/java/com/google/android/torus/core/wallpaper/listener/LiveWallpaperKeyguardEventListener.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.google.android.torus.core.wallpaper.listener
+
+/** Interface that is used to implement specific wallpaper callbacks related to keyguard events. */
+interface LiveWallpaperKeyguardEventListener {
+
+ /** Called when the keyguard is going away. */
+ fun onKeyguardGoingAway()
+}
diff --git a/toruslib/torus-framework-canvas/build.gradle b/toruslib/torus-framework-canvas/build.gradle
new file mode 100644
index 0000000..8d59718
--- /dev/null
+++ b/toruslib/torus-framework-canvas/build.gradle
@@ -0,0 +1,18 @@
+// 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.
+
+dependencies {
+ implementation project(':torus-core')
+ implementation project(':torus-utils')
+}
diff --git a/toruslib/torus-framework-canvas/src/main/AndroidManifest.xml b/toruslib/torus-framework-canvas/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3c68a96
--- /dev/null
+++ b/toruslib/torus-framework-canvas/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+<?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 package="com.google.android.torus" />
diff --git a/toruslib/torus-framework-canvas/src/main/java/com/google/android/torus/canvas/engine/CanvasWallpaperEngine.kt b/toruslib/torus-framework-canvas/src/main/java/com/google/android/torus/canvas/engine/CanvasWallpaperEngine.kt
new file mode 100644
index 0000000..814dff6
--- /dev/null
+++ b/toruslib/torus-framework-canvas/src/main/java/com/google/android/torus/canvas/engine/CanvasWallpaperEngine.kt
@@ -0,0 +1,329 @@
+/*
+ * 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.google.android.torus.canvas.engine
+
+import android.graphics.Canvas
+import android.graphics.RuntimeShader
+import android.os.SystemClock
+import android.util.Log
+import android.util.Size
+import android.view.Choreographer
+import android.view.SurfaceHolder
+import androidx.annotation.VisibleForTesting
+import com.google.android.torus.core.engine.TorusEngine
+import com.google.android.torus.core.power.FpsThrottler
+import com.google.android.torus.core.time.TimeController
+import com.google.android.torus.core.wallpaper.LiveWallpaper
+import java.io.PrintWriter
+
+/**
+ * Class that implements [TorusEngine] using Canvas and can be used in a [LiveWallpaper]. This
+ * class also inherits from [LiveWallpaper.LiveWallpaperConnector] which allows to do some calls
+ * related to Live Wallpapers, like the method [isPreview] or [notifyWallpaperColorsChanged].
+ *
+ * By default it won't start [startUpdateLoop]. To run animations and update logic per frame, call
+ * [startUpdateLoop] and [stopUpdateLoop] when it's no longer needed.
+ *
+ * This class also can be used with the new RuntimeShader.
+ */
+abstract class CanvasWallpaperEngine(
+ /** The default [SurfaceHolder] to be used. */
+ private val defaultHolder: SurfaceHolder,
+
+ /**
+ * Defines if the surface should be hardware accelerated or not. If you are using
+ * [RuntimeShader], this value should be set to true. When setting it to true, some
+ * functions might not be supported. Please refer to the documentation:
+ * https://developer.android.com/guide/topics/graphics/hardware-accel#unsupported
+ */
+ private val hardwareAccelerated: Boolean = false,
+) : LiveWallpaper.LiveWallpaperConnector(), TorusEngine {
+
+ private val choreographer = Choreographer.getInstance()
+ private val timeController = TimeController().also {
+ it.resetDeltaTime(SystemClock.uptimeMillis())
+ }
+ private val frameScheduler = FrameCallback()
+ private val fpsThrottler = FpsThrottler()
+
+ protected var screenSize = Size(0, 0)
+ private set
+ private var resizeCalled: Boolean = false
+
+ private var isWallpaperEngineVisible = false
+ /**
+ * Indicates whether the engine#onCreate is called.
+ *
+ * TODO(b/277672928): These two booleans were introduced as a workaround where
+ * [onSurfaceRedrawNeeded] called after an [onSurfaceDestroyed], without [onCreate]/
+ * [onSurfaceCreated] being called between those. Remove these once it's fixed in
+ * [WallpaperService].
+ */
+ private var isCreated = false
+ private var shouldInvokeResume = false
+
+ /** Callback to handle when the [TorusEngine] has been created. */
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ open fun onCreate(isFirstActiveInstance: Boolean) {
+ // No-op. Ready for being overridden by children.
+ }
+
+ /** Callback to handle when the [TorusEngine] has been resumed. */
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ open fun onResume() {
+ // No-op. Ready for being overridden by children.
+ }
+
+ /** Callback to handle when the [TorusEngine] has been paused. */
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ open fun onPause() {
+ // No-op. Ready for being overridden by children.
+ }
+
+ /**
+ * Callback to handle when the surface holding the [TorusEngine] has changed its size.
+ *
+ * @param size The new size of the surface holding the [TorusEngine].
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ open fun onResize(size: Size) {
+ // No-op. Ready for being overridden by children.
+ }
+
+ /**
+ * Callback to handle when the [TorusEngine] needs to be updated. Call [startUpdateLoop] to
+ * initiate the frame loop; call [stopUpdateLoop] to end the loop. The client is supposed to
+ * update logic and render in this loop.
+ *
+ * @param deltaMillis The time in millis since the last time [onUpdate] was called.
+ * @param frameTimeNanos The time in nanoseconds when the frame started being rendered,
+ * in the [System.nanoTime] timebase.
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ open fun onUpdate(deltaMillis: Long, frameTimeNanos: Long) {
+ // No-op. Ready for being overridden by children.
+ }
+
+ /**
+ * Callback to handle when we need to destroy the surface.
+ *
+ * @param isLastActiveInstance Whether this was the last wallpaper engine instance (until the
+ * next [onCreate]).
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ open fun onDestroy(isLastActiveInstance: Boolean) {
+ // No-op. Ready for being overridden by children.
+ }
+
+ final override fun create(isFirstActiveInstance: Boolean) {
+ screenSize = Size(
+ getCurrentSurfaceHolder().surfaceFrame.width(),
+ getCurrentSurfaceHolder().surfaceFrame.height()
+ )
+
+ onCreate(isFirstActiveInstance)
+
+ isCreated = true
+
+ if (shouldInvokeResume) {
+ Log.e(
+ TAG, "Force invoke resume. onVisibilityChanged must have been called" +
+ "before onCreate.")
+ resume()
+ shouldInvokeResume = false
+ }
+ }
+
+ final override fun pause() {
+ if (!isCreated) {
+ Log.e(
+ TAG, "Engine is not yet created but pause is called. Set a flag to invoke" +
+ " resume on next create.")
+ shouldInvokeResume = true
+ return
+ }
+
+ if (isWallpaperEngineVisible) {
+ onPause()
+ isWallpaperEngineVisible = false
+ }
+ }
+
+ final override fun resume() {
+ if (!isCreated) {
+ Log.e(
+ TAG, "Engine is not yet created but resume is called. Set a flag to " +
+ "invoke resume on next create.")
+ shouldInvokeResume = true
+ return
+ }
+
+ if (!isWallpaperEngineVisible) {
+ onResume()
+ isWallpaperEngineVisible = true
+ }
+ }
+
+ final override fun resize(width: Int, height: Int) {
+ resizeCalled = true
+
+ screenSize = Size(width, height)
+ onResize(screenSize)
+ }
+
+ final override fun destroy(isLastActiveInstance: Boolean) {
+ choreographer.removeFrameCallback(frameScheduler)
+ timeController.resetDeltaTime(SystemClock.uptimeMillis())
+
+ // Always detach the surface before destroying the engine
+ onDestroy(isLastActiveInstance)
+ }
+
+ /**
+ * Renders to canvas. Use this in [onUpdate] loop. This will automatically throttle (or limit)
+ * FPS that was set via [setFpsLimit].
+ *
+ * @param frameTimeNanos The time in nanoseconds when the frame started being rendered, in the
+ * [System.nanoTime] timebase.
+ * @param onRender The callback triggered when the canvas is ready for render.
+ *
+ * @return Whether it is rendered.
+ */
+ fun renderWithFpsLimit(frameTimeNanos: Long, onRender: (canvas: Canvas) -> Unit): Boolean {
+ if (resizeCalled) {
+ /**
+ * Skip rendering a frame to a buffer with potentially-outdated dimensions, and request
+ * redraw in the next frame.
+ */
+ resizeCalled = false
+
+ fpsThrottler.requestRendering()
+ return renderWithFpsLimit(frameTimeNanos, onRender)
+ }
+
+ return fpsThrottler.tryRender(frameTimeNanos) {
+ renderToCanvas(onRender)
+ }
+ }
+
+ /**
+ * Renders to canvas.
+ *
+ * @param onRender The callback triggered when the canvas is ready for render.
+ *
+ * @return Whether it is rendered.
+ */
+ fun render(onRender: (canvas: Canvas) -> Unit): Boolean {
+ if (resizeCalled) {
+ /**
+ * Skip rendering a frame to a buffer with potentially-outdated dimensions, and request
+ * redraw in the next frame.
+ */
+ resizeCalled = false
+ return render(onRender)
+ }
+
+ return renderToCanvas(onRender)
+ }
+
+ /**
+ * Sets the FPS limit. See [FpsThrottler] for the FPS constants. The max FPS will be the screen
+ * refresh (VSYNC) rate.
+ *
+ * @param fps Desired mas FPS.
+ */
+ protected fun setFpsLimit(fps: Float) {
+ fpsThrottler.updateFps(fps)
+ }
+
+ /**
+ * Starts the update loop.
+ */
+ protected fun startUpdateLoop() {
+ if (!frameScheduler.running) {
+ frameScheduler.running = true
+ choreographer.postFrameCallback(frameScheduler)
+ }
+ }
+
+ /**
+ * Stops the update loop.
+ */
+ protected fun stopUpdateLoop() {
+ if (frameScheduler.running) {
+ frameScheduler.running = false
+ choreographer.removeFrameCallback(frameScheduler)
+ }
+ }
+
+ private fun renderToCanvas(onRender: (canvas: Canvas) -> Unit): Boolean {
+ val surfaceHolder = getCurrentSurfaceHolder()
+ if (!surfaceHolder.surface.isValid) return false
+ var canvas: Canvas? = null
+
+ try {
+ canvas = if (hardwareAccelerated) {
+ surfaceHolder.lockHardwareCanvas()
+ } else {
+ surfaceHolder.lockCanvas()
+ } ?: return false
+
+ onRender(canvas)
+
+ } catch (e: java.lang.Exception) {
+ Log.e("canvas_exception", "canvas exception", e)
+ } finally {
+ if (canvas != null) {
+ surfaceHolder.unlockCanvasAndPost(canvas)
+ }
+ }
+ return true
+ }
+
+ private fun getCurrentSurfaceHolder(): SurfaceHolder =
+ getEngineSurfaceHolder() ?: defaultHolder
+
+ /**
+ * Implementation of [Choreographer.FrameCallback] which triggers [onUpdate].
+ */
+ inner class FrameCallback : Choreographer.FrameCallback {
+ internal var running: Boolean = false
+
+ override fun doFrame(frameTimeNanos: Long) {
+ if (running) choreographer.postFrameCallback(this)
+ // onUpdate should be called for every V_SYNC.
+ val frameTimeMillis = frameTimeNanos / 1000_000
+ timeController.updateDeltaTime(frameTimeMillis)
+ onUpdate(timeController.deltaTimeMillis, frameTimeNanos)
+ timeController.resetDeltaTime(frameTimeMillis)
+ }
+ }
+
+ /**
+ * Override this for dumpsys.
+ *
+ * You still need to have your WallpaperService overriding [dump] and call
+ * [CanvasWallpaperEngine.dump].
+ *
+ * Usage: adb shell dumpsys activity service ${your_wallpaper_service_name}.
+ */
+ open fun dump(out: PrintWriter) = Unit
+
+ private companion object {
+ private val TAG: String = CanvasWallpaperEngine::class.java.simpleName
+ }
+} \ No newline at end of file
diff --git a/toruslib/torus-math/build.gradle b/toruslib/torus-math/build.gradle
new file mode 100644
index 0000000..7dcad1d
--- /dev/null
+++ b/toruslib/torus-math/build.gradle
@@ -0,0 +1,13 @@
+// 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.
diff --git a/toruslib/torus-math/src/main/AndroidManifest.xml b/toruslib/torus-math/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3c68a96
--- /dev/null
+++ b/toruslib/torus-math/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+<?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 package="com.google.android.torus" />
diff --git a/toruslib/torus-math/src/main/java/com/google/android/torus/math/AffineTransform.kt b/toruslib/torus-math/src/main/java/com/google/android/torus/math/AffineTransform.kt
new file mode 100644
index 0000000..627a6ab
--- /dev/null
+++ b/toruslib/torus-math/src/main/java/com/google/android/torus/math/AffineTransform.kt
@@ -0,0 +1,177 @@
+/*
+ * 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.google.android.torus.math
+
+import android.opengl.Matrix
+import java.util.*
+
+/** An immutable 3D transformation in homogeneous coordinates. */
+open class AffineTransform @JvmOverloads constructor(
+ /** The position of the transform. */
+ val position: Vector3 = Vector3(0f, 0f, 0f),
+ /** The rotation of the transform. */
+ val rotation: RotationQuaternion = RotationQuaternion(),
+ /** The scale of the transform. */
+ val scale: Vector3 = Vector3(1f, 1f, 1f)
+) : MatrixTransform {
+ constructor(transform: AffineTransform) : this(
+ transform.position,
+ transform.rotation,
+ transform.scale
+ )
+
+ /**
+ * Creates a new [AffineTransform] with ([x], [y], [z]) as the new translation.
+ *
+ * @param x The X component of the translation.
+ * @param y The Y component of the translation.
+ * @param z The Z component of the translation.
+ *
+ * @return The new [AffineTransform] with the new translation.
+ */
+ fun withTranslation(x: Float, y: Float, z: Float): AffineTransform {
+ return AffineTransform(Vector3(x, y, z), rotation, scale)
+ }
+
+ /**
+ * Creates a new [AffineTransform] with ([x], [y], [z]) as the new scale.
+ *
+ * @param x The scale in the X direction.
+ * @param y The scale in the Y direction.
+ * @param z The scale in the Z direction.
+ *
+ * @return The new [AffineTransform] with the new scale.
+ */
+ fun withScale(x: Float, y: Float, z: Float): AffineTransform {
+ return AffineTransform(position, rotation, Vector3(x, y, z))
+ }
+
+ /**
+ * Creates a new [AffineTransform] with ([scale], [scale], [scale]) as the new scale.
+ *
+ * @param scale The scale applied in the X,Y and Z directions.
+ *
+ * @return The new [AffineTransform] with the new scale.
+ */
+ fun withScale(scale: Float): AffineTransform {
+ return withScale(scale, scale, scale)
+ }
+
+ /**
+ * Creates a new [AffineTransform] with [rotation] as the new rotation.
+ *
+ * @param rotation The new rotation.
+ *
+ * @return The new [AffineTransform] with the new rotation.
+ */
+ fun withRotation(rotation: RotationQuaternion): AffineTransform {
+ return AffineTransform(position, RotationQuaternion(rotation), scale)
+ }
+
+ /**
+ * Returns a new transform with a new rotation using Euler rotation angles (ZYX sequence).
+ *
+ * @param x The Euler rotation angle around X axis, in degrees.
+ * @param y The Euler rotation angle around Y axis, in degrees.
+ * @param z The Euler rotation angle around Z axis, in degrees.
+ */
+ fun withEulerRotation(x: Float, y: Float, z: Float): AffineTransform {
+ return AffineTransform(position, RotationQuaternion.fromEuler(x, y, z), scale)
+ }
+
+ fun translateBy(x: Float, y: Float, z: Float): AffineTransform {
+ return AffineTransform(position + Vector3(x, y, z), rotation, scale)
+ }
+
+ fun scaleBy(scale: Float): AffineTransform {
+ return AffineTransform(position, rotation, this.scale + Vector3(scale))
+ }
+
+ fun scaleBy(x: Float, y: Float, z: Float): AffineTransform {
+ return AffineTransform(position, rotation, this.scale + Vector3(x, y, z))
+ }
+
+ fun rotateBy(quaternion: RotationQuaternion): AffineTransform {
+ return AffineTransform(position, quaternion * rotation, scale)
+ }
+
+ /**
+ * Rotates the current rotation using some Euler rotation angles (ZYX sequence).
+ *
+ * @param x The Euler rotation angle around X axis, in degrees.
+ * @param y The Euler rotation angle around Y axis, in degrees.
+ * @param z The Euler rotation angle around Z axis, in degrees.
+ */
+ fun rotateByEuler(x: Float, y: Float, z: Float): AffineTransform {
+ return rotateBy(RotationQuaternion.fromEuler(x, y, z))
+ }
+
+ /**
+ * Returns a 4x4 transform Matrix. The format of the matrix follows the OpenGL ES matrix format
+ * stored in float arrays.
+ * Matrices are 4 x 4 column-vector matrices stored in column-major order:
+ *
+ * <pre>
+ * m[0] m[4] m[8] m[12]
+ * m[1] m[5] m[9] m[13]
+ * m[2] m[6] m[10] m[14]
+ * m[3] m[7] m[11] m[15]
+ * </pre>
+ *
+ * @return a 16 value [FloatArray] representing the transform as a 4x4 matrix.
+ */
+ override fun toMatrix(): FloatArray {
+ val transformMatrix = FloatArray(16)
+ Matrix.setIdentityM(transformMatrix, 0)
+ // The order of operations matter; we should follow the usual: Scale, Rotate and Translate.
+ Matrix.scaleM(transformMatrix, 0, scale.x, scale.y, scale.z)
+ Matrix.rotateM(
+ transformMatrix,
+ 0,
+ rotation.angle.toFloat(),
+ rotation.direction.x,
+ rotation.direction.y,
+ rotation.direction.z
+ )
+ Matrix.translateM(transformMatrix, 0, position.x, position.y, position.z)
+ return transformMatrix
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as AffineTransform
+
+ if (position != other.position) return false
+ if (rotation != other.rotation) return false
+ if (scale != other.scale) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = position.hashCode()
+ result = 31 * result + rotation.hashCode()
+ result = 31 * result + scale.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "Position: ${position}\nRotation: ${rotation}\nScale: $scale\n"
+ }
+} \ No newline at end of file
diff --git a/toruslib/torus-math/src/main/java/com/google/android/torus/math/MathUtils.kt b/toruslib/torus-math/src/main/java/com/google/android/torus/math/MathUtils.kt
new file mode 100644
index 0000000..85f4fda
--- /dev/null
+++ b/toruslib/torus-math/src/main/java/com/google/android/torus/math/MathUtils.kt
@@ -0,0 +1,181 @@
+/*
+ * 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.google.android.torus.math
+
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Numeric operations and constants not included in the main Math class.
+ */
+object MathUtils {
+ const val DEG_TO_RAD = Math.PI / 180.0
+ const val RAD_TO_DEG = 1 / DEG_TO_RAD
+ const val TAU = Math.PI * 2.0
+
+ /**
+ * Maps a value from a range to a different one (with the option to clamp it to the new range).
+ *
+ * @param value The value to map from a value range to a different one.
+ * @param inMin The minimum value of the original range.
+ * @param inMax The maximum value of the original range.
+ * @param outMin The minimum value of the new range.
+ * @param outMax The maximum value of the new range.
+ * @param clamp If you want to clamp the mapped value to the new range, set to true; otherwise
+ * set to false (by default is set to true).
+ *
+ * @return The [value] mapped to the new range.
+ */
+ @JvmStatic
+ @JvmOverloads
+ fun map(
+ value: Double,
+ inMin: Double,
+ inMax: Double,
+ outMin: Double,
+ outMax: Double,
+ clamp: Boolean = true
+ ): Double {
+ if (clamp) {
+ if (value < inMin) {
+ return outMin
+ }
+ if (value > inMax) {
+ return outMax
+ }
+ }
+ return (value - inMin) / (inMax - inMin) * (outMax - outMin) + outMin
+ }
+
+ /**
+ * Maps a value from a range to a different one (with the option to clamp it to the new range).
+ *
+ * @param value The value to map from a value range to a different one.
+ * @param inMin The minimum value of the original range.
+ * @param inMax The maximum value of the original range.
+ * @param outMin The minimum value of the new range.
+ * @param outMax The maximum value of the new range.
+ * @param clamp If you want to clamp the mapped value to the new range, set to true; otherwise
+ * set to false (by default is set to true).
+ *
+ * @return The [value] mapped to the new range.
+ */
+ @JvmStatic
+ @JvmOverloads
+ fun map(
+ value: Float,
+ inMin: Float,
+ inMax: Float,
+ outMin: Float,
+ outMax: Float,
+ clamp: Boolean = true
+ ): Float {
+ if (clamp) {
+ if (value < inMin) {
+ return outMin
+ }
+ if (value > inMax) {
+ return outMax
+ }
+ }
+ return (value - inMin) / (inMax - inMin) * (outMax - outMin) + outMin
+ }
+
+ /**
+ * Linear interpolation between two values.
+ *
+ * @param start The first value.
+ * @param end The second value.
+ * @param amount Decides the how we mix the interpolated values; when is 0, it returns the init
+ * value. When is 1, it returns the end value. For any value in between it returns the linearly
+ * interpolated value between [init] and [end]. If [amount] is smaller than 0 or bigger than 1
+ * and [clamp] is false, it continues returning values based on the line created using
+ * [init] and [end]; otherwise the value is clamped to [init] and [end].
+ * @param clamp If you want to clamp the mapped value to the new range, set to true; otherwise
+ * set to false (by default is set to true).
+ *
+ * @return The interpolated value.
+ */
+ @JvmStatic
+ @JvmOverloads
+ fun lerp(start: Double, end: Double, amount: Double, clamp: Boolean = true): Double {
+ val amountClamped = if (clamp) {
+ clamp(amount, 0.0, 1.0)
+ } else {
+ amount
+ }
+ return (end - start) * amountClamped + start
+ }
+
+ /**
+ * Linear interpolation between two values.
+ *
+ * @param init The first value.
+ * @param end The second value.
+ * @param amount Decides the how we mix the interpolated values; when is 0, it returns the init
+ * value. When is 1, it returns the end value. For any value in between it returns the linearly
+ * interpolated value between [init] and [end]. If [amount] is smaller than 0 or bigger than 1
+ * and [clamp] is false, it continues returning values based on the line created using
+ * [init] and [end]; otherwise the value is clamped to [init] and [end].
+ * @param clamp If you want to clamp the mapped value to the new range, set to true; otherwise
+ * set to false (by default is set to true).
+ *
+ * @return The interpolated value.
+ */
+ @JvmStatic
+ @JvmOverloads
+ fun lerp(init: Float, end: Float, amount: Float, clamp: Boolean = true): Float {
+ val amountClamped = if (clamp) {
+ clamp(amount, 0.0f, 1.0f)
+ } else {
+ amount
+ }
+ return (end - init) * amountClamped + init
+ }
+
+ /**
+ * Secures that a value is not smaller or bigger than the given range.
+ *
+ * @param value The input value.
+ * @param min The min value of the range. If [value] is smaller than this value, it is fixed to
+ * [min] value.
+ * @param max The max value of the range. If [value] is bigger than this value, it is fixed to
+ * [min] value.
+ *
+ * @return the value that is secured in the [[min], [max]] range.
+ */
+ @JvmStatic
+ fun clamp(value: Double, min: Double, max: Double): Double {
+ return max(min(value, max), min)
+ }
+
+ /**
+ * Secures that a value is not smaller or bigger than the given range.
+ *
+ * @param value The input value.
+ * @param min The min value of the range. If [value] is smaller than this value, it is fixed to
+ * [min] value.
+ * @param max The max value of the range. If [value] is bigger than this value, it is fixed to
+ * [min] value.
+ *
+ * @return the value that is secured in the [[min], [max]] range.
+ */
+ @JvmStatic
+ fun clamp(value: Float, min: Float, max: Float): Float {
+ return max(min(value, max), min)
+ }
+}
diff --git a/toruslib/torus-math/src/main/java/com/google/android/torus/math/MatrixTransform.kt b/toruslib/torus-math/src/main/java/com/google/android/torus/math/MatrixTransform.kt
new file mode 100644
index 0000000..e44da51
--- /dev/null
+++ b/toruslib/torus-math/src/main/java/com/google/android/torus/math/MatrixTransform.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.google.android.torus.math
+
+/**
+ * Interface to make sure that the Transform classes that implement it return a transform matrix.
+ */
+interface MatrixTransform {
+ /**
+ * Returns a 4x4 transform Matrix. The format of the matrix follows the OpenGL ES matrix format
+ * stored in float arrays.
+ * Matrices are 4 x 4 column-vector matrices stored in column-major order:
+ *
+ * <pre>
+ * m[0] m[4] m[8] m[12]
+ * m[1] m[5] m[9] m[13]
+ * m[2] m[6] m[10] m[14]
+ * m[3] m[7] m[11] m[15]
+ * </pre>
+ *
+ * @return a 16 value [FloatArray] representing the transform as a 4x4 matrix.
+ */
+ fun toMatrix(): FloatArray
+} \ No newline at end of file
diff --git a/toruslib/torus-math/src/main/java/com/google/android/torus/math/RotationQuaternion.kt b/toruslib/torus-math/src/main/java/com/google/android/torus/math/RotationQuaternion.kt
new file mode 100644
index 0000000..3a25da9
--- /dev/null
+++ b/toruslib/torus-math/src/main/java/com/google/android/torus/math/RotationQuaternion.kt
@@ -0,0 +1,198 @@
+/*
+ * 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.google.android.torus.math
+
+import com.google.android.torus.math.MathUtils.DEG_TO_RAD
+import com.google.android.torus.math.MathUtils.RAD_TO_DEG
+import java.util.*
+import kotlin.math.*
+
+/**
+ * A unit quaternion representing a rotation.
+ */
+class RotationQuaternion {
+ companion object {
+ /**
+ * Creates a rotation quaternion from a quaternion.
+ *
+ * @param w The w value of a quaternion.
+ * @param x The x value of a quaternion.
+ * @param y The y value of a quaternion.
+ * @param z The z value of a quaternion.
+ */
+ @JvmStatic
+ fun fromQuaternion(w: Double, x: Double, y: Double, z: Double): RotationQuaternion {
+ val rotation = 2.0 * atan2(sqrt(x * x + y * y + z * z), w) * RAD_TO_DEG
+ val direction = Vector3(x.toFloat(), y.toFloat(), z.toFloat()).toNormalized()
+ return RotationQuaternion(rotation, direction)
+ }
+
+ /**
+ * Creates a rotation quaternion from some Euler angles (ZYX sequence).
+ *
+ * @param eulerAngles The Euler rotation angles around each axis, in degrees.
+ */
+ @JvmStatic
+ fun fromEuler(eulerAngles: Vector3): RotationQuaternion {
+ return fromEuler(eulerAngles.x, eulerAngles.y, eulerAngles.z)
+ }
+
+ /**
+ * Creates a rotation quaternion from some Euler angles (ZYX sequence).
+ *
+ * @param rotationX The Euler rotation angle around the X axis, in degrees.
+ * @param rotationY The Euler rotation angle around the Y axis, in degrees.
+ * @param rotationZ The Euler rotation angle around the Z axis, in degrees.
+ */
+ @JvmStatic
+ fun fromEuler(rotationX: Float, rotationY: Float, rotationZ: Float): RotationQuaternion {
+ val halfDegToRad = 0.5 * DEG_TO_RAD
+ val cy = cos(rotationZ * halfDegToRad)
+ val sy = sin(rotationZ * halfDegToRad)
+ val cp = cos(rotationY * halfDegToRad)
+ val sp = sin(rotationY * halfDegToRad)
+ val cr = cos(rotationX * halfDegToRad)
+ val sr = sin(rotationX * halfDegToRad)
+
+ val w = cr * cp * cy + sr * sp * sy
+ val x = sr * cp * cy - cr * sp * sy
+ val y = cr * sp * cy + sr * cp * sy
+ val z = cr * cp * sy - sr * sp * cy
+
+ return fromQuaternion(w, x, y, z)
+ }
+ }
+
+ private val w: Double
+ private val x: Double
+ private val y: Double
+ private val z: Double
+ val direction: Vector3
+ val angle: Double
+
+ /**
+ * Creates a unit quaternion representing a rotation.
+ *
+ * @param angle The angle of rotation around [direction] vector, in degrees. The rotation is
+ * counterclockwise (if the [direction] vector is pointing at the point of sight).
+ * @param direction The angle of rotation, in degrees.
+ */
+ constructor(angle: Double, direction: Vector3) {
+ this.direction = direction.toNormalized()
+ this.angle = angle
+
+ val angleRad = angle * DEG_TO_RAD
+ val sinAngle = sin(angleRad)
+ w = cos(angleRad)
+ x = sinAngle * this.direction.x
+ y = sinAngle * this.direction.y
+ z = sinAngle * this.direction.z
+ }
+
+ /**
+ * Creates a identity rotation quaternion, with the direction pointing to the X axis.
+ */
+ constructor() : this(0.0, Vector3.X_AXIS)
+
+ /**
+ * Creates a rotation quaternion from another rotation quaternion.
+ */
+ constructor(rotationQuaternion: RotationQuaternion) : this(
+ rotationQuaternion.angle,
+ rotationQuaternion.direction
+ )
+
+ /**
+ * Returns a [Vector3] representing a quaternion as some Rotation Euler Angles (ZYX sequence).
+ *
+ * @return A [Vector3] containing the Euler rotation angle around each axis, in degrees.
+ */
+ fun toEulerAngles(): Vector3 {
+ val angleX = atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y))
+
+ val tmp = 2 * (w * y - z * x)
+ val angleY = if (abs(tmp) >= 1) {
+ tmp.sign * PI / 2.0
+ } else {
+ asin(tmp)
+ }
+
+ val angleZ = atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))
+
+ return Vector3(
+ (angleX * RAD_TO_DEG).toFloat(),
+ (angleY * RAD_TO_DEG).toFloat(),
+ (angleZ * RAD_TO_DEG).toFloat()
+ )
+ }
+
+ /**
+ * Inverts the rotation quaternion (q^-1).
+ *
+ * @return The new inverted quaternion.
+ */
+ fun inverse(): RotationQuaternion {
+ return fromQuaternion(w, -x, -y, -z)
+ }
+
+ /**
+ * Applies the current rotation quaternion to a [Vector3].
+ *
+ * @param vector the [Vector3] that will be rotated.
+ *
+ * @return the rotated [vector].
+ */
+ fun applyRotationTo(vector: Vector3): Vector3 {
+ return (this * (fromQuaternion(
+ 0.0,
+ vector.x.toDouble(),
+ vector.y.toDouble(),
+ vector.z.toDouble()
+ ) * this.inverse())).direction * vector.length()
+ }
+
+ operator fun times(q: RotationQuaternion): RotationQuaternion {
+ return fromQuaternion(
+ w * q.w - x * q.x - y * q.y - z * q.z,
+ w * q.x + x * q.w + y * q.z - z * q.y,
+ w * q.y - x * q.z + y * q.w + z * q.x,
+ w * q.z + x * q.y - y * q.x + z * q.w
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as RotationQuaternion
+
+ if (direction != other.direction) return false
+ if (angle != other.angle) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = direction.hashCode()
+ result = 31 * result + angle.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "Angle: ${angle}º, Direction: $direction"
+ }
+} \ No newline at end of file
diff --git a/toruslib/torus-math/src/main/java/com/google/android/torus/math/SphericalTransform.kt b/toruslib/torus-math/src/main/java/com/google/android/torus/math/SphericalTransform.kt
new file mode 100644
index 0000000..3da6bb1
--- /dev/null
+++ b/toruslib/torus-math/src/main/java/com/google/android/torus/math/SphericalTransform.kt
@@ -0,0 +1,313 @@
+/*
+ * 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.google.android.torus.math
+
+import android.opengl.Matrix
+import java.util.*
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * Defines an immutable transformation using Spherical Coordinates, which might be more
+ * suitable for certain animations or behaviors than [AffineTransform] (i.e. camera orbit control).
+ *
+ * The position of a point P in Spherical Coordinates system is specified by:
+ * - A point [center], which defines the origin of spherical coordinate system.
+ * - A [distance] of the point P from the [center].
+ * - And some polar angles [elevation] which defines the angle from the reference plane (which is
+ * parallel to the ZY plane and contains [center]) to the zenith direction (which is parallel to
+ * the Y axis and the normal of the reference plane, and passes though [center]) of the Point P;
+ * and [azimuth] that defines the angle of rotation of the projected point P into the
+ * reference plane (from Z to Y).
+ *
+ * In addition we add [roll] to the model (that rotates around the model's (intrinsic) Z axis), and
+ * [scale].
+ *
+ * The order of operations is: scale => roll => Spherical Coordinates position and rotation.
+ */
+class SphericalTransform @JvmOverloads constructor(
+ /** The azimuth angle (from Z to X, counter clockwise, in degrees). */
+ val azimuth: Float = 0f,
+
+ /**
+ * The elevation angle (from ZX plane to Y axis; positive is up negative is down, in degrees).
+ */
+ val elevation: Float = 0f,
+
+ /** The roll rotation (around the model's (intrinsic) Z axis, in degrees). */
+ val roll: Float = 0f,
+
+ /** Center position of the spherical transform (the target). */
+ val center: Vector3 = Vector3(0f, 0f, 0f),
+
+ /**
+ * Distance of the transform from [center] which defines a sphere of radius = distance.
+ * The distance value has to be >= 0.
+ */
+ val distance: Float = 1f,
+
+ /** The scale of the transform. */
+ val scale: Vector3 = Vector3(1f, 1f, 1f)
+
+) : MatrixTransform {
+ constructor(transform: SphericalTransform) : this(
+ transform.azimuth,
+ transform.elevation,
+ transform.roll,
+ transform.center,
+ transform.distance,
+ transform.scale
+ )
+
+ init {
+ if (distance < 0) throw IllegalArgumentException("Distance cannot be negative!")
+ }
+
+ /**
+ * Creates a new [SphericalTransform] with a new [azimuth] rotation.
+ *
+ * @param azimuth The new azimuth rotation (from Z to X, counter clockwise, in degrees).
+ *
+ * @return The new [SphericalTransform] with the new rotation.
+ */
+ fun withAzimuth(azimuth: Float): SphericalTransform {
+ return SphericalTransform(azimuth, elevation, roll, center, distance, scale)
+ }
+
+ /**
+ * Creates a new [SphericalTransform] with a new [elevation] rotation.
+ *
+ * @param elevation The new elevation rotation (from ZX plane to Y axis; positive is up negative
+ * is down). In degrees.
+ *
+ * @return The new [SphericalTransform] with the new rotation.
+ */
+ fun withElevation(elevation: Float): SphericalTransform {
+ return SphericalTransform(azimuth, elevation, roll, center, distance, scale)
+ }
+
+ /**
+ * Creates a new [SphericalTransform] with a new [roll] rotation.
+ *
+ * @param roll The new elevation rotation (around Z axis, in degrees).
+ *
+ * @return The new [SphericalTransform] with the new rotation.
+ */
+ fun withRoll(roll: Float): SphericalTransform {
+ return SphericalTransform(azimuth, elevation, roll, center, distance, scale)
+ }
+
+ /**
+ * Creates a new [SphericalTransform] with a new [center].
+ *
+ * @param center The center of the spherical transform.
+ *
+ * @return The new [SphericalTransform] with the new center.
+ */
+ fun withCenter(center: Vector3): SphericalTransform {
+ return SphericalTransform(azimuth, elevation, roll, center, distance, scale)
+ }
+
+ /**
+ * Creates a new [SphericalTransform] with a new ([x], [y], [z]) center.
+ *
+ * @param x The scale in the X direction.
+ * @param y The scale in the Y direction.
+ * @param z The scale in the Z direction.
+ *
+ * @return The new [SphericalTransform] with the new center.
+ */
+ fun withCenter(x: Float, y: Float, z: Float): SphericalTransform {
+ return SphericalTransform(azimuth, elevation, roll, Vector3(x, y, z), distance, scale)
+ }
+
+ /**
+ * Creates a new [SphericalTransform] with a new [distance] from the [center].
+ *
+ * @param distance The new distance (cannot be smaller than 0).
+ *
+ * @return The new [SphericalTransform] with the new rotation.
+ */
+ fun withDistance(distance: Float): SphericalTransform {
+ return SphericalTransform(azimuth, elevation, roll, center, distance, scale)
+ }
+
+ /**
+ * Creates a new [SphericalTransform] with ([x], [y], [z]) as the new scale.
+ *
+ * @param x The scale in the X direction.
+ * @param y The scale in the Y direction.
+ * @param z The scale in the Z direction.
+ *
+ * @return The new [SphericalTransform] with the new scale.
+ */
+ fun withScale(x: Float, y: Float, z: Float): SphericalTransform {
+ return SphericalTransform(azimuth, elevation, roll, center, distance, Vector3(x, y, z))
+ }
+
+ /**
+ * Creates a new [SphericalTransform] with ([scale], [scale], [scale]) as the new scale.
+ *
+ * @param scale The scale applied in the X,Y and Z directions.
+ *
+ * @return The new [SphericalTransform] with the new scale.
+ */
+ fun withScale(scale: Float): SphericalTransform {
+ return withScale(scale, scale, scale)
+ }
+
+ fun rotateByAzimuth(azimuth: Float): SphericalTransform {
+ return SphericalTransform(
+ this.azimuth + azimuth,
+ elevation,
+ roll,
+ center,
+ distance,
+ scale
+ )
+ }
+
+ fun rotateByElevation(elevation: Float): SphericalTransform {
+ return SphericalTransform(
+ azimuth,
+ this.elevation + elevation,
+ roll,
+ center,
+ distance,
+ scale
+ )
+ }
+
+ fun rollBy(roll: Float): SphericalTransform {
+ return SphericalTransform(
+ azimuth,
+ elevation,
+ this.roll + roll,
+ center,
+ distance,
+ scale
+ )
+ }
+
+ fun translateCenterBy(x: Float, y: Float, z: Float): SphericalTransform {
+ return SphericalTransform(
+ azimuth,
+ elevation,
+ roll,
+ center + Vector3(x, y, z),
+ distance,
+ scale
+ )
+ }
+
+ fun translateBy(distance: Float): SphericalTransform {
+ return SphericalTransform(azimuth, elevation, roll, center, this.distance + distance, scale)
+ }
+
+ fun scaleBy(scale: Float): SphericalTransform {
+ return SphericalTransform(
+ azimuth,
+ elevation,
+ roll,
+ center,
+ distance,
+ this.scale + Vector3(scale)
+ )
+ }
+
+ fun scaleBy(x: Float, y: Float, z: Float): SphericalTransform {
+ return SphericalTransform(
+ azimuth,
+ elevation,
+ roll,
+ center,
+ distance,
+ scale + Vector3(x, y, z)
+ )
+ }
+
+ /**
+ * Returns a 4x4 transform Matrix. The format of the matrix follows the OpenGL ES matrix format
+ * stored in float arrays.
+ * Matrices are 4 x 4 column-vector matrices stored in column-major order:
+ *
+ * <pre>
+ * m[0] m[4] m[8] m[12]
+ * m[1] m[5] m[9] m[13]
+ * m[2] m[6] m[10] m[14]
+ * m[3] m[7] m[11] m[15]
+ * </pre>
+ *
+ * @return a 16 value [FloatArray] representing the transform as a 4x4 matrix.
+ */
+ override fun toMatrix(): FloatArray {
+ val transformMatrix = FloatArray(16)
+ val tmp = FloatArray(16)
+ val azimuthRad = (azimuth * MathUtils.DEG_TO_RAD).toFloat()
+ val elevationRad = (elevation * MathUtils.DEG_TO_RAD).toFloat()
+ Matrix.setIdentityM(transformMatrix, 0)
+ Matrix.scaleM(transformMatrix, 0, scale.x, scale.y, scale.z)
+ Matrix.rotateM(transformMatrix, 0, roll, 0f, 0f, 1f)
+ Matrix.setLookAtM(
+ tmp, 0,
+ center.x + distance * sin(azimuthRad) * cos(elevationRad),
+ center.y + distance * sin(elevationRad),
+ center.z + distance * cos(azimuthRad) * cos(elevationRad),
+ center.x,
+ center.y,
+ center.z,
+ Vector3.Y_AXIS.x,
+ Vector3.Y_AXIS.y,
+ Vector3.Y_AXIS.z
+ )
+ Matrix.multiplyMM(transformMatrix, 0, tmp, 0, transformMatrix, 0)
+ return transformMatrix
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as SphericalTransform
+
+ if (azimuth != other.azimuth) return false
+ if (elevation != other.elevation) return false
+ if (roll != other.roll) return false
+ if (center != other.center) return false
+ if (distance != other.distance) return false
+ if (scale != other.scale) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = azimuth.hashCode()
+ result = 31 * result + elevation.hashCode()
+ result = 31 * result + roll.hashCode()
+ result = 31 * result + center.hashCode()
+ result = 31 * result + distance.hashCode()
+ result = 31 * result + scale.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "Rotation (az, el, ro): (${azimuth}, ${elevation}, ${roll})\n" +
+ "Center: $center\n" +
+ "Distance: $distance\n" +
+ "Scale: $scale\n"
+ }
+} \ No newline at end of file
diff --git a/toruslib/torus-math/src/main/java/com/google/android/torus/math/Vector2.kt b/toruslib/torus-math/src/main/java/com/google/android/torus/math/Vector2.kt
new file mode 100644
index 0000000..aba0810
--- /dev/null
+++ b/toruslib/torus-math/src/main/java/com/google/android/torus/math/Vector2.kt
@@ -0,0 +1,197 @@
+/*
+ * 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.google.android.torus.math
+
+import java.text.DecimalFormat
+import java.util.*
+import kotlin.math.hypot
+
+/**
+ * An immutable two-dimensional vector.
+ */
+class Vector2 @JvmOverloads constructor(val x: Float, val y: Float) {
+ companion object {
+ val ZERO = Vector2(0f, 0f)
+ val Y_AXIS = Vector2(0f, 1f)
+ val NEG_Y_AXIS = Vector2(0f, -1f)
+ val X_AXIS = Vector2(1f, 0f)
+ val NEG_X_AXIS = Vector2(-1f, 0f)
+ private val FORMAT = DecimalFormat("##.###")
+
+ /**
+ * Linear interpolation between two vectors. The interpolated value will be always inside
+ * the interval [[vectorStart], [vectorEnd]].
+ *
+ * @param vectorStart The first point that defines the linear interpolant.
+ * @param vectorEnd The second point that defines the linear interpolant.
+ * @param amount Value used to interpolate between [vectorStart] and [vectorEnd]. When
+ * [amount] is zero, the return value is [vectorStart]; when [amount] is 1, the return value
+ * is [vectorEnd].
+ *
+ * @return interpolated value.
+ */
+ @JvmStatic
+ fun lerp(vectorStart: Vector2, vectorEnd: Vector2, amount: Float): Vector2 {
+ return Vector2(
+ MathUtils.lerp(vectorStart.x, vectorEnd.x, amount),
+ MathUtils.lerp(vectorStart.y, vectorEnd.y, amount)
+ )
+ }
+ }
+
+ constructor() : this(0f)
+ constructor(value: Float) : this(value, value)
+ constructor(vector: Vector2) : this(vector.x, vector.y)
+
+ fun plus(x: Float, y: Float): Vector2 {
+ return Vector2(this.x + x, this.y + y)
+ }
+
+ fun minus(x: Float, y: Float): Vector2 {
+ return Vector2(this.x - x, this.y - y)
+ }
+
+ fun multiplyBy(value: Float): Vector2 {
+ return multiplyBy(value, value)
+ }
+
+ fun multiplyBy(vector: Vector2): Vector2 {
+ return multiplyBy(vector.x, vector.y)
+ }
+
+ fun multiplyBy(x: Float, y: Float): Vector2 {
+ return Vector2(this.x * x, this.y * y)
+ }
+
+ /**
+ * Returns a new [Vector2] instance with a normalize length (length = 1), and same direction
+ * as the current vector.
+ *
+ * @return the new [Vector2] instance with a normalized length and same direction as current
+ * vector.
+ */
+ fun toNormalized(): Vector2 {
+ val length = length()
+ return Vector2(x / length, y / length)
+ }
+
+ /**
+ * Performs the algebraic dot product operation.
+ *
+ * @param vector The second vector of the dot product.
+ *
+ * @return the result of the dot product of current vector and [vector].
+ */
+ fun dot(vector: Vector2): Float {
+ return dot(vector.x, vector.y)
+ }
+
+ /**
+ * Performs the algebraic dot product operation.
+ *
+ * @param x The first component of a two-dimensional vector.
+ * @param y The second component of a two-dimensional vector.
+ *
+ * @return the result of the dot product of current vector and ([x], [y]).
+ */
+ fun dot(x: Float, y: Float): Float {
+ return this.x * x + this.y * y
+ }
+
+ /**
+ * Returns the distance between the current 2D point defined by current vector and [vector].
+ *
+ * @param vector The second point.
+ *
+ * @return the distance between current vector and [vector].
+ */
+ fun distanceTo(vector: Vector2): Float {
+ return distanceTo(vector.x, vector.y)
+ }
+
+ /**
+ * Returns the distance between the current 2D point defined by current vector
+ * and ([x], [y]).
+ *
+ * @param x The first component of a two-dimensional vector.
+ * @param y The second component of a two-dimensional vector.
+ *
+ * @return the distance between the current vector and ([x], [y]).
+ */
+ fun distanceTo(x: Float, y: Float): Float {
+ return hypot(x - this.x, y - this.y)
+ }
+
+ /**
+ * Returns the length of the current vector (which is the distance from origin (0, 0) to the
+ * position defined by the current vector).
+ *
+ * @return The length of the current vector.
+ */
+ fun length(): Float {
+ return hypot(x, y)
+ }
+
+ /**
+ * Returns a new vector with same direction as the current vector and a [newLength] length.
+ *
+ * @param newLength the new length of the vector
+ *
+ * @return the new [Vector2] instance with a [newLength] and same direction as current vector.
+ */
+ fun withLength(newLength: Float): Vector2 {
+ return times(newLength / length())
+ }
+
+ operator fun minus(vector: Vector2): Vector2 {
+ return minus(vector.x, vector.y)
+ }
+
+ operator fun plus(vector: Vector2): Vector2 {
+ return plus(vector.x, vector.y)
+ }
+
+ operator fun times(value: Float): Vector2 {
+ return multiplyBy(value)
+ }
+
+ operator fun times(vector: Vector2): Vector2 {
+ return multiplyBy(vector)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Vector2
+
+ if (x != other.x) return false
+ if (y != other.y) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = x.hashCode()
+ result = 31 * result + y.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "(${FORMAT.format(x)}, ${FORMAT.format(y)})"
+ }
+} \ No newline at end of file
diff --git a/toruslib/torus-math/src/main/java/com/google/android/torus/math/Vector3.kt b/toruslib/torus-math/src/main/java/com/google/android/torus/math/Vector3.kt
new file mode 100644
index 0000000..e55c139
--- /dev/null
+++ b/toruslib/torus-math/src/main/java/com/google/android/torus/math/Vector3.kt
@@ -0,0 +1,226 @@
+/*
+ * 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.google.android.torus.math
+
+import java.text.DecimalFormat
+import java.util.*
+import kotlin.math.sqrt
+
+/**
+ * An immutable three-dimensional vector.
+ */
+class Vector3 @JvmOverloads constructor(val x: Float, val y: Float, val z: Float) {
+ companion object {
+ val ZERO = Vector3(0f, 0f, 0f)
+ val Y_AXIS = Vector3(0f, 1f, 0f)
+ val NEG_Y_AXIS = Vector3(0f, -1f, 0f)
+ val X_AXIS = Vector3(1f, 0f, 0f)
+ val NEG_X_AXIS = Vector3(-1f, 0f, 0f)
+ val Z_AXIS = Vector3(0f, 0f, 1f)
+ val NEG_Z_AXIS = Vector3(0f, 0f, -1f)
+ private val FORMAT = DecimalFormat("##.###")
+
+ /**
+ * Linear interpolation between two vectors. The interpolated value will be always inside
+ * the interval [[vectorStart], [vectorEnd]].
+ *
+ * @param vectorStart The first point that defines the linear interpolant.
+ * @param vectorEnd The second point that defines the linear interpolant.
+ * @param amount Value used to interpolate between [vectorStart] and [vectorEnd]. When
+ * [amount] is zero, the return value is [vectorStart]; when [amount] is 1, the return value
+ * is [vectorEnd].
+ *
+ * @return interpolated value.
+ */
+ @JvmStatic
+ fun lerp(vectorStart: Vector3, vectorEnd: Vector3, amount: Float): Vector3 {
+ return Vector3(
+ MathUtils.lerp(vectorStart.x, vectorEnd.x, amount),
+ MathUtils.lerp(vectorStart.y, vectorEnd.y, amount),
+ MathUtils.lerp(vectorStart.z, vectorEnd.z, amount)
+ )
+ }
+
+ /**
+ * Calculates the cross product from [firstVector] to [secondVector]
+ * (that means [firstVector]x[secondVector]).
+ *
+ * @param firstVector The first three-dimensional vector.
+ * @param secondVector The second three-dimensional vector.
+ *
+ * @return A [Vector3] that represents the result of [firstVector]x[secondVector].
+ */
+ @JvmStatic
+ fun cross(firstVector: Vector3, secondVector: Vector3): Vector3 {
+ return Vector3(
+ firstVector.y * secondVector.z - firstVector.z * secondVector.y,
+ firstVector.z * secondVector.x - firstVector.x * secondVector.z,
+ firstVector.x * secondVector.y - firstVector.y * secondVector.x
+ )
+ }
+ }
+
+ constructor() : this(0f)
+ constructor(value: Float) : this(value, value, value)
+ constructor(vector: Vector3) : this(vector.x, vector.y, vector.z)
+
+
+ fun plus(x: Float, y: Float, z: Float): Vector3 {
+ return Vector3(this.x + x, this.y + y, this.z + z)
+ }
+
+ fun minus(x: Float, y: Float, z: Float): Vector3 {
+ return Vector3(this.x - x, this.y - y, this.z - z)
+ }
+
+ fun multiplyBy(value: Float): Vector3 {
+ return multiplyBy(value, value, value)
+ }
+
+ fun multiplyBy(vector: Vector3): Vector3 {
+ return multiplyBy(vector.x, vector.y, vector.z)
+ }
+
+ fun multiplyBy(x: Float, y: Float, z: Float): Vector3 {
+ return Vector3(this.x * x, this.y * y, this.z * z)
+ }
+
+ /**
+ * Returns a new [Vector3] instance with a normalize length (length = 1), and same direction
+ * as the current vector.
+ *
+ * @return the new [Vector3] instance with a normalized length and same direction as current
+ * vector.
+ */
+ fun toNormalized(): Vector3 {
+ val length = length()
+ return Vector3(x / length, y / length, z / length)
+ }
+
+ /**
+ * Performs the algebraic dot product operation.
+ *
+ * @param vector The second vector of the dot product.
+ *
+ * @return the result of the dot product of current vector and [vector].
+ */
+ fun dot(vector: Vector3): Float {
+ return dot(vector.x, vector.y, vector.z)
+ }
+
+ /**
+ * Performs the algebraic dot product operation.
+ *
+ * @param x The first component of a three-dimensional vector.
+ * @param y The second component of a three-dimensional vector.
+ * @param y The third component of a three-dimensional vector.
+ *
+ * @return the result of the dot product of current vector and ([x], [y], [z]).
+ */
+ fun dot(x: Float, y: Float, z: Float): Float {
+ return this.x * x + this.y * y + this.z * z
+ }
+
+ /**
+ * Returns the distance between the current 3D point defined by current vector and [vector].
+ *
+ * @param vector The second point.
+ *
+ * @return the distance between current vector and [vector].
+ */
+ fun distanceTo(vector: Vector3): Float {
+ return distanceTo(vector.x, vector.y, vector.z)
+ }
+
+ /**
+ * Returns the distance between the current 3D point defined by current vector
+ * and ([x], [y], [z]).
+ *
+ * @param x The first component of a three-dimensional vector.
+ * @param y The second component of a three-dimensional vector.
+ * @param z The third component of a three-dimensional vector.
+ *
+ * @return the distance between current vector and ([x], [y], [z]).
+ */
+ fun distanceTo(x: Float, y: Float, z: Float): Float {
+ val dx = x - this.x
+ val dy = y - this.y
+ val dz = z - this.z
+ return sqrt(dx * dx + dy * dy + dz * dz)
+ }
+
+ /**
+ * Returns the length of the current vector (which is the distance from origin (0, 0, 0) to the
+ * position defined by the current vector).
+ *
+ * @return The length of the current vector.
+ */
+ fun length(): Float {
+ return sqrt(dot(x, y, z))
+ }
+
+ /**
+ * Returns a new vector with same direction as the current vector and a [newLength] length.
+ *
+ * @param newLength the new length of the vector
+ *
+ * @return the new [Vector3] instance with a [newLength] and same direction as current vector.
+ */
+ fun withLength(newLength: Float): Vector3 {
+ return multiplyBy(newLength / length())
+ }
+
+ operator fun minus(vector: Vector3): Vector3 {
+ return minus(vector.x, vector.y, vector.z)
+ }
+
+ operator fun plus(vector: Vector3): Vector3 {
+ return plus(vector.x, vector.y, vector.z)
+ }
+
+ operator fun times(value: Float): Vector3 {
+ return multiplyBy(value)
+ }
+
+ operator fun times(vector: Vector3): Vector3 {
+ return multiplyBy(vector)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Vector3
+
+ if (x != other.x) return false
+ if (y != other.y) return false
+ if (z != other.z) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = x.hashCode()
+ result = 31 * result + y.hashCode()
+ result = 31 * result + z.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "(${FORMAT.format(x)}, ${FORMAT.format(y)}, ${FORMAT.format(z)})"
+ }
+} \ No newline at end of file
diff --git a/toruslib/torus-utils/build.gradle b/toruslib/torus-utils/build.gradle
new file mode 100644
index 0000000..689e4fb
--- /dev/null
+++ b/toruslib/torus-utils/build.gradle
@@ -0,0 +1,17 @@
+// 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.
+
+dependencies {
+ implementation project(':torus-math')
+} \ No newline at end of file
diff --git a/toruslib/torus-utils/src/main/AndroidManifest.xml b/toruslib/torus-utils/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..d7de763
--- /dev/null
+++ b/toruslib/torus-utils/src/main/AndroidManifest.xml
@@ -0,0 +1,15 @@
+<?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 package="com.google.android.torus" />
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/BitmapUtils.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/BitmapUtils.kt
new file mode 100644
index 0000000..dadb6b5
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/BitmapUtils.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.google.android.torus.utils
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Color
+import android.util.Size
+import android.view.View
+import java.io.IOException
+
+/**
+ * Bitmap utils.
+ */
+object BitmapUtils {
+ @JvmStatic
+ @Throws(IOException::class)
+ fun loadBitmap(context: Context, bitmapResourceId: Int): Bitmap {
+ val options = BitmapFactory.Options()
+ options.inScaled = false
+ return BitmapFactory.decodeResource(context.resources, bitmapResourceId, options)
+ ?: throw IOException("Bitmap has not been decoded properly.")
+ }
+
+ @JvmStatic
+ fun getBitmapSize(context: Context, bitmapResourceId: Int): Size {
+ val options = BitmapFactory.Options()
+ options.inJustDecodeBounds = true
+ val bitmap = BitmapFactory.decodeResource(context.resources, bitmapResourceId, options)
+ val size = Size(options.outWidth, options.outHeight)
+ bitmap?.recycle()
+ return size
+ }
+
+ /**
+ * Generates a Bitmap from a view.
+ *
+ * @param view The view that we want to create a [Bitmap].
+ * @param config The [Bitmap.Config] of how we load the view. By default uses
+ * an ARGB 8888 format.
+ *
+ * @return The generated [Bitmap]
+ */
+ @JvmStatic
+ fun generateBitmapFromView(
+ view: View,
+ config: Bitmap.Config = Bitmap.Config.ARGB_8888
+ ): Bitmap {
+ val bitmap = Bitmap.createBitmap(view.measuredWidth, view.measuredHeight, config)
+ val canvas = Canvas(bitmap)
+ canvas.drawColor(Color.TRANSPARENT)
+ view.draw(canvas)
+ return bitmap
+ }
+} \ No newline at end of file
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/animation/EasingUtils.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/animation/EasingUtils.kt
new file mode 100644
index 0000000..d9b33a4
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/animation/EasingUtils.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.google.android.torus.utils.animation
+
+import com.google.android.torus.math.MathUtils
+import kotlin.math.pow
+
+/** Utilities to help implement "easing" operations. */
+object EasingUtils {
+ /**
+ * Easing function to interpolate a smooth curve that "follows" some other signal value in
+ * real-time. The "follow curve" is an exponentially-weighted moving average (EWMA) of the
+ * signal values: assuming a fixed timestep, the "follow value" at time t is determined by the
+ * signal value S_t as `F_t = k * S_t + (1 - k) * F_(t-1)`, for some "easing rate" k between
+ * 0 and 1. Note this formulation assumes that the "signal curve" moves by discrete steps with
+ * zero velocity in between. This may cause slightly unexpected "follow" behavior -- e.g. the
+ * curve may start to "settle" toward the new signal value even if we don't expect it to be
+ * stable, or it may lag and/or move somewhat abruptly if the "signal curve" reverses direction.
+ * These discrepancies would be most noticeable at frame rates that are especially low or
+ * highly-variable, and so far they haven't seemed problematic in any of our applications.
+ *
+ * @param currentValue The value of the "follow curve" prior to this update step (i.e., either
+ * the value returned the last time this function was called, or the initial value where the
+ * follow curve should start). In most applications the initial value will be set to match
+ * the first reading of the signal value.
+ * @param targetValue The most recent reading of the "signal value." If this value remains
+ * constant, the "follow curve" will eventually settle to it (asymptotically).
+ * @param easingRate A parameter to control the "follow speed" between 0 (the follow curve
+ * remains at its |currentValue| regardless of the new signal) and 1 (the follow curve
+ * immediately snaps to the new |targetValue|, effectively disabling easing).
+ * This parameter is typically tuned empirically. If the simulation is running at 60FPS, the
+ * easing function exactly matches the "fixed timestep" version above, with easing rate k.
+ * @param deltaSeconds The amount of time elapsed since determining the old |currentValue|, in
+ * seconds, during which the "follow curve" is assumed to have been converging towards the new
+ * |targetValue|.
+ *
+ * @return the value of the "easing curve" after updating by |deltaSeconds|.
+ */
+ @JvmStatic
+ fun calculateEasing(
+ currentValue: Float, targetValue: Float, easingRate: Float, deltaSeconds: Float
+ ): Float {
+ /* The exponential form of easing we use to support variable frame rates is inverted from
+ * the fixed timestep version above; an easing rate of zero "disables easing" so that the
+ * follow curve "snaps" to the new value, while an easing rate of one leaves the follow
+ * curve at its current value. We can simply take the complement: */
+ val exponentialEasingRate = 1f - easingRate
+
+ val lerpBy = 1f - exponentialEasingRate.pow(deltaSeconds)
+ return MathUtils.lerp(currentValue, targetValue, lerpBy)
+ }
+}
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/broadcast/BroadcastEventController.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/broadcast/BroadcastEventController.kt
new file mode 100644
index 0000000..f25d498
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/broadcast/BroadcastEventController.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.google.android.torus.utils.broadcast
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * This is the base class to be implemented when we need to listen to broadcast events
+ * It registers a broadcast receiver and triggers [onBroadcastReceived] when a new
+ * broadcast is received.
+ */
+abstract class BroadcastEventController constructor(protected var context: Context) {
+ private val broadcastRegistered: AtomicBoolean = AtomicBoolean(false)
+ private val initialized: AtomicBoolean
+ private val broadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ intent.action?.let { action ->
+ onBroadcastReceived(context, intent, action)
+ }
+ }
+ }
+
+ init {
+ val hasInitialized = initResources()
+ initialized = AtomicBoolean(hasInitialized)
+ }
+
+ protected abstract fun initResources(): Boolean
+ abstract fun onBroadcastReceived(context: Context, intent: Intent, action: String)
+ protected abstract fun onRegister(fire: Boolean): IntentFilter
+ protected abstract fun onUnregister()
+
+ /**
+ * Start listening to broadcasts by registering the broadcast receiver.
+ *
+ * @param fire sets whether to notify the listener right away with the current state.
+ */
+ fun start(fire: Boolean = false) {
+ if (!initialized.get()) {
+ val hasInitialized = initResources()
+ if (!hasInitialized) return
+ initialized.set(true)
+ }
+ if (!broadcastRegistered.get()) {
+ val filter = onRegister(fire)
+ registerReceiver(context, broadcastReceiver, filter)
+ broadcastRegistered.set(true)
+ }
+ }
+
+ /**
+ * Stop listening to broadcasts by unregistering the broadcast receiver.
+ */
+ @Synchronized
+ fun stop() {
+ if (broadcastRegistered.get()) {
+ onUnregister()
+ unregisterReceiver(context, broadcastReceiver)
+ broadcastRegistered.set(false)
+ }
+ }
+
+ protected fun registerReceiver(
+ context: Context,
+ broadcastReceiver: BroadcastReceiver?,
+ filter: IntentFilter?
+ ) {
+ context.registerReceiver(broadcastReceiver, filter)
+ }
+
+ protected fun unregisterReceiver(context: Context, broadcastReceiver: BroadcastReceiver?) {
+ context.unregisterReceiver(broadcastReceiver)
+ }
+}
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/broadcast/PowerSaveController.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/broadcast/PowerSaveController.kt
new file mode 100644
index 0000000..c9f0d3e
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/broadcast/PowerSaveController.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.google.android.torus.utils.broadcast
+
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.PowerManager
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * PowerSaveController registers a BroadcastReceiver that listens to
+ * changes in Power Save Mode provided by the OS.
+ * Forwards received broadcasts to be handled by a [PowerSaveListener].
+ */
+class PowerSaveController(
+ context: Context,
+ private val listener: PowerSaveListener?
+) : BroadcastEventController(context) {
+ companion object {
+ const val DEFAULT_POWER_SAVE_MODE = false
+ }
+
+ private var powerSaving: AtomicBoolean? = null
+ private var powerManager: PowerManager? = null
+
+ override fun initResources(): Boolean {
+ if (powerSaving == null) powerSaving = AtomicBoolean(DEFAULT_POWER_SAVE_MODE)
+ if (powerManager == null) powerManager =
+ context.getSystemService(Context.POWER_SERVICE) as PowerManager?
+ return powerManager != null
+ }
+
+ override fun onBroadcastReceived(context: Context, intent: Intent, action: String) {
+ if (action == PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) {
+ /* Check if powerSaveMode has changed. */
+ powerManager?.let { setPowerSave(it.isPowerSaveMode, true) }
+ }
+ }
+
+ override fun onRegister(fire: Boolean): IntentFilter {
+ powerManager?.let { setPowerSave(it.isPowerSaveMode, fire) }
+ return IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
+ }
+
+ override fun onUnregister() {}
+
+ private fun setPowerSave(isPowerSave: Boolean, fire: Boolean) {
+ powerSaving?.let {
+ if (it.get() == isPowerSave) return
+ it.set(isPowerSave)
+ }
+
+ listener?.let {
+ if (fire) listener.onPowerSaveModeChanged(isPowerSave)
+ }
+ }
+
+ fun isPowerSaving(): Boolean = powerSaving?.get() ?: false
+
+ interface PowerSaveListener {
+ fun onPowerSaveModeChanged(isPowerSaveMode: Boolean)
+ }
+
+}
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/content/ResourcesManager.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/content/ResourcesManager.kt
new file mode 100644
index 0000000..0e94530
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/content/ResourcesManager.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.google.android.torus.utils.content
+
+import java.lang.ref.WeakReference
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.ConcurrentMap
+
+/**
+ * Class that holds the resources to be used by the engine in concurrent instances.
+ * This class is used to re-use resources that take time and memory in the system. Adding them here
+ * they can be re-used by different instances.
+ */
+class ResourcesManager {
+ private val resources: ConcurrentMap<String, WeakReference<Any>> = ConcurrentHashMap(0)
+
+ /**
+ * The number of resources hold.
+ */
+ var size: Int = 0
+ get() = resources.size
+ private set
+
+ /**
+ * Stores the given resource in the ResourceManager.
+ *
+ * @param key A string identifying the resource (it can be any).
+ * @param resource The resource that we want to use in multiple instances.
+ * @return True if the resource was added; false if the resource already existed.
+ */
+ fun addResource(key: String, resource: Any): Boolean {
+ if (resources.contains(key) &&
+ resources[key] != null &&
+ resources[key]!!.get() != null
+ ) {
+ return false
+ }
+
+ resources[key] = WeakReference(resource)
+ return true
+ }
+
+ /**
+ * Gets a resource from the ResourcesManager, using the supplied function to create the resource
+ * if it didn't already exist. The key is always mapped to a non-null resource as a
+ * post-condition of this method.
+ *
+ * @param key A string identifying the resource (it can be any).
+ * @param provider A function to create the resource if it's not already indexed.
+ * @return The (new or existing) resource associated with the key.
+ */
+ fun <T> getOrAddResource(key: String, provider: () -> T): T {
+ val resource: T = (resources[key]?.get() as T) ?: provider()
+ resources[key] = WeakReference<Any>(resource)
+ return resource
+ }
+
+ /**
+ * Returns the resource associated with the [key].
+ *
+ * @param key The key associated with the resource.
+ * @return The given resource; null if the resource wasn't found.
+ */
+ fun getResource(key: String): Any? {
+ if (!resources.contains(key)) return null
+ resources[key]?.let {
+ return it.get()
+ }
+ return null
+ }
+
+ /**
+ * Stops the resource associated with the given [key].
+ *
+ * @param key The key associated with the resource.
+ * @return The resource reference that has been removed; null if the resource wasn't found.
+ */
+ fun removeResource(key: String): WeakReference<Any>? = resources.remove(key)
+}
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplayOrientationController.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplayOrientationController.kt
new file mode 100644
index 0000000..7d2379e
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplayOrientationController.kt
@@ -0,0 +1,124 @@
+/*
+ * 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.google.android.torus.utils.display
+
+import android.content.Context
+import android.hardware.display.DisplayManager
+import android.os.Build
+import android.view.Display
+import android.view.Surface
+import android.view.WindowManager
+
+
+/**
+ * Listens to rotation changes of the current display (read from the context for Android 11+)
+ * or the orientation of default display (For version <= Android 10).
+ */
+class DisplayOrientationController(
+ context: Context,
+ private val listener: DisplayOrientationListener? = null
+) {
+ /**
+ * The orientation of the screen. we have two types:
+ * [DisplayOrientation.NATURAL_ORIENTATION]: The default orientation of the device
+ * (for a phone it is portrait).
+ *
+ * [DisplayOrientation.ALTERNATE_ORIENTATION]: When we rotate the device ± 90º from the
+ * default orientation (for a phone it is landscape).
+ */
+ enum class DisplayOrientation { NATURAL_ORIENTATION, ALTERNATE_ORIENTATION }
+
+ var rotation = Surface.ROTATION_0
+ private set
+ var orientation = DisplayOrientation.NATURAL_ORIENTATION
+ private set
+ private val displayChangeListener: DisplayManager.DisplayListener =
+ object : DisplayManager.DisplayListener {
+ override fun onDisplayAdded(displayId: Int) {
+ if (displayId == display.displayId) updateRotationAndOrientation()
+ }
+
+ override fun onDisplayRemoved(displayId: Int) {
+ if (displayId == display.displayId) updateRotationAndOrientation()
+ }
+
+ override fun onDisplayChanged(displayId: Int) {
+ if (displayId == display.displayId) updateRotationAndOrientation()
+ }
+
+ }
+ private val displayManager: DisplayManager =
+ context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
+ private val display: Display
+
+ init {
+ val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+
+ // Only available for Android 11 (SDK 30); before that we can only know the default display.
+ display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ context.display ?: windowManager.defaultDisplay
+ } else {
+ windowManager.defaultDisplay
+ }
+
+ updateRotationAndOrientation(false)
+ }
+
+ /** Starts listening for display rotation/orientation changes. */
+ fun start() {
+ updateRotationAndOrientation()
+ displayManager.registerDisplayListener(displayChangeListener, null)
+ }
+
+ /**
+ * Requests an update on rotation and orientation. If there are changes,
+ * [DisplayOrientationListener.onDisplayOrientationChanged] will be called.
+ */
+ fun update() = updateRotationAndOrientation()
+
+ /** Stops listening for display rotation/orientation changes. */
+ fun stop() = displayManager.unregisterDisplayListener(displayChangeListener)
+
+ private fun updateRotationAndOrientation(sendEvent: Boolean = true) {
+ val rotationTmp = display.rotation
+
+ if (rotation != rotationTmp) {
+
+ rotation = rotationTmp
+ orientation = when (rotation) {
+ Surface.ROTATION_90 -> DisplayOrientation.ALTERNATE_ORIENTATION
+ Surface.ROTATION_270 -> DisplayOrientation.ALTERNATE_ORIENTATION
+ else -> DisplayOrientation.NATURAL_ORIENTATION
+ }
+
+ if (sendEvent) listener?.onDisplayOrientationChanged(orientation, rotation)
+ }
+ }
+
+ /** Interface to listen to display orientation changes. */
+ interface DisplayOrientationListener {
+ /**
+ * Called when orientation has changed for the [Display] that [DisplayOrientationController]
+ * is currently tracking.
+ *
+ * @param orientation the new [DisplayOrientationController.DisplayOrientation] orientation.
+ * @param rotation the new rotation (it can be [Surface.ROTATION_0], [Surface.ROTATION_90],
+ * [Surface.ROTATION_180] or [Surface.ROTATION_270]).
+ */
+ fun onDisplayOrientationChanged(orientation: DisplayOrientation, rotation: Int)
+ }
+}
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplaySizeType.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplaySizeType.kt
new file mode 100644
index 0000000..ceeda6f
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplaySizeType.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.google.android.torus.utils.display
+
+import android.content.res.Configuration
+import android.view.Display
+
+/**
+ * Class that defines the type of display based on its size. The types or displays align with
+ * the Window size classes and thresholds defined in:
+ * https://developer.android.com/guide/topics/large-screens/support-different-screen-sizes.
+ */
+enum class DisplaySizeType {
+ COMPACT,
+ MEDIUM,
+ EXPANDED;
+
+ companion object {
+ private const val MEDIUM_WIDTH_DP_THRESHOLD: Float = 600f
+ private const val EXPANDED_WIDTH_DP_THRESHOLD: Float = 840f
+ private const val MEDIUM_HEIGHT_DP_THRESHOLD: Float = 480f
+ private const val EXPANDED_HEIGHT_DP_THRESHOLD: Float = 900f
+
+ /**
+ * Returns the [DisplaySizeType] based on the display's width.
+ *
+ * @param width the current width of the display (in dp).
+ *
+ * @return The [DisplaySizeType] based on the display [width].
+ */
+ @JvmStatic
+ fun fromWidth(width: Float): DisplaySizeType {
+ return when {
+ width < MEDIUM_WIDTH_DP_THRESHOLD -> COMPACT
+ width < EXPANDED_WIDTH_DP_THRESHOLD -> MEDIUM
+ else -> EXPANDED
+ }
+ }
+
+ /**
+ * Returns the [DisplaySizeType] based the display's height.
+ *
+ * @param height the current height of the display (in dp).
+ *
+ * @return The [DisplaySizeType] based on the display [height].
+ */
+ @JvmStatic
+ fun fromHeight(height: Float): DisplaySizeType {
+ return when {
+ height < MEDIUM_HEIGHT_DP_THRESHOLD -> COMPACT
+ height < EXPANDED_HEIGHT_DP_THRESHOLD -> MEDIUM
+ else -> EXPANDED
+ }
+ }
+
+ /**
+ * Returns the smallest [DisplaySizeType] available (that means for any orientation
+ * of the device) for the current Screen associated to the [config]. This can help
+ * understand what kind of display we have (i.e., if the returned value is
+ * [DisplaySizeType.MEDIUM], we know that this display, independently of its
+ * orientation will have a screen width (in dp) >= MEDIUM_WIDTH_DP_THRESHOLD.
+ *
+ * @param config the current [Configuration].
+ *
+ * @return The smallest [DisplaySizeType] that the current display will have (of all
+ * possible orientations).
+ */
+ @JvmStatic
+ fun smallestAvailableFromDisplay(display: Display): DisplaySizeType {
+ /*
+ * if we were using a Configuration object and we only wanted the display/screen
+ * associated with the current configuration, we could use
+ * config.smallestScreenWidthDp.toFloat() instead.
+ */
+ return fromWidth(DisplayUtils.getSmallestDisplayWidthDp(display))
+ }
+ }
+}
+
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplayUtils.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplayUtils.kt
new file mode 100644
index 0000000..248988e
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/display/DisplayUtils.kt
@@ -0,0 +1,139 @@
+/*
+ * 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.google.android.torus.utils.display
+
+import android.content.Context
+import android.graphics.Point
+import android.hardware.display.DisplayManager
+import android.util.DisplayMetrics
+import android.util.Size
+import android.view.Display
+import kotlin.math.round
+
+/** Display-related utils. */
+object DisplayUtils {
+
+ /** A constant value that should be passed in to get all the screens. */
+ private const val DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED =
+ "android.hardware.display.category.ALL_INCLUDING_DISABLED"
+ /**
+ * Returns a list of the ID and size of each Display available on the current device.
+ *
+ * @param context the application context.
+ *
+ * @return a [List] composed by [Pair<Int, Size>] of Display ID and Display Size.
+ */
+ @JvmStatic
+ fun getDisplayIdsAndSizes(context: Context): List<Pair<Int, Size>> {
+ val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
+ val displays = displayManager.getDisplays(DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED)
+ val displaySizes = displays.map {
+ val size = Point()
+ /*
+ * Note: this API has been deprecated but currently there isn't a good alternative.
+ * The proposed way in the Android Developers site:
+ *
+ * ```
+ * This method was deprecated in API level 31.
+ * Use WindowManager#getCurrentWindowMetrics() to identify the current size of
+ * the activity window. UI-related work, such as choosing UI layouts,
+ * should rely upon WindowMetrics#getBounds().
+ * ```
+ *
+ * Only works to retrieve the DEFAULT/current display but not for any display.
+ * Once we have an API that allows to retrieve this information, this code will be
+ * updated.
+ */
+ it.getRealSize(size)
+ Pair(it.displayId, Size(size.x, size.y))
+ }
+
+ return displaySizes
+ }
+
+ /**
+ * Returns the number of available displays.
+ *
+ * @param context the application context.
+ *
+ * @return the number of available displays.
+ */
+ @JvmStatic
+ fun getNumberOfDisplaysAvailable(context: Context): Int {
+ val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
+ return displayManager.getDisplays(DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED).size
+ }
+
+ /**
+ * Converts a pixel unit to dp.
+ *
+ * @param pixels the pixels unit to convert.
+ * @param displayMetrics the [DisplayMetrics] we want to use.
+ *
+ * @return the pixel unit converted to dp.
+ */
+ @JvmStatic
+ fun convertPixelToDp(pixels: Float, displayMetrics: DisplayMetrics): Float {
+ val densityRatio = displayMetrics.densityDpi / DisplayMetrics.DENSITY_DEFAULT.toFloat()
+ return pixels / densityRatio
+ }
+
+ /**
+ * Converts a dp unit to pixels.
+ *
+ * @param dp the dp unit to convert.
+ * @param displayMetrics the [DisplayMetrics] we want to use.
+ *
+ * @return the dp unit converted to pixels.
+ */
+ @JvmStatic
+ fun convertDpToPixel(dp: Float, displayMetrics: DisplayMetrics): Float {
+ val densityRatio = displayMetrics.densityDpi / DisplayMetrics.DENSITY_DEFAULT.toFloat()
+ return round(dp * densityRatio)
+ }
+
+ /**
+ * Returns the smallest [Display] width in dp (that means for any orientation of the device).
+ *
+ * @param display the [Display] we want to get the smallest width in dp.
+ *
+ * @return The smallest width in dp.
+ */
+ fun getSmallestDisplayWidthDp(display: Display): Float {
+ val metrics = DisplayMetrics()
+ /*
+ * Note: this API has been deprecated but currently there isn't a good alternative.
+ * The proposed way in the Android Developers site:
+ *
+ * ```
+ * This method was deprecated in API level 31.
+ * Use WindowManager#getCurrentWindowMetrics() to identify the current size of
+ * the activity window. UI-related work, such as choosing UI layouts,
+ * should rely upon WindowMetrics#getBounds().
+ * ```
+ *
+ * Only works to retrieve the DEFAULT/current display but not for any display.
+ * Once we have an API that allows to retrieve this information, this code will be
+ * updated.
+ */
+ display.getRealMetrics(metrics)
+ return convertPixelToDp(
+ minOf(metrics.widthPixels, metrics.heightPixels).toFloat(),
+ metrics
+ )
+ }
+}
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/ActivityExt.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/ActivityExt.kt
new file mode 100644
index 0000000..d0dade4
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/ActivityExt.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.google.android.torus.utils.extensions
+
+import android.app.Activity
+import android.view.View
+import android.view.WindowInsets
+import android.view.WindowInsetsController
+
+/**
+ * Extends [Activity] to allow to set immersive fullscreen mode.
+ */
+fun Activity.setImmersiveFullScreen() {
+ // Sets into Immersive mode.
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
+ window.setDecorFitsSystemWindows(false)
+
+ // Hides Navigation bar and Status bar.
+ val controller = window.insetsController
+
+ if (controller != null) {
+ controller.hide(
+ WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()
+ )
+ controller.systemBarsBehavior =
+ WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ } else {
+ // Sets into Immersive mode on older APIs.
+ window.decorView.systemUiVisibility = (
+ View.SYSTEM_UI_FLAG_IMMERSIVE
+ // Set the content to appear under the system bars so that the
+ // content doesn't resize when the system bars hide and show.
+ or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ // Hide the nav bar and status bar
+ or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ or View.SYSTEM_UI_FLAG_FULLSCREEN
+ )
+ }
+}
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/AssetManagerExt.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/AssetManagerExt.kt
new file mode 100644
index 0000000..7f788b0
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/AssetManagerExt.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.google.android.torus.utils.extensions
+
+import android.content.res.AssetManager
+import java.nio.ByteBuffer
+import java.nio.channels.Channels
+
+/**
+ * Extends [AssetManager] to read uncompressed assets.
+ *
+ * @param assetPathAndName The string of the asset path and name inside the assets folder.
+ * The asset must be uncompressed.
+ *
+ * @return A [ByteBuffer] containing the asset.
+ */
+fun AssetManager.readUncompressedAsset(assetPathAndName: String): ByteBuffer {
+
+ openFd(assetPathAndName).use { fd ->
+ val input = fd.createInputStream()
+ val dst = ByteBuffer.allocate(fd.length.toInt())
+
+ val src = Channels.newChannel(input)
+ src.read(dst)
+ src.close()
+
+ return dst.apply { rewind() }
+ }
+}
+
+/**
+ * Extends [AssetManager] to read assets.
+ *
+ * @param assetPathAndName The string of the asset path and name inside the assets folder.
+ * @return A [ByteBuffer] containing the asset.
+ */
+fun AssetManager.readAsset(assetPathAndName: String): ByteBuffer {
+ open(assetPathAndName).use { inputStream ->
+ val byteArray = inputStream.readBytes()
+
+ val dst = ByteBuffer.allocate(byteArray.size)
+ dst.put(byteArray)
+ inputStream.close()
+
+ return dst.apply { rewind() }
+ }
+}
+
+/**
+ * Extends [AssetManager] to read an asset as a [String].
+ *
+ * @param assetName The string of the asset inside the assets folder.
+ * @return A [String] containing the asset information.
+ */
+fun AssetManager.readAssetAsString(assetName: String): String {
+ return String(readAsset(assetName).array(), Charsets.ISO_8859_1)
+}
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/SizeExt.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/SizeExt.kt
new file mode 100644
index 0000000..e92f2c7
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/extensions/SizeExt.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.google.android.torus.utils.extensions
+
+import android.util.Size
+import android.util.SizeF
+
+/**
+ * Extends [Size] to return the aspect ratio (ratio between the width and height). This ratio is
+ * returned as the value resulting of the operation width / height. If width or height have invalid
+ * values (smaller or equal to 0), -1 is returned.
+ *
+ * @return the [Float] representing the aspect ratio, or -1 if width or height have invalid values
+ * (smaller or equal to 0).
+ */
+fun Size.getAspectRatio(): Float {
+ return if (height <= 0 || width <= 0) {
+ -1f
+ } else {
+ width / height.toFloat()
+ }
+}
+
+/**
+ * Extends [Size] to return the aspect ratio (ratio between the width and height). This ratio is
+ * returned as the value resulting of the operation width / height. If width or height have invalid
+ * values (smaller or equal to 0), -1 is returned.
+ *
+ * @return the [Float] representing the aspect ratio, or -1 if width or height have invalid values
+ * (smaller or equal to 0).
+ */
+fun SizeF.getAspectRatio(): Float {
+ return if (height <= 0 || width <= 0) {
+ -1f
+ } else {
+ width / height
+ }
+} \ No newline at end of file
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/interaction/Gyro2dController.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/interaction/Gyro2dController.kt
new file mode 100644
index 0000000..20b3236
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/interaction/Gyro2dController.kt
@@ -0,0 +1,343 @@
+/*
+ * 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.google.android.torus.utils.interaction
+
+import android.content.Context
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.util.Log
+import android.view.Surface
+import com.google.android.torus.math.MathUtils
+import com.google.android.torus.math.Vector2
+import kotlin.math.abs
+import kotlin.math.sign
+
+/**
+ * Class that analyzed the gyroscope and generates a rotation out of it.
+ * This class only calculates the gyroscope rotation for two angles/degrees:
+ * - Pitch (rotation around device X axis).
+ * - Yaw (rotation around device Y axis).
+ *
+ * (Check https://developer.android.com/guide/topics/sensors/sensors_motion for more info).
+ */
+class Gyro2dController(context: Context, config: GyroConfig = GyroConfig()) {
+ companion object {
+ private const val TAG = "Gyro2dController"
+ const val NANOS_TO_S = 1.0f / 1_000_000_000.0f
+ const val RAD_TO_DEG = (180f / Math.PI).toFloat()
+ const val BASE_FPS = 60f
+ const val DEFAULT_EASING = 0.8f
+ }
+
+ /**
+ * Defines the final rotation.
+ *
+ * - [Vector2.x] represents the Pitch (in degrees).
+ * - [Vector2.y] represents the Yaw (in degrees).
+ */
+ var rotation: Vector2 = Vector2()
+ private set
+
+ /**
+ * Defines if gyro is considered to be settled.
+ * TODO: remove once clients are switched to the new |isCurrentlySettled(Vector2)| API.
+ */
+ var isSettled: Boolean = false
+ private set
+
+ /**
+ * Defines whether the gyro animation is almost settled.
+ * TODO: remove once clients are switched to the new |isNearlySettled(Vector2)| API.
+ */
+ var isAlmostSettled: Boolean = false
+ private set
+
+ /**
+ * The config that defines the behavior of the gyro..
+ */
+ var config: GyroConfig = config
+ set(value) {
+ field = value
+ onNewConfig()
+ }
+
+ private val angles: FloatArray = FloatArray(3)
+ private val sensorEventListener: SensorEventListener = object : SensorEventListener {
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
+ }
+
+ override fun onSensorChanged(event: SensorEvent) {
+ updateGyroRotation(event)
+ }
+ }
+ private val displayRotationValues: IntArray =
+ intArrayOf(
+ Surface.ROTATION_0,
+ Surface.ROTATION_90,
+ Surface.ROTATION_180,
+ Surface.ROTATION_270
+ )
+ private val sensorManager: SensorManager =
+ context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
+ private val gyroSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
+ private var displayRotation: Int = displayRotationValues[0]
+ private var timestamp: Float = 0f
+ private var recenter: Boolean = false
+ private var recenterMul: Float = 1f
+ private var ease: Boolean = true
+ private var gyroSensorRegistered: Boolean = false
+
+ // Speed per frame, based on 60FPS.
+ private var easingMul: Float = DEFAULT_EASING * BASE_FPS
+
+ init {
+ onNewConfig()
+ }
+
+ /**
+ * Starts listening for gyroscope events.
+ * (the rotation is also reset).
+ */
+ fun start() {
+ gyroSensor?.let {
+ sensorManager.registerListener(
+ sensorEventListener,
+ it,
+ SensorManager.SENSOR_DELAY_GAME
+ )
+
+ gyroSensorRegistered = true
+ }
+
+ if (gyroSensor == null) Log.w(
+ TAG,
+ "SensorManager could not find a default TYPE_GYROSCOPE sensor"
+ )
+ }
+
+ /**
+ * Stops listening for the gyroscope events.
+ * (the rotation is also reset).
+ */
+ fun stop() {
+ if (gyroSensorRegistered) {
+ sensorManager.unregisterListener(sensorEventListener)
+ gyroSensorRegistered = false
+ }
+ }
+
+ /**
+ * Resets the rotation values.
+ */
+ fun resetValues() {
+ rotation = Vector2()
+ angles[0] = 0f
+ angles[1] = 0f
+ }
+
+ /**
+ * Updates the output rotation (mostly it is used the update and ease the rotation value based
+ * on the [Gyro2dController.GyroConfig.easingSpeed] value).
+ *
+ * @param deltaSeconds the time in seconds elapsed since the last time
+ * [Gyro2dController.update] was called.
+ */
+ fun update(deltaSeconds: Float) {
+ /*
+ * Ease if needed (specially to reduce movement variation which will allow us to use a
+ * smaller fps).
+ */
+ rotation = if (ease) {
+ Vector2(
+ MathUtils.lerp(rotation.x, angles[0], easingMul * deltaSeconds),
+ MathUtils.lerp(rotation.y, angles[1], easingMul * deltaSeconds)
+ )
+ } else {
+ Vector2(angles[0], angles[1])
+ }
+
+ isSettled = isCurrentlySettled()
+ isAlmostSettled = isNearlySettled()
+ }
+
+ /**
+ * Call it to change how the gyro sensor is interpreted. This function is specially important
+ * when the display is not being presented in its default orientation (by default
+ * [Gyro2dController] will read the gyro values as if the device is in its default orientation).
+ *
+ * @param displayRotation The current display rotation. It can only be one of the following
+ * values: [Surface.ROTATION_0], [Surface.ROTATION_90], [Surface.ROTATION_180] or
+ * [Surface.ROTATION_270].
+ */
+ fun setDisplayRotation(displayRotation: Int) {
+ if (displayRotation !in displayRotationValues) {
+ throwDisplayRotationException(displayRotation)
+ }
+
+ this.displayRotation = displayRotation
+ }
+
+ /**
+ * Determine whether the gyro orientation is considered to be "settled" and unexpected to change
+ * in the near future. If a non-null [referenceRotation] is provided, then the gyro also won't
+ * be considered "settled" if the current or (expected) future state is too far from the
+ * reference. For example, clients can provide the value of our [rotation] at the time that they
+ * last presented that state to the user, to determine if that reference value is now too far
+ * behind.
+ */
+ fun isCurrentlySettled(referenceRotation: Vector2 = rotation): Boolean =
+ (getErrorDistance(referenceRotation) < config.settledThreshold)
+
+ /** Like [isCurrentlySettled], but with a wider tolerance. */
+ fun isNearlySettled(referenceRotation: Vector2 = rotation): Boolean =
+ (getErrorDistance(referenceRotation) < config.almostSettledThreshold)
+
+ /**
+ * Determine the amount of recent-or-expected angular rotation given our sensor values and
+ * easing state as documented for [isCurrentlySettled]. This is a signal for how frequently we
+ * should update based on gyro activity.
+ */
+ private fun getErrorDistance(referenceRotation: Vector2 = rotation): Float {
+ val targetOrientation = Vector2(angles[0], angles[1])
+
+ // Have we now updated to a state far from the last one we presented?
+ val distanceFromReferenceToCurrent = referenceRotation.distanceTo(rotation)
+
+ // Did our last frame have a long way to go to get to our current target?
+ val distanceFromReferenceToTarget = referenceRotation.distanceTo(targetOrientation)
+
+ // Are we *currently* far from the target? Note we may often expect the current value to be
+ // somewhere *between* the target and the last-rendered rotation as each frame gets closer
+ // to the target, but it's actually possible for the target to move between updates such
+ // that the "current" value falls outside of the range.
+ val distanceFromCurrentToTarget = rotation.distanceTo(targetOrientation)
+
+ return maxOf(
+ distanceFromReferenceToCurrent,
+ distanceFromReferenceToTarget,
+ distanceFromCurrentToTarget
+ )
+ }
+
+ private fun updateGyroRotation(event: SensorEvent) {
+ if (timestamp != 0f) {
+ val dT = (event.timestamp - timestamp) * NANOS_TO_S
+ // Adjust based on display rotation.
+ var axisX: Float = when (displayRotation) {
+ Surface.ROTATION_90 -> -event.values[1]
+ Surface.ROTATION_180 -> -event.values[0]
+ Surface.ROTATION_270 -> event.values[1]
+ else -> event.values[0]
+ }
+
+ var axisY: Float = when (displayRotation) {
+ Surface.ROTATION_90 -> event.values[0]
+ Surface.ROTATION_180 -> -event.values[1]
+ Surface.ROTATION_270 -> -event.values[0]
+ else -> event.values[1]
+ }
+
+ axisX *= RAD_TO_DEG * dT * config.intensity
+ axisY *= RAD_TO_DEG * dT * config.intensity
+
+ angles[0] = updateAngle(angles[0], axisX, config.maxAngleRotation.x)
+ angles[1] = updateAngle(angles[1], axisY, config.maxAngleRotation.y)
+ }
+
+ timestamp = event.timestamp.toFloat()
+ }
+
+ private fun updateAngle(angle: Float, deltaAngle: Float, maxAngle: Float): Float {
+ // Adds incremental value.
+ var angleCombined = angle + deltaAngle
+
+ // Clamps to maxAngleRotation x and maxAngleRotation y.
+ if (abs(angleCombined) > maxAngle) angleCombined = maxAngle * sign(angleCombined)
+
+ // Re-centers to origin if needed.
+ if (recenter) angleCombined *= recenterMul
+
+ return angleCombined
+ }
+
+ private fun throwDisplayRotationException(displayRotation: Int) {
+ throw IllegalArgumentException(
+ "setDisplayRotation only accepts Surface.ROTATION_0 (0), " +
+ "Surface.ROTATION_90 (1), Surface.ROTATION_180 (2) or \n" +
+ "[Surface.ROTATION_270 (3); Instead the value was $displayRotation."
+ )
+ }
+
+ private fun onNewConfig() {
+ recenter = config.recenterSpeed > 0f
+ recenterMul = 1f - MathUtils.clamp(config.recenterSpeed, 0f, 1f)
+ ease = config.easingSpeed < 1f
+ easingMul = MathUtils.clamp(config.easingSpeed, 0f, 1f) * BASE_FPS
+ }
+
+ /**
+ * Class that contains the config attributes for the gyro.
+ */
+ data class GyroConfig(
+ /**
+ * Adjusts the maximum output rotation (in degrees) for both positive and negative angles,
+ * for each direction (x for the rotation around the X axis, y for the rotation
+ * around the Y axis).
+ *
+ * i.e. if [maxAngleRotation] = (2, 4), the output rotation would be inside
+ * ([-2º, 2º], [-4º, 4º]).
+ */
+ val maxAngleRotation: Vector2 = Vector2(2f),
+
+ /**
+ * Adjusts how much movement we need to apply to the device to make it rotate. This value
+ * multiplies the original rotation values; thus if the value is < 1f, we would need to
+ * rotate more the device than the actual rotation; if it is 1 it would be the default
+ * phone rotation; if it is > 1f it will magnify the rotation.
+ */
+ val intensity: Float = 0.05f,
+
+ /**
+ * Adjusts how much the end rotation is eased. This value can be from range [0, 1].
+ * - When 0, the eased value won't change.
+ * - when 1, there isn't any easing.
+ */
+ val easingSpeed: Float = 0.8f,
+
+ /**
+ * How fast we want the rotation to recenter besides the gyro values.
+ * - When 0, it doesn't recenter.
+ * - when 1, it would make the rotation be in the center all the time.
+ */
+ val recenterSpeed: Float = 0f,
+
+ /**
+ * The minimum frame-over-frame delta required between gyroscope readings
+ * (by L2 distance in the rotation angles) in order to consider the device to be settled
+ * in a given animation frame.
+ */
+ val settledThreshold: Float = 0.0005f,
+
+ /**
+ * The minimum frame-over-frame delta required between the target orientation and the
+ * current orientation, in order to define if the orientation is almost settled.
+ */
+ val almostSettledThreshold: Float = 0.01f
+ )
+}
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/interaction/HingeController.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/interaction/HingeController.kt
new file mode 100644
index 0000000..57313e8
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/interaction/HingeController.kt
@@ -0,0 +1,160 @@
+/*
+ * 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.google.android.torus.utils.interaction
+
+import android.content.Context
+import android.content.Context.SENSOR_SERVICE
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.os.Build
+import androidx.annotation.FloatRange
+import androidx.annotation.RequiresApi
+import com.google.android.torus.utils.animation.EasingUtils
+import kotlin.math.abs
+
+/**
+ * Class that listens to changes to the device hinge angle.
+ * This class can only be used on Android R or above
+ */
+@RequiresApi(Build.VERSION_CODES.R)
+class HingeController(context: Context) {
+
+ companion object {
+
+ const val DEFAULT_EASING = 0.36f
+ const val ALMOST_SETTLED_THRESHOLD = 1f
+ const val SETTLED_THRESHOLD = .01f
+ }
+
+ private var hingeAngleSensorValue: Float = 0f
+ var hingeAngle: Float = 0f
+ private set
+
+ /**
+ * Adjusts how much the end hinge angle is eased. This value can be from range [0, 1].
+ * - When 0, the eased value won't change.
+ * - when 1, there isn't any easing.
+ */
+ @FloatRange(from = 0.0, to = 1.0)
+ var hingeEasingSpeed: Float = DEFAULT_EASING
+
+ /**
+ * Defines if hinge angle is considered to be settled.
+ */
+ var isSettled: Boolean = false
+ private set
+
+ /**
+ * Defines whether the hinge animation is almost settled.
+ */
+ var isAlmostSettled: Boolean = false
+ private set
+
+ private var sensorManager: SensorManager =
+ context.getSystemService(SENSOR_SERVICE) as SensorManager
+ private var hingeAngleSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE)
+ private var sensorListener: SensorEventListener? = object : SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ hingeAngleSensorValue = event.values[0]
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
+ }
+ }
+
+ /**
+ * Starts listening for hinge events.
+ */
+ fun start() {
+ hingeAngleSensor?.let {
+ sensorManager.registerListener(
+ sensorListener,
+ it,
+ SensorManager.SENSOR_DELAY_NORMAL
+ )
+ }
+ }
+
+ /**
+ * Stops listening for hinge events.
+ */
+ fun stop() {
+ hingeAngleSensor?.let {
+ sensorManager.unregisterListener(sensorListener, it)
+ }
+ }
+
+ /**
+ * Updates the output hinge angle using easing settings.
+ *
+ * @param deltaSeconds the time in seconds elapsed since the last time
+ * [HingeController.update] was called.
+ */
+ fun update(deltaSeconds: Float) {
+ /*
+ * Ease if needed (specially to reduce movement variation which will allow us to use a
+ * smaller fps).
+ */
+ hingeAngle = EasingUtils.calculateEasing(
+ hingeAngle,
+ hingeAngleSensorValue,
+ hingeEasingSpeed,
+ deltaSeconds
+ )
+
+ isSettled = isCurrentlySettled()
+ isAlmostSettled = isNearlySettled()
+ }
+
+ /**
+ * Determine the amount of recent-or-expected angular rotation given our sensor values and
+ * easing state as documented for [isCurrentlySettled]. This is a signal for how frequently we
+ * should update based on hinge activity.
+ */
+ private fun getErrorDistance(referenceHingeAngle: Float = hingeAngle): Float {
+ // Have we now updated to a state far from the last one we presented?
+ val distanceFromReferenceToCurrent = abs(referenceHingeAngle - hingeAngle)
+
+ // Did our last frame have a long way to go to get to our current target?
+ val distanceFromReferenceToTarget = abs(referenceHingeAngle - hingeAngleSensorValue)
+
+ // Are we *currently* far from the target? Note we may often expect the current value to be
+ // somewhere *between* the target and the last-rendered angle as each frame gets closer
+ // to the target, but it's actually possible for the target to move between updates such
+ // that the "current" value falls outside of the range.
+ val distanceFromCurrentToTarget = abs(hingeAngleSensorValue - hingeAngle)
+
+ return maxOf(
+ distanceFromReferenceToCurrent,
+ distanceFromReferenceToTarget,
+ distanceFromCurrentToTarget
+ )
+ }
+
+ /**
+ * Determine whether the hinge angle is considered to be "settled" and unexpected to change
+ * in the near future.
+ */
+ fun isCurrentlySettled(referenceHingeAngle: Float = hingeAngle): Boolean =
+ (getErrorDistance(referenceHingeAngle) < SETTLED_THRESHOLD)
+
+ /** Like [isCurrentlySettled], but with a wider tolerance. */
+ fun isNearlySettled(referenceHingeAngle: Float = hingeAngle): Boolean =
+ (getErrorDistance(referenceHingeAngle) < ALMOST_SETTLED_THRESHOLD)
+}
diff --git a/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/wallpaper/WallpaperUtils.kt b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/wallpaper/WallpaperUtils.kt
new file mode 100644
index 0000000..012768d
--- /dev/null
+++ b/toruslib/torus-utils/src/main/java/com/google/android/torus/utils/wallpaper/WallpaperUtils.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.google.android.torus.utils.wallpaper
+
+import android.app.WallpaperColors
+import android.graphics.Color
+import android.os.Build
+
+/** Creates some utils for wallpapers. */
+object WallpaperUtils {
+ /**
+ * Returns a [WallpaperColors] with the color provided and launcher using black text
+ * if [darkText] is true.
+ *
+ * @param primaryColor Primary color.
+ * @param secondaryColor Secondary color.
+ * @param tertiaryColor Tertiary color.
+ * @param darkText If the launcher should use dark text (It won't work for SDK < 31 (S)).
+ *
+ * @return the wallpaper color with the color hints (if possible).
+ */
+ @JvmStatic
+ fun getWallpaperColors(
+ primaryColor: Color,
+ secondaryColor: Color,
+ tertiaryColor: Color,
+ darkText: Boolean = false
+ ): WallpaperColors {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && darkText) {
+ WallpaperColors(
+ primaryColor,
+ secondaryColor,
+ tertiaryColor,
+ WallpaperColors.HINT_SUPPORTS_DARK_TEXT or WallpaperColors.HINT_SUPPORTS_DARK_THEME
+ )
+ } else {
+ WallpaperColors(
+ primaryColor,
+ secondaryColor,
+ tertiaryColor,
+ )
+ }
+ }
+}
diff --git a/toruslib/torus-wallpaper-settings/build.gradle b/toruslib/torus-wallpaper-settings/build.gradle
new file mode 100644
index 0000000..e6e0ee4
--- /dev/null
+++ b/toruslib/torus-wallpaper-settings/build.gradle
@@ -0,0 +1,19 @@
+// 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.
+
+dependencies {
+ implementation project(':torus-utils')
+ implementation "androidx.slice:slice-builders:$versions.androidXLib"
+ implementation "androidx.slice:slice-core:$versions.androidXLib"
+} \ No newline at end of file
diff --git a/toruslib/torus-wallpaper-settings/src/main/AndroidManifest.xml b/toruslib/torus-wallpaper-settings/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3c68a96
--- /dev/null
+++ b/toruslib/torus-wallpaper-settings/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+<?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 package="com.google.android.torus" />
diff --git a/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/BaseSliceConfigProvider.kt b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/BaseSliceConfigProvider.kt
new file mode 100755
index 0000000..5b9aa11
--- /dev/null
+++ b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/BaseSliceConfigProvider.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.google.android.torus.settings.inlinecontrol
+
+import android.Manifest
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener
+import android.net.Uri
+import androidx.slice.SliceProvider
+import androidx.slice.builders.ListBuilder
+import com.google.android.torus.settings.storage.CustomizedSharedPreferences
+
+/**
+ * BaseSliceConfigProvider is the base class for configuration wallpaper Slices.
+ * It can be extended and overridden the [uri], [onBindSlice], and [onConfigChange] for providing
+ * different Slice UI.
+ */
+abstract class BaseSliceConfigProvider : SliceProvider(Manifest.permission.BIND_WALLPAPER) {
+ companion object {
+ const val EXTRA_WALLPAPER_URI = "extra_wallpaper_uri"
+ }
+
+ protected abstract val sharedPrefKey: String
+ abstract val uriStringId: Int
+ abstract fun onConfigChange()
+
+ protected lateinit var preferences: CustomizedSharedPreferences
+ protected lateinit var listBuilder: ListBuilder
+ private lateinit var sharedPreferenceListener: OnSharedPreferenceChangeListener
+ private lateinit var uri: Uri
+
+ override fun onCreateSliceProvider(): Boolean {
+ context?.let { context ->
+ uri = Uri.parse(context.getString(uriStringId))
+ preferences = CustomizedSharedPreferences(context, sharedPrefKey)
+ sharedPreferenceListener = OnSharedPreferenceChangeListener { _, _ ->
+ preferences.load(true)
+ onConfigChange()
+ updateSlice()
+ }
+ preferences.register(sharedPreferenceListener)
+ preferences.load(true)
+ onConfigChange()
+ return true
+ } ?: return false
+ }
+
+ private fun updateSlice() {
+ context?.contentResolver?.notifyChange(uri, null)
+ }
+}
diff --git a/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/ColorChipsRowBuilder.kt b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/ColorChipsRowBuilder.kt
new file mode 100755
index 0000000..7f5cd29
--- /dev/null
+++ b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/ColorChipsRowBuilder.kt
@@ -0,0 +1,233 @@
+/*
+ * 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.google.android.torus.settings.inlinecontrol
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.text.TextUtils
+import androidx.annotation.ColorInt
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.core.content.res.ResourcesCompat
+import androidx.core.graphics.drawable.IconCompat
+import androidx.slice.builders.GridRowBuilder
+import androidx.slice.builders.GridRowBuilder.CellBuilder
+import androidx.slice.builders.ListBuilder
+import androidx.slice.builders.SliceAction
+import com.google.android.torus.R
+import com.google.android.torus.settings.inlinecontrol.BaseSliceConfigProvider.Companion.EXTRA_WALLPAPER_URI
+import kotlin.math.max
+
+/**
+ * ColorChipsRowBuilder is used to construct and hold Slice rows data and color configurations
+ * for wallpapers that are configurable using the provided params in {@link #create()} method
+ */
+object ColorChipsRowBuilder {
+ var ACTION_PRIMARY = ".action.PRIMARY"
+ var ACTION_SET_COLOR = ".action.SET_COLOR"
+ var EXTRA_COLOR_INDEX = "extra_color_index"
+ var DEFAULT_COLOR_INDEX = 0
+
+ @JvmOverloads
+ fun create(
+ context: Context,
+ colorOptions: Array<ColorOption>,
+ selectedItem: Int,
+ title: CharSequence? = null,
+ minSpaces: Int = 0, // disabled by default
+ wallpaperUriString: String? = "",
+ wallpaperName: String,
+ zoomOnSelection: Boolean
+ ): GridRowBuilder? {
+ val packageName = context.packageName
+ if (TextUtils.isEmpty(packageName)) return null
+
+ val res = context.resources ?: return null
+ val titleAdjusted = title ?: res.getText(R.string.color_chips_title)
+
+ val iconHeight = res.getDimensionPixelSize(R.dimen.slice_icon_height)
+ val iconWidth = res.getDimensionPixelSize(R.dimen.slice_icon_width)
+ val gridRowBuilder = GridRowBuilder()
+ val bmpEmpty = Bitmap.createBitmap(iconWidth, iconHeight, Bitmap.Config.ARGB_8888)
+ val emptyCell = CellBuilder()
+ .addImage(IconCompat.createWithBitmap(bmpEmpty), ListBuilder.SMALL_IMAGE)
+ val primaryAction = SliceAction.create(
+ PendingIntent.getBroadcast(
+ context, 0,
+ Intent("$packageName.$wallpaperName$ACTION_PRIMARY").setPackage(packageName)
+ .putExtra(EXTRA_COLOR_INDEX, 0),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ ),
+ IconCompat.createWithBitmap(bmpEmpty),
+ ListBuilder.SMALL_IMAGE, ""
+ )
+
+ gridRowBuilder.primaryAction = primaryAction
+
+ // Content description for this grid row.
+ gridRowBuilder.setContentDescription(titleAdjusted)
+
+ for (cellIndex in 0..max(colorOptions.size - 1, minSpaces - 1)) {
+ if (cellIndex < colorOptions.size) {
+ // Add color option
+ gridRowBuilder.addCell(
+ makeColorOption(
+ colorOptions,
+ cellIndex,
+ selectedItem,
+ zoomOnSelection,
+ res,
+ context,
+ packageName,
+ wallpaperUriString,
+ wallpaperName
+ )
+ )
+ } else {
+ // Add empty cell for unused spaces
+ gridRowBuilder.addCell(emptyCell)
+ }
+ }
+ return gridRowBuilder
+ }
+
+ private fun makeColorOption(
+ colorOptions: Array<ColorOption>,
+ cellIndex: Int,
+ selectedItem: Int,
+ zoomOnSelection: Boolean,
+ res: Resources,
+ context: Context,
+ packageName: String,
+ wallpaperUriString: String?,
+ wallpaperName: String
+ ) = CellBuilder()
+ .addImage(
+ getIcon(
+ option = colorOptions[cellIndex],
+ selected = cellIndex == selectedItem,
+ zoomOnSelection = zoomOnSelection,
+ res = res
+ ),
+ ListBuilder.SMALL_IMAGE
+ )
+ .setContentIntent(
+ PendingIntent.getBroadcast(
+ context, cellIndex,
+ Intent("$packageName.$wallpaperName$ACTION_SET_COLOR")
+ .setPackage(packageName)
+ .putExtra(
+ EXTRA_COLOR_INDEX,
+ cellIndex
+ ).putExtra(EXTRA_WALLPAPER_URI, wallpaperUriString),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ .setContentDescription(res.getText(colorOptions[cellIndex].description))
+
+ private fun getIcon(
+ option: ColorOption,
+ selected: Boolean,
+ zoomOnSelection: Boolean,
+ res: Resources
+ ): IconCompat {
+ val iconHeight = res.getDimensionPixelSize(R.dimen.slice_icon_height)
+ val iconWidth = res.getDimensionPixelSize(R.dimen.slice_icon_width)
+ val colorChipHeight = res.getDimensionPixelSize(R.dimen.color_chips_height)
+ val colorChipPenWidth = res.getDimensionPixelSize(R.dimen.color_chips_pen_width)
+ val iconOnSize = res.getDimensionPixelSize(R.dimen.torus_slice_vector_icon_on_size)
+ val iconOffSize = if (zoomOnSelection) {
+ res.getDimensionPixelSize(R.dimen.torus_slice_vector_icon_off_size)
+ } else {
+ res.getDimensionPixelSize(R.dimen.torus_slice_vector_icon_on_size)
+ }
+ val bmp = Bitmap.createBitmap(iconWidth, iconHeight, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bmp)
+ val paint = Paint()
+ if (option.drawable == -1) {
+ if (selected) {
+ paint.style = Paint.Style.FILL_AND_STROKE
+ } else {
+ paint.style = Paint.Style.STROKE
+ }
+ paint.strokeWidth = colorChipPenWidth.toFloat()
+ paint.color = option.value
+ paint.isAntiAlias = true
+ canvas.drawCircle(
+ (iconWidth / 2).toFloat(),
+ (iconHeight / 2).toFloat(),
+ ((colorChipHeight - colorChipPenWidth) / 2).toFloat(),
+ paint
+ )
+ } else {
+ val drawableSize = if (selected) iconOnSize else iconOffSize
+
+ val drawable = if (selected && option.drawableSelected != 1) {
+ ResourcesCompat.getDrawable(res, option.drawableSelected, null)
+ } else {
+ ResourcesCompat.getDrawable(res, option.drawable, null)
+ }
+ drawable?.setBounds(0, 0, drawableSize, drawableSize)
+ canvas.translate(
+ ((iconWidth - drawableSize) / 2).toFloat(),
+ ((iconHeight - drawableSize) / 2).toFloat()
+ )
+ drawable?.draw(canvas)
+ }
+ return IconCompat.createWithBitmap(bmp)
+ }
+
+ /**
+ * A color option to present in an inline control menu
+ */
+ open class ColorOption {
+ @ColorInt
+ var value: Int
+
+ @StringRes
+ val description: Int
+
+ @DrawableRes
+ val drawable: Int
+
+ @DrawableRes
+ val drawableSelected: Int
+
+ constructor(
+ @StringRes description: Int,
+ @DrawableRes drawable: Int,
+ @DrawableRes drawableSelected: Int
+ ) {
+ this.description = description
+ this.value = -1
+ this.drawable = drawable
+ this.drawableSelected = drawableSelected
+ }
+
+ constructor(@StringRes description: Int, @ColorInt value: Int) {
+ this.description = description
+ this.value = value
+ drawable = -1
+ drawableSelected = -1
+ }
+ }
+}
diff --git a/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/InputRangeRowBuilder.kt b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/InputRangeRowBuilder.kt
new file mode 100644
index 0000000..db9443e
--- /dev/null
+++ b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/InputRangeRowBuilder.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.google.android.torus.settings.inlinecontrol
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.slice.builders.ListBuilder
+import com.google.android.torus.settings.inlinecontrol.BaseSliceConfigProvider.Companion.EXTRA_WALLPAPER_URI
+
+/**
+ * InputRangeRowBuilder is used to construct and hold Slice rows data, color options,
+ * and range value for wallpaper configurations that have an input range slider
+ */
+object InputRangeRowBuilder {
+ var ACTION_SET_RANGE = ".action.SET_RANGE"
+ var DEFAULT_RANGE_VALUE = 40
+
+ fun createInputRangeBuilder(
+ context: Context,
+ rangeIntent: Intent,
+ currentRangeValue: Int,
+ minRangeValue: Int,
+ maxRangeValue: Int,
+ title: String
+ ): ListBuilder.InputRangeBuilder {
+ val rangePendingIntent: PendingIntent = PendingIntent.getBroadcast(
+ context,
+ 0,
+ rangeIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+
+ return ListBuilder.InputRangeBuilder()
+ .setTitle(title)
+ .setInputAction(rangePendingIntent)
+ .setMin(minRangeValue)
+ .setMax(maxRangeValue)
+ .setValue(currentRangeValue)
+ }
+
+ fun getInputRangeRowIntent(
+ context: Context,
+ wallpaper: Uri,
+ wallpaperName: String
+ ): Intent {
+ val packageName = context.packageName
+ return Intent("$packageName.$wallpaperName$ACTION_SET_RANGE")
+ .setPackage(packageName)
+ .putExtra(
+ EXTRA_WALLPAPER_URI,
+ wallpaper.toString()
+ )
+ }
+}
diff --git a/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/SingleSelectionRowConfigProvider.kt b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/SingleSelectionRowConfigProvider.kt
new file mode 100644
index 0000000..c3cccc7
--- /dev/null
+++ b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/SingleSelectionRowConfigProvider.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.google.android.torus.settings.inlinecontrol
+
+import android.content.Context
+import android.net.Uri
+import androidx.annotation.StringRes
+import androidx.slice.Slice
+import androidx.slice.builders.ListBuilder
+import com.google.android.torus.R
+
+/**
+ * SingleSelectionRowConfigProvider provides the most common used Slice UI among the wallpapers.
+ * It will get ColorOptions from wallpaper engines and build a row of color chips for user
+ * to choose. It can be extended and overridden the onBindSlice for providing different Slice UI.
+ */
+open class SingleSelectionRowConfigProvider(
+ val colorOptions: Array<ColorChipsRowBuilder.ColorOption>?,
+ override val sharedPrefKey: String,
+ @StringRes override val uriStringId: Int,
+ @StringRes open val wallpaperNameResId: Int,
+ @StringRes val sliceTitleStringId: Int = R.string.color_chips_title,
+ val minSpaces: Int = MIN_SPACES,
+ private val zoomOnSelection: Boolean = false
+) : BaseSliceConfigProvider() {
+ companion object {
+ private const val MIN_SPACES = 5
+ const val PREF_COLOR_INDEX = "COlOR_INDEX"
+ }
+
+ protected var colorIndex = 0
+
+ override fun onBindSlice(sliceUri: Uri): Slice? {
+ context?.let { context ->
+ val title: CharSequence = context.getString(wallpaperNameResId)
+ val subtitleResId: Int =
+ colorOptions?.get(colorIndex)?.description ?: sliceTitleStringId
+ listBuilder = ListBuilder(context, sliceUri, ListBuilder.INFINITY)
+ buildColorChips(
+ context,
+ title,
+ context.getString(subtitleResId),
+ sliceUri
+ )
+
+ return listBuilder.build()
+ } ?: run {
+ return null
+ }
+ }
+
+ override fun onConfigChange() {
+ colorIndex = preferences.getInt(
+ PREF_COLOR_INDEX,
+ ColorChipsRowBuilder.DEFAULT_COLOR_INDEX
+ )
+ }
+
+ protected fun buildColorChips(
+ context: Context,
+ title: CharSequence,
+ subtitle: CharSequence,
+ sliceUri: Uri
+ ) {
+ val gridRowBuilder = colorOptions?.let {
+ ColorChipsRowBuilder.create(
+ context = context,
+ colorOptions = it,
+ selectedItem = colorIndex,
+ title = title,
+ minSpaces = minSpaces,
+ wallpaperUriString = sliceUri.toString(),
+ wallpaperName = context.getString(wallpaperNameResId),
+ zoomOnSelection = zoomOnSelection
+ )
+ }
+ gridRowBuilder?.let {
+ listBuilder
+ .setHeader(
+ ListBuilder.HeaderBuilder()
+ .setTitle(title)
+ .setSubtitle(subtitle)
+ )
+ .addGridRow(it)
+ }
+ }
+}
diff --git a/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/SliceConfigController.kt b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/SliceConfigController.kt
new file mode 100644
index 0000000..b80f92b
--- /dev/null
+++ b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/inlinecontrol/SliceConfigController.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.google.android.torus.settings.inlinecontrol
+
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import com.google.android.torus.utils.broadcast.BroadcastEventController
+
+/**
+ * SliceConfigController registers a BroadcastReceiver that listens to
+ * the intent filter provided by an implementation of [BaseSliceConfigProvider].
+ * Forwards received broadcasts to be handled by a [SliceConfigController.SliceConfigListener].
+ */
+class SliceConfigController(
+ context: Context,
+ private val sliceIntentFilter: IntentFilter,
+ private val sliceConfigListener: SliceConfigListener
+) : BroadcastEventController(context) {
+
+ override fun initResources(): Boolean {
+ return true
+ }
+
+ override fun onBroadcastReceived(context: Context, intent: Intent, action: String) {
+ intent?.let { sliceConfigListener.onSliceConfig(it) }
+ }
+
+ override fun onRegister(fire: Boolean): IntentFilter {
+ return sliceIntentFilter
+ }
+
+ override fun onUnregister() {}
+
+ interface SliceConfigListener {
+ fun onSliceConfig(intent: Intent)
+ }
+}
diff --git a/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/storage/CustomizedSharedPreferences.kt b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/storage/CustomizedSharedPreferences.kt
new file mode 100755
index 0000000..f832a1d
--- /dev/null
+++ b/toruslib/torus-wallpaper-settings/src/main/java/com/google/android/torus/settings/storage/CustomizedSharedPreferences.kt
@@ -0,0 +1,250 @@
+/*
+ * 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.google.android.torus.settings.storage
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener
+import android.os.Bundle
+import android.util.Log
+import androidx.annotation.GuardedBy
+import org.json.JSONException
+import org.json.JSONObject
+
+/**
+ * This class provide APIs to save and load a bundle of values that may map to
+ * customization (inline control) settings. Each instance will have two set of values, one for
+ * normal and one for preview mode.
+ * Currently supported value types are boolean, integer, float and string.
+ */
+class CustomizedSharedPreferences(context: Context, name: String) {
+ companion object {
+ private const val TAG = "CustomizedSharedPref"
+ private const val DEBUG = false
+ private const val SUFFIX_PREVIEW = "_preview"
+ const val PREF_FILENAME = "inline_control"
+
+ /**
+ * Convert a JSONObject to Bundle.
+ *
+ * @param json a JSONObject
+ * @return a Bundle object
+ */
+ private fun jsonToBundle(json: JSONObject): Bundle {
+ val bundle = Bundle()
+ val it = json.keys()
+ while (it.hasNext()) {
+ val key = it.next()
+ try {
+ when (val obj = json[key]) {
+ is Boolean -> {
+ bundle.putBoolean(key, obj)
+ }
+ is Int -> {
+ bundle.putInt(key, obj)
+ }
+ is Double -> {
+ // Only support Float, but JSONObject save Float as Double,
+ // So we convert Double to Float here.
+ bundle.putFloat(key, obj.toFloat())
+ }
+ is CharSequence -> {
+ bundle.putString(key, obj as String)
+ }
+ }
+ } catch (e: JSONException) {
+ Log.w(TAG, "JSONObject get fail for $key")
+ }
+ }
+ return bundle
+ }
+
+ /**
+ * Convert a Bundle to JSONObject.
+ *
+ * @param bundle a Bundle to convert
+ * @return a JsonObject object
+ */
+ private fun bundleToJson(bundle: Bundle): JSONObject {
+ val jsonObj = JSONObject()
+ for (key in bundle.keySet()) {
+ try {
+ jsonObj.put(key, bundle[key])
+ } catch (e: JSONException) {
+ if (DEBUG) {
+ e.printStackTrace()
+ }
+ }
+ }
+
+ return jsonObj
+ }
+ }
+
+ private val preferences: SharedPreferences
+ private val wallpaperConfigName: String
+ private val lock = Any()
+
+ @GuardedBy("lock")
+ private var bundle = Bundle()
+
+ /**
+ * Create an instance with a config name, the name should be unique among all wallpapers.
+ */
+ init {
+ // Use createDeviceProtectedStorageContext to support direct boot.
+ // The shared preference file will be saved under:
+ // /data/user_de/0/com.google.pixel.livewallpapers/shared_prefs
+ val secureContext = context.createDeviceProtectedStorageContext()
+ preferences = secureContext.getSharedPreferences(PREF_FILENAME, Context.MODE_MULTI_PROCESS)
+ wallpaperConfigName = name
+ }
+
+ /**
+ * Save the bundle keys and values that have put in this object.
+ *
+ * @param isPreview Select save keys and values as normal set or preview set.
+ */
+ fun save(isPreview: Boolean) {
+ lateinit var jsonObj: JSONObject
+ synchronized(lock) { jsonObj = bundleToJson(bundle) }
+ val editor = preferences.edit()
+ val keySuffix = if (isPreview) SUFFIX_PREVIEW else ""
+ editor.putString(wallpaperConfigName + keySuffix, jsonObj.toString())
+ editor.apply()
+ }
+
+ /**
+ * Load the bundle keys and values that stored in shared preferences file.
+ *
+ * @param isPreview Select load keys and values from normal set or preview set.
+ */
+ fun load(isPreview: Boolean) {
+ val keySuffix = if (isPreview) SUFFIX_PREVIEW else ""
+ val jsonStr = preferences.getString(wallpaperConfigName + keySuffix, "")
+ try {
+ val json = JSONObject(jsonStr)
+ synchronized(lock) { bundle = jsonToBundle(json) }
+ } catch (e: JSONException) {
+ if (DEBUG) {
+ Log.w(TAG, "JSONObject creation failed for \n$jsonStr")
+ e.printStackTrace()
+ }
+ }
+ }
+
+ /**
+ * Inserts a boolean value into the bundle in CustomizedSharedPreferences, replacing any
+ * existing value for the given key.
+ *
+ * @param key a String key
+ * @param value a boolean
+ */
+ fun putBoolean(key: String, value: Boolean) {
+ synchronized(lock) { bundle.putBoolean(key, value) }
+ }
+
+ /**
+ * Inserts an int value into the bundle in CustomizedSharedPreferences, replacing any existing
+ * value for the given key.
+ *
+ * @param key a String key
+ * @param value an int
+ */
+ fun putInt(key: String, value: Int) {
+ synchronized(lock) { bundle.putInt(key, value) }
+ }
+
+ /**
+ * Inserts a float value into the bundle in CustomizedSharedPreferences, replacing any existing
+ * value for the given key.
+ *
+ * @param key a String key
+ * @param value a float
+ */
+ fun putFloat(key: String, value: Float) {
+ synchronized(lock) { bundle.putFloat(key, value) }
+ }
+
+ /**
+ * Inserts a String value into the bundle in CustomizedSharedPreferences, replacing any existing
+ * value for the given key.
+ *
+ * @param key a String key
+ * @param value a String
+ */
+ fun putString(key: String, value: String) {
+ synchronized(lock) { bundle.putString(key, value) }
+ }
+
+ /**
+ * Return the value associated with the given key from currently loaded shared preference set,
+ * or defaultValue if no mapping of the desired value of type Boolean exists for the given key.
+ *
+ * @param key a String key
+ * @param defaultValue Value to return if key does not exist
+ */
+ fun getBoolean(key: String, defaultValue: Boolean): Boolean {
+ synchronized(lock) { return bundle.getBoolean(key, defaultValue) }
+ }
+
+ /**
+ * Return the value associated with the given key from currently loaded shared preference set,
+ * or defaultValue if no mapping of the desired value of type Int exists for the given key.
+ *
+ * @param key a String key
+ * @param defaultValue Value to return if key does not exist
+ */
+ fun getInt(key: String, defaultValue: Int): Int {
+ synchronized(lock) { return bundle.getInt(key, defaultValue) }
+ }
+
+ /**
+ * Return the value associated with the given key from currently loaded shared preference set,
+ * or defaultValue if no mapping of the desired value of type Float exists for the given key.
+ *
+ * @param key a String key
+ * @param defaultValue Value to return if key does not exist
+ */
+ fun getFloat(key: String, defaultValue: Float): Float {
+ synchronized(lock) { return bundle.getFloat(key, defaultValue) }
+ }
+
+ /**
+ * Return the value associated with the given key from currently loaded shared preference set,
+ * or defaultValue if no mapping of the desired value of type String exists for the given key.
+ *
+ * @param key a String key
+ * @param defaultValue Value to return if key does not exist
+ */
+ fun getString(key: String, defaultValue: String?): String {
+ synchronized(lock) { return bundle.getString(key, defaultValue) }
+ }
+
+ /**
+ * Remove any entry with the given key.
+ *
+ * @param key a String key
+ */
+ fun remove(key: String) {
+ synchronized(lock) { bundle.remove(key) }
+ }
+
+ fun register(listener: OnSharedPreferenceChangeListener?) {
+ preferences.registerOnSharedPreferenceChangeListener(listener)
+ }
+}
diff --git a/toruslib/torus-wallpaper-settings/src/main/res/values/dimens.xml b/toruslib/torus-wallpaper-settings/src/main/res/values/dimens.xml
new file mode 100755
index 0000000..9aa1416
--- /dev/null
+++ b/toruslib/torus-wallpaper-settings/src/main/res/values/dimens.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Dimensions for the color chips. -->
+ <dimen name="color_chips_height">24dp</dimen>
+ <dimen name="slice_icon_height">48dp</dimen>
+ <dimen name="slice_icon_width">48dp</dimen>
+ <dimen name="color_chips_pen_width">4dp</dimen>
+
+ <dimen name="torus_slice_vector_icon_on_size">48dp</dimen>
+ <dimen name="torus_slice_vector_icon_off_size">24dp</dimen>
+</resources>
diff --git a/toruslib/torus-wallpaper-settings/src/main/res/values/strings.xml b/toruslib/torus-wallpaper-settings/src/main/res/values/strings.xml
new file mode 100644
index 0000000..427ade5
--- /dev/null
+++ b/toruslib/torus-wallpaper-settings/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<resources>
+ <!-- Title for live wallpaper setting page that lets the user choose color. -->
+ <string name="color_chips_title">Choose color</string>
+</resources> \ No newline at end of file
diff --git a/tracinglib/Android.bp b/tracinglib/Android.bp
new file mode 100644
index 0000000..1521c01
--- /dev/null
+++ b/tracinglib/Android.bp
@@ -0,0 +1,28 @@
+// 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 {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_library {
+ name: "tracinglib",
+ static_libs: [
+ "kotlinx_coroutines_android",
+ ],
+ srcs: [
+ "src/**/*.kt"
+ ],
+ kotlincflags: ["-Xjvm-default=all"],
+}
diff --git a/tracinglib/src/com/android/app/tracing/FlowTracing.kt b/tracinglib/src/com/android/app/tracing/FlowTracing.kt
new file mode 100644
index 0000000..d6fe63e
--- /dev/null
+++ b/tracinglib/src/com/android/app/tracing/FlowTracing.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.tracing
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.onEach
+
+/** Utilities to trace Flows */
+object FlowTracing {
+
+ /** Logs each flow element to a trace. */
+ inline fun <T> Flow<T>.traceEach(
+ flowName: String,
+ logcat: Boolean = false,
+ crossinline valueToString: (T) -> String = { it.toString() }
+ ): Flow<T> {
+ val stateLogger = TraceStateLogger(flowName, logcat = logcat)
+ return onEach { stateLogger.log(valueToString(it)) }
+ }
+}
diff --git a/tracinglib/src/com/android/app/tracing/ListenersTracing.kt b/tracinglib/src/com/android/app/tracing/ListenersTracing.kt
new file mode 100644
index 0000000..d73ca07
--- /dev/null
+++ b/tracinglib/src/com/android/app/tracing/ListenersTracing.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.tracing
+
+/** Utilities to trace automatically computations happening for each element of a list. */
+object ListenersTracing {
+
+ /**
+ * Like [forEach], but outputs a trace for each element.
+ *
+ * The ideal usage of this is to debug what's taking long in a list of Listeners. For example:
+ * ```
+ * listeners.forEach { it.dispatch(state) }
+ * ```
+ *
+ * often it's tricky to udnerstand which listener is causing delays. This can be used instead to
+ * log how much each listener is taking:
+ * ```
+ * listeners.forEachTraced(TAG) { it.dispatch(state) }
+ * ```
+ */
+ inline fun <T : Any> List<T>.forEachTraced(tag: String = "", f: (T) -> Unit) {
+ forEach { traceSection({ "$tag#${it::javaClass.get().name}" }) { f(it) } }
+ }
+}
diff --git a/tracinglib/src/com/android/app/tracing/TraceContextElement.kt b/tracinglib/src/com/android/app/tracing/TraceContextElement.kt
new file mode 100644
index 0000000..2c952e1
--- /dev/null
+++ b/tracinglib/src/com/android/app/tracing/TraceContextElement.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.tracing
+
+import com.android.app.tracing.TraceUtils.Companion.instant
+import com.android.app.tracing.TraceUtils.Companion.traceCoroutine
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.CopyableThreadContextElement
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+
+/**
+ * Used for safely persisting [TraceData] state when coroutines are suspended and resumed.
+ *
+ * This is internal machinery for [traceCoroutine]. It cannot be made `internal` or `private`
+ * because [traceCoroutine] is a Public-API inline function.
+ *
+ * @see traceCoroutine
+ */
+@OptIn(DelicateCoroutinesApi::class)
+@ExperimentalCoroutinesApi
+class TraceContextElement(private val traceData: TraceData = TraceData()) :
+ CopyableThreadContextElement<TraceData?> {
+
+ companion object Key : CoroutineContext.Key<TraceContextElement>
+
+ override val key: CoroutineContext.Key<TraceContextElement> = Key
+
+ @OptIn(ExperimentalStdlibApi::class)
+ override fun updateThreadContext(context: CoroutineContext): TraceData? {
+ val oldState = threadLocalTrace.get()
+ oldState?.endAllOnThread()
+ threadLocalTrace.set(traceData)
+ instant("resuming ${context[CoroutineDispatcher]}")
+ traceData.beginAllOnThread()
+ return oldState
+ }
+
+ @OptIn(ExperimentalStdlibApi::class)
+ override fun restoreThreadContext(context: CoroutineContext, oldState: TraceData?) {
+ instant("suspending ${context[CoroutineDispatcher]}")
+ traceData.endAllOnThread()
+ threadLocalTrace.set(oldState)
+ oldState?.beginAllOnThread()
+ }
+
+ override fun copyForChild(): CopyableThreadContextElement<TraceData?> {
+ return TraceContextElement(traceData.copy())
+ }
+
+ override fun mergeForChild(overwritingElement: CoroutineContext.Element): CoroutineContext {
+ return TraceContextElement(traceData.copy())
+ }
+}
diff --git a/tracinglib/src/com/android/app/tracing/TraceData.kt b/tracinglib/src/com/android/app/tracing/TraceData.kt
new file mode 100644
index 0000000..f014eab
--- /dev/null
+++ b/tracinglib/src/com/android/app/tracing/TraceData.kt
@@ -0,0 +1,122 @@
+/*
+ * 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.tracing
+
+import android.os.Build
+import android.util.Log
+import com.android.app.tracing.TraceUtils.Companion.beginSlice
+import com.android.app.tracing.TraceUtils.Companion.endSlice
+import com.android.app.tracing.TraceUtils.Companion.traceCoroutine
+import kotlin.random.Random
+
+/**
+ * Used for giving each thread a unique [TraceData] for thread-local storage. `null` by default.
+ * [threadLocalTrace] can only be used when it is paired with a [TraceContextElement].
+ *
+ * This ThreadLocal will be `null` if either 1) we aren't in a coroutine, or 2) the coroutine we are
+ * in does not have a [TraceContextElement].
+ *
+ * This is internal machinery for [traceCoroutine]. It cannot be made `internal` or `private`
+ * because [traceCoroutine] is a Public-API inline function.
+ *
+ * @see traceCoroutine
+ */
+val threadLocalTrace = ThreadLocal<TraceData?>()
+
+/**
+ * Used for storing trace sections so that they can be added and removed from the currently running
+ * thread when the coroutine is suspended and resumed.
+ *
+ * This is internal machinery for [traceCoroutine]. It cannot be made `internal` or `private`
+ * because [traceCoroutine] is a Public-API inline function.
+ *
+ * @see traceCoroutine
+ */
+class TraceData {
+ private var slices = mutableListOf<TraceSection>()
+
+ /** Adds current trace slices back to the current thread. Called when coroutine is resumed. */
+ fun beginAllOnThread() {
+ slices.forEach { beginSlice(it.name) }
+ }
+
+ /**
+ * Removes all current trace slices from the current thread. Called when coroutine is suspended.
+ */
+ fun endAllOnThread() {
+ for (i in 0..slices.size) {
+ endSlice()
+ }
+ }
+
+ /**
+ * Creates a new trace section with a unique ID and adds it to the current trace data. The slice
+ * will also be added to the current thread immediately. This slice will not propagate to parent
+ * coroutines, or to child coroutines that have already started. The unique ID is used to verify
+ * that the [endSpan] is corresponds to a [beginSpan].
+ */
+ fun beginSpan(name: String): Int {
+ val newSlice = TraceSection(name, Random.nextInt(FIRST_VALID_SPAN, Int.MAX_VALUE))
+ slices.add(newSlice)
+ beginSlice(name)
+ return newSlice.id
+ }
+
+ /**
+ * Used by [TraceContextElement] when launching a child coroutine so that the child coroutine's
+ * state is isolated from the parent.
+ */
+ fun copy(): TraceData {
+ return TraceData().also { it.slices.addAll(slices) }
+ }
+
+ /**
+ * Ends the trace section and validates it corresponds with an earlier call to [beginSpan]. The
+ * trace slice will immediately be removed from the current thread. This information will not
+ * propagate to parent coroutines, or to child coroutines that have already started.
+ */
+ fun endSpan(id: Int) {
+ val v = slices.removeLast()
+ if (v.id != id) {
+ if (STRICT_MODE) {
+ throw IllegalArgumentException(errorMsg)
+ } else if (Log.isLoggable(TAG, Log.VERBOSE)) {
+ Log.v(TAG, errorMsg)
+ }
+ }
+ endSlice()
+ }
+
+ companion object {
+ private const val TAG = "TraceData"
+ const val INVALID_SPAN = -1
+ const val FIRST_VALID_SPAN = 1
+
+ /**
+ * If true, throw an exception instead of printing a warning when trace sections beginnings
+ * and ends are mismatched.
+ */
+ private val STRICT_MODE = Build.IS_ENG
+
+ private const val errorMsg =
+ "Mismatched trace section. This likely means you are accessing the trace local " +
+ "storage (threadLocalTrace) without a corresponding CopyableThreadContextElement." +
+ " This could happen if you are using a global dispatcher like Dispatchers.IO." +
+ " To fix this, use one of the coroutine contexts provided by the dagger scope " +
+ "(e.g. \"@Main CoroutineContext\")."
+ }
+}
diff --git a/tracinglib/src/com/android/app/tracing/TraceSection.kt b/tracinglib/src/com/android/app/tracing/TraceSection.kt
new file mode 100644
index 0000000..c96c0e5
--- /dev/null
+++ b/tracinglib/src/com/android/app/tracing/TraceSection.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.tracing
+
+import com.android.app.tracing.TraceUtils.Companion.traceCoroutine
+
+/**
+ * Represents a section of code executing in a coroutine. This can be split up into multiple slices
+ * on different threads as the coroutine is suspended and resumed.
+ *
+ * This is internal machinery for [traceCoroutine]. It cannot be made `internal` or `private`
+ * because [traceCoroutine] is a Public-API inline function.
+ *
+ * @param name the name of the slice to appear on the current thread's track.
+ * @param id used for matching the beginning and end of trace sections and validating correctness
+ * @see traceCoroutine
+ */
+data class TraceSection(
+ val name: String,
+ val id: Int,
+)
diff --git a/tracinglib/src/com/android/app/tracing/TraceStateLogger.kt b/tracinglib/src/com/android/app/tracing/TraceStateLogger.kt
new file mode 100644
index 0000000..02c8737
--- /dev/null
+++ b/tracinglib/src/com/android/app/tracing/TraceStateLogger.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.tracing
+
+import android.os.Trace
+import android.util.Log
+
+/**
+ * Utility class used to log state changes easily in a track with a custom name.
+ *
+ * Example of usage:
+ * ```kotlin
+ * class MyClass {
+ * val screenStateLogger = TraceStateLogger("Screen state")
+ *
+ * fun onTurnedOn() { screenStateLogger.log("on") }
+ * fun onTurnedOff() { screenStateLogger.log("off") }
+ * }
+ * ```
+ *
+ * This creates a new slice in a perfetto trace only if the state is different than the previous
+ * one.
+ */
+class TraceStateLogger(
+ private val trackName: String,
+ private val logOnlyIfDifferent: Boolean = true,
+ private val instantEvent: Boolean = true,
+ private val logcat: Boolean = false,
+) {
+
+ private var previousValue: String? = null
+
+ /** If needed, logs the value to a track with name [trackName]. */
+ fun log(newValue: String) {
+ if (instantEvent) {
+ Trace.instantForTrack(Trace.TRACE_TAG_APP, trackName, newValue)
+ }
+ if (logOnlyIfDifferent && previousValue == newValue) return
+ Trace.asyncTraceForTrackEnd(Trace.TRACE_TAG_APP, trackName, 0)
+ Trace.asyncTraceForTrackBegin(Trace.TRACE_TAG_APP, trackName, newValue, 0)
+ if (logcat) {
+ Log.d(trackName, "newValue: $newValue")
+ }
+ previousValue = newValue
+ }
+}
diff --git a/tracinglib/src/com/android/app/tracing/TraceUtils.kt b/tracinglib/src/com/android/app/tracing/TraceUtils.kt
new file mode 100644
index 0000000..ec75bd2
--- /dev/null
+++ b/tracinglib/src/com/android/app/tracing/TraceUtils.kt
@@ -0,0 +1,452 @@
+/*
+ * 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.tracing
+
+import android.os.Trace
+import android.os.TraceNameSupplier
+import android.util.Log
+import com.android.app.tracing.TraceData.Companion.FIRST_VALID_SPAN
+import com.android.app.tracing.TraceData.Companion.INVALID_SPAN
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.coroutineContext
+import kotlin.random.Random
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+
+/**
+ * Run a block within a [Trace] section. Calls [Trace.beginSection] before and [Trace.endSection]
+ * after the passed block.
+ */
+inline fun <T> traceSection(tag: String, block: () -> T): T {
+ val tracingEnabled = Trace.isTagEnabled(Trace.TRACE_TAG_APP)
+ if (tracingEnabled) Trace.traceBegin(Trace.TRACE_TAG_APP, tag)
+ return try {
+ // Note that as this is inline, the block section would be duplicated if it is called
+ // several times. For this reason, we're using the try/finally even if tracing is disabled.
+ block()
+ } finally {
+ if (tracingEnabled) Trace.traceEnd(Trace.TRACE_TAG_APP)
+ }
+}
+
+/**
+ * Same as [traceSection], but the tag is provided as a lambda to help avoiding creating expensive
+ * strings when not needed.
+ */
+inline fun <T> traceSection(tag: () -> String, block: () -> T): T {
+ val tracingEnabled = Trace.isTagEnabled(Trace.TRACE_TAG_APP)
+ if (tracingEnabled) Trace.traceBegin(Trace.TRACE_TAG_APP, tag())
+ return try {
+ block()
+ } finally {
+ if (tracingEnabled) Trace.traceEnd(Trace.TRACE_TAG_APP)
+ }
+}
+
+class TraceUtils {
+ companion object {
+ const val TAG = "TraceUtils"
+ private const val DEBUG_COROUTINE_TRACING = false
+ const val DEFAULT_TRACK_NAME = "AsyncTraces"
+
+ @JvmStatic
+ inline fun <T> trace(crossinline tag: () -> String, crossinline block: () -> T): T {
+ return traceSection(tag) { block() }
+ }
+
+ @JvmStatic
+ inline fun <T> trace(tag: String, crossinline block: () -> T): T {
+ return traceSection(tag) { block() }
+ }
+
+ @JvmStatic
+ inline fun traceRunnable(tag: String, crossinline block: () -> Unit): Runnable {
+ return Runnable { traceSection(tag) { block() } }
+ }
+
+ @JvmStatic
+ inline fun traceRunnable(crossinline tag: () -> String, crossinline block: () -> Unit): Runnable {
+ return Runnable { traceSection(tag) { block() } }
+ }
+
+ /**
+ * Helper function for creating a Runnable object that implements TraceNameSupplier.
+ *
+ * This is useful for posting Runnables to Handlers with meaningful names.
+ */
+ @JvmStatic
+ inline fun namedRunnable(tag: String, crossinline block: () -> Unit): Runnable {
+ return object : Runnable, TraceNameSupplier {
+ override fun getTraceName(): String = tag
+
+ override fun run() = block()
+ }
+ }
+
+ /**
+ * Cookie used for async traces. Shouldn't be public, but to use it inside inline methods
+ * there is no other way around.
+ */
+ val lastCookie = AtomicInteger(0)
+
+ /**
+ * Creates an async slice in a track called "AsyncTraces".
+ *
+ * This can be used to trace coroutine code. Note that all usages of this method will appear
+ * under a single track.
+ */
+ @JvmStatic
+ inline fun <T> traceAsync(method: String, block: () -> T): T =
+ traceAsync(DEFAULT_TRACK_NAME, method, block)
+
+ /**
+ * Creates an async slice in a track with [trackName] while [block] runs.
+ *
+ * This can be used to trace coroutine code. [method] will be the name of the slice,
+ * [trackName] of the track. The track is one of the rows visible in a perfetto trace inside
+ * the app process.
+ */
+ @JvmStatic
+ inline fun <T> traceAsync(trackName: String, method: String, block: () -> T): T {
+ val cookie = lastCookie.incrementAndGet()
+ Trace.asyncTraceForTrackBegin(Trace.TRACE_TAG_APP, trackName, method, cookie)
+ try {
+ return block()
+ } finally {
+ Trace.asyncTraceForTrackEnd(Trace.TRACE_TAG_APP, trackName, cookie)
+ }
+ }
+
+ /**
+ * Convenience function for calling [CoroutineScope.launch] with [traceCoroutine] enable
+ * tracing.
+ *
+ * @see traceCoroutine
+ */
+ inline fun CoroutineScope.launch(
+ crossinline spanName: () -> String,
+ context: CoroutineContext = EmptyCoroutineContext,
+ // TODO(b/306457056): DO NOT pass CoroutineStart; doing so will regress .odex size
+ crossinline block: suspend CoroutineScope.() -> Unit
+ ): Job = launch(context) { traceCoroutine(spanName) { block() } }
+
+ /**
+ * Convenience function for calling [CoroutineScope.launch] with [traceCoroutine] enable
+ * tracing.
+ *
+ * @see traceCoroutine
+ */
+ inline fun CoroutineScope.launch(
+ spanName: String,
+ context: CoroutineContext = EmptyCoroutineContext,
+ // TODO(b/306457056): DO NOT pass CoroutineStart; doing so will regress .odex size
+ crossinline block: suspend CoroutineScope.() -> Unit
+ ): Job = launch(context) { traceCoroutine(spanName) { block() } }
+
+ /**
+ * Convenience function for calling [CoroutineScope.async] with [traceCoroutine] enable
+ * tracing
+ *
+ * @see traceCoroutine
+ */
+ inline fun <T> CoroutineScope.async(
+ crossinline spanName: () -> String,
+ context: CoroutineContext = EmptyCoroutineContext,
+ // TODO(b/306457056): DO NOT pass CoroutineStart; doing so will regress .odex size
+ crossinline block: suspend CoroutineScope.() -> T
+ ): Deferred<T> = async(context) { traceCoroutine(spanName) { block() } }
+
+ /**
+ * Convenience function for calling [CoroutineScope.async] with [traceCoroutine] enable
+ * tracing.
+ *
+ * @see traceCoroutine
+ */
+ inline fun <T> CoroutineScope.async(
+ spanName: String,
+ context: CoroutineContext = EmptyCoroutineContext,
+ // TODO(b/306457056): DO NOT pass CoroutineStart; doing so will regress .odex size
+ crossinline block: suspend CoroutineScope.() -> T
+ ): Deferred<T> = async(context) { traceCoroutine(spanName) { block() } }
+
+ /**
+ * Convenience function for calling [runBlocking] with [traceCoroutine] to enable tracing.
+ *
+ * @see traceCoroutine
+ */
+ inline fun <T> runBlocking(
+ crossinline spanName: () -> String,
+ context: CoroutineContext,
+ crossinline block: suspend () -> T
+ ): T = runBlocking(context) { traceCoroutine(spanName) { block() } }
+
+ /**
+ * Convenience function for calling [runBlocking] with [traceCoroutine] to enable tracing.
+ *
+ * @see traceCoroutine
+ */
+ inline fun <T> runBlocking(
+ spanName: String,
+ context: CoroutineContext,
+ crossinline block: suspend CoroutineScope.() -> T
+ ): T = runBlocking(context) { traceCoroutine(spanName) { block() } }
+
+ /**
+ * Convenience function for calling [withContext] with [traceCoroutine] to enable tracing.
+ *
+ * @see traceCoroutine
+ */
+ suspend inline fun <T> withContext(
+ spanName: String,
+ context: CoroutineContext,
+ crossinline block: suspend CoroutineScope.() -> T
+ ): T = withContext(context) { traceCoroutine(spanName) { block() } }
+
+ /**
+ * Convenience function for calling [withContext] with [traceCoroutine] to enable tracing.
+ *
+ * @see traceCoroutine
+ */
+ suspend inline fun <T> withContext(
+ crossinline spanName: () -> String,
+ context: CoroutineContext,
+ crossinline block: suspend CoroutineScope.() -> T
+ ): T = withContext(context) { traceCoroutine(spanName) { block() } }
+
+ /**
+ * A hacky way to propagate the value of the COROUTINE_TRACING flag for static usage in this
+ * file. It should only every be set to true during startup. Once true, it cannot be set to
+ * false again.
+ */
+ var coroutineTracingIsEnabled = false
+ set(v) {
+ if (v) field = true
+ }
+
+ /**
+ * Traces a section of work of a `suspend` [block]. The trace sections will appear on the
+ * thread that is currently executing the [block] of work. If the [block] is suspended, all
+ * trace sections added using this API will end until the [block] is resumed, which could
+ * happen either on this thread or on another thread. If a child coroutine is started, it
+ * will inherit the trace sections of its parent. The child will continue to print these
+ * trace sections whether or not the parent coroutine is still running them.
+ *
+ * The current [CoroutineContext] must have a [TraceContextElement] for this API to work.
+ * Otherwise, the trace sections will be dropped.
+ *
+ * For example, in the following trace, Thread #1 ran some work, suspended, then continued
+ * working on Thread #2. Meanwhile, Thread #2 created a new child coroutine which inherited
+ * its trace sections. Then, the original coroutine resumed on Thread #1 before ending.
+ * Meanwhile Thread #3 is still printing trace sections from its parent because they were
+ * copied when it was created. There is no way for the parent to communicate to the child
+ * that it marked these slices as completed. While this might seem counterintuitive, it
+ * allows us to pinpoint the origin of the child coroutine's work.
+ *
+ * ```
+ * Thread #1 | [==== Slice A ====] [==== Slice A ====]
+ * | [==== B ====] [=== B ===]
+ * --------------------------------------------------------------------------------------
+ * Thread #2 | [====== Slice A ======]
+ * | [========= B =========]
+ * | [===== C ======]
+ * --------------------------------------------------------------------------------------
+ * Thread #3 | [== Slice A ==] [== Slice A ==]
+ * | [===== B =====] [===== B =====]
+ * | [===== C =====] [===== C =====]
+ * | [=== D ===]
+ * ```
+ *
+ * @param name The name of the code section to appear in the trace
+ * @see endSlice
+ * @see traceCoroutine
+ */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ suspend inline fun <T> traceCoroutine(
+ spanName: Lazy<String>,
+ crossinline block: suspend () -> T
+ ): T {
+ // For coroutine tracing to work, trace spans must be added and removed even when
+ // tracing is not active (i.e. when TRACE_TAG_APP is disabled). Otherwise, when the
+ // coroutine resumes when tracing is active, we won't know its name.
+ val tracer = getTraceData(spanName)
+ val coroutineSpanCookie = tracer?.beginSpan(spanName.value) ?: INVALID_SPAN
+
+ // For now, also trace to "AsyncTraces". This will allow us to verify the correctness
+ // of the COROUTINE_TRACING feature flag.
+ val asyncTraceCookie =
+ if (Trace.isTagEnabled(Trace.TRACE_TAG_APP))
+ Random.nextInt(FIRST_VALID_SPAN, Int.MAX_VALUE)
+ else INVALID_SPAN
+ if (asyncTraceCookie != INVALID_SPAN) {
+ Trace.asyncTraceForTrackBegin(
+ Trace.TRACE_TAG_APP,
+ DEFAULT_TRACK_NAME,
+ spanName.value,
+ asyncTraceCookie
+ )
+ }
+ try {
+ return block()
+ } finally {
+ if (asyncTraceCookie != INVALID_SPAN) {
+ Trace.asyncTraceForTrackEnd(
+ Trace.TRACE_TAG_APP,
+ DEFAULT_TRACK_NAME,
+ asyncTraceCookie
+ )
+ }
+ tracer?.endSpan(coroutineSpanCookie)
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ suspend fun getTraceData(spanName: Lazy<String>): TraceData? {
+ if (!coroutineTracingIsEnabled) {
+ logVerbose("Experimental flag COROUTINE_TRACING is off", spanName)
+ } else if (coroutineContext[TraceContextElement] == null) {
+ logVerbose("Current CoroutineContext is missing TraceContextElement", spanName)
+ } else {
+ return threadLocalTrace.get().also {
+ if (it == null) logVerbose("ThreadLocal TraceData is null", spanName)
+ }
+ }
+ return null
+ }
+
+ private fun logVerbose(logMessage: String, spanName: Lazy<String>) {
+ if (DEBUG_COROUTINE_TRACING && Log.isLoggable(TAG, Log.VERBOSE)) {
+ Log.v(TAG, "$logMessage. Dropping trace section: \"${spanName.value}\"")
+ }
+ }
+
+ /** @see traceCoroutine */
+ suspend inline fun <T> traceCoroutine(
+ spanName: String,
+ crossinline block: suspend () -> T
+ ): T = traceCoroutine(lazyOf(spanName)) { block() }
+
+ /** @see traceCoroutine */
+ suspend inline fun <T> traceCoroutine(
+ crossinline spanName: () -> String,
+ crossinline block: suspend () -> T
+ ): T = traceCoroutine(lazy(LazyThreadSafetyMode.PUBLICATION) { spanName() }) { block() }
+
+ /**
+ * Writes a trace message to indicate that a given section of code has begun running __on
+ * the current thread__. This must be followed by a corresponding call to [endSlice] in a
+ * reasonably short amount of time __on the same thread__ (i.e. _before_ the thread becomes
+ * idle again and starts running other, unrelated work).
+ *
+ * Calls to [beginSlice] and [endSlice] may be nested, and they will render in Perfetto as
+ * follows:
+ * ```
+ * Thread #1 | [==========================]
+ * | [==============]
+ * | [====]
+ * ```
+ *
+ * This function is provided for convenience to wrap a call to [Trace.traceBegin], which is
+ * more verbose to call than [Trace.beginSection], but has the added benefit of not throwing
+ * an [IllegalArgumentException] if the provided string is longer than 127 characters. We
+ * use the term "slice" instead of "section" to be consistent with Perfetto.
+ *
+ * # Avoiding malformed traces
+ *
+ * Improper usage of this API will lead to malformed traces with long slices that sometimes
+ * never end. This will look like the following:
+ * ```
+ * Thread #1 | [===================================================================== ...
+ * | [==============] [====================================== ...
+ * | [=======] [======] [===================== ...
+ * | [=======]
+ * ```
+ *
+ * To avoid this, [beginSlice] and [endSlice] should never be called from `suspend` blocks
+ * (instead, use [traceCoroutine] for tracing suspending functions). While it would be
+ * technically okay to call from a suspending function if that function were to only wrap
+ * non-suspending blocks with [beginSlice] and [endSlice], doing so is risky because suspend
+ * calls could be mistakenly added to that block as the code is refactored.
+ *
+ * Additionally, it is _not_ okay to call [beginSlice] when registering a callback and match
+ * it with a call to [endSlice] inside that callback, even if the callback runs on the same
+ * thread. Doing so would cause malformed traces because the [beginSlice] wasn't closed
+ * before the thread became idle and started running unrelated work.
+ *
+ * @param sliceName The name of the code section to appear in the trace
+ * @see endSlice
+ * @see traceCoroutine
+ */
+ fun beginSlice(sliceName: String) {
+ Trace.traceBegin(Trace.TRACE_TAG_APP, sliceName)
+ }
+
+ /**
+ * Writes a trace message to indicate that a given section of code has ended. This call must
+ * be preceded by a corresponding call to [beginSlice]. See [beginSlice] for important
+ * information regarding usage.
+ *
+ * @see beginSlice
+ * @see traceCoroutine
+ */
+ fun endSlice() {
+ Trace.traceEnd(Trace.TRACE_TAG_APP)
+ }
+
+ /**
+ * Writes a trace message indicating that an instant event occurred on the current thread.
+ * Unlike slices, instant events have no duration and do not need to be matched with another
+ * call. Perfetto will display instant events using an arrow pointing to the timestamp they
+ * occurred:
+ * ```
+ * Thread #1 | [==============] [======]
+ * | [====] ^
+ * | ^
+ * ```
+ *
+ * @param eventName The name of the event to appear in the trace.
+ */
+ fun instant(eventName: String) {
+ Trace.instant(Trace.TRACE_TAG_APP, eventName)
+ }
+
+ /**
+ * Writes a trace message indicating that an instant event occurred on the given track.
+ * Unlike slices, instant events have no duration and do not need to be matched with another
+ * call. Perfetto will display instant events using an arrow pointing to the timestamp they
+ * occurred:
+ * ```
+ * Async | [==============] [======]
+ * Track | [====] ^
+ * Name | ^
+ * ```
+ *
+ * @param trackName The track where the event should appear in the trace.
+ * @param eventName The name of the event to appear in the trace.
+ */
+ fun instantForTrack(trackName: String, eventName: String) {
+ Trace.instantForTrack(Trace.TRACE_TAG_APP, trackName, eventName)
+ }
+ }
+}
diff --git a/viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt
new file mode 100644
index 0000000..795212d
--- /dev/null
+++ b/viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt
@@ -0,0 +1,21 @@
+package com.android.app.viewcapture
+
+import android.media.permission.SafeCloseable
+import android.os.HandlerThread
+import android.view.View
+import android.view.Window
+
+/**
+ * We don't want to enable the ViewCapture for release builds, since it currently only serves
+ * 1p apps, and has memory / cpu load that we don't want to risk negatively impacting release builds
+ */
+class NoOpViewCapture: ViewCapture(0, 0,
+ createAndStartNewLooperExecutor("NoOpViewCapture", HandlerThread.MIN_PRIORITY)) {
+ override fun startCapture(view: View, name: String): SafeCloseable {
+ return SafeCloseable { }
+ }
+
+ override fun startCapture(window: Window): SafeCloseable {
+ return SafeCloseable { }
+ }
+} \ No newline at end of file
diff --git a/viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt
index 9a857c3..ce1dfe8 100644
--- a/viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt
+++ b/viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt
@@ -89,6 +89,7 @@ internal constructor(private val context: Context, executor: Executor)
@JvmStatic
fun getInstance(context: Context): ViewCapture = when {
INSTANCE != null -> INSTANCE!!
+ !android.os.Build.IS_DEBUGGABLE -> NoOpViewCapture()
Looper.myLooper() == Looper.getMainLooper() -> SettingsAwareViewCapture(
context.applicationContext,
createAndStartNewLooperExecutor("SAViewCapture",
diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java b/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java
index bbd797e..6e0b047 100644
--- a/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java
+++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java
@@ -19,7 +19,9 @@ package com.android.app.viewcapture;
import static com.android.app.viewcapture.data.ExportedData.MagicNumber.MAGIC_NUMBER_H;
import static com.android.app.viewcapture.data.ExportedData.MagicNumber.MAGIC_NUMBER_L;
+import android.content.ComponentCallbacks2;
import android.content.Context;
+import android.content.res.Configuration;
import android.content.res.Resources;
import android.media.permission.SafeCloseable;
import android.os.HandlerThread;
@@ -46,6 +48,7 @@ import com.android.app.viewcapture.data.MotionWindowData;
import com.android.app.viewcapture.data.ViewNode;
import com.android.app.viewcapture.data.WindowData;
+import java.io.DataOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
@@ -125,7 +128,7 @@ public abstract class ViewCapture {
* Attaches the ViewCapture to the provided window and returns a handle to detach the listener
*/
@NonNull
- public SafeCloseable startCapture(Window window) {
+ public SafeCloseable startCapture(@NonNull Window window) {
String title = window.getAttributes().getTitle().toString();
String name = TextUtils.isEmpty(title) ? window.toString() : title;
return startCapture(window.getDecorView(), name);
@@ -136,11 +139,16 @@ public abstract class ViewCapture {
* Verifies that ViewCapture is enabled before actually attaching an onDrawListener.
*/
@NonNull
- public SafeCloseable startCapture(View view, String name) {
+ public SafeCloseable startCapture(@NonNull View view, @NonNull String name) {
WindowListener listener = new WindowListener(view, name);
if (mIsEnabled) MAIN_EXECUTOR.execute(listener::attachToRoot);
mListeners.add(listener);
+ view.getContext().registerComponentCallbacks(listener);
+
return () -> {
+ if (listener.mRoot != null && listener.mRoot.getContext() != null) {
+ listener.mRoot.getContext().unregisterComponentCallbacks(listener);
+ }
mListeners.remove(listener);
listener.detachFromRoot();
};
@@ -173,9 +181,14 @@ public abstract class ViewCapture {
}
@AnyThread
- public void dumpTo(OutputStream os, Context context)
+ protected void dumpTo(OutputStream os, Context context)
throws InterruptedException, ExecutionException, IOException {
- if (mIsEnabled) getExportedData(context).writeTo(os);
+ if (mIsEnabled) {
+ DataOutputStream dataOutputStream = new DataOutputStream(os);
+ ExportedData ex = getExportedData(context);
+ dataOutputStream.writeInt(ex.getSerializedSize());
+ ex.writeTo(dataOutputStream);
+ }
}
@VisibleForTesting
@@ -222,42 +235,47 @@ public abstract class ViewCapture {
* 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.
- *
+ * <p>
* 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.
- *
+ * <p>
* 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.
- *
+ * <p>
* 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.
- *
+ * <p>
* 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.
- *
+ * <p>
* 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.
- *
+ * <p>
* 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.
- *
+ * <p>
* 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.
+ * <p>
+ * Each WindowListener is memory aware and will both stop collecting view capture information,
+ * as well as delete their current stash of information upon a signal from the system that
+ * memory resources are scarce. The user will need to restart the app process before
+ * more ViewCapture information is captured.
*/
- private class WindowListener implements ViewTreeObserver.OnDrawListener {
+ private class WindowListener implements ViewTreeObserver.OnDrawListener, ComponentCallbacks2 {
- @Nullable // Nullable in tests only
+ @Nullable
public View mRoot;
public final String name;
@@ -265,8 +283,8 @@ public abstract class ViewCapture {
private int mFrameIndexBg = -1;
private boolean mIsFirstFrame = true;
- private final long[] mFrameTimesNanosBg = new long[mMemorySize];
- private final ViewPropertyRef[] mNodesBg = new ViewPropertyRef[mMemorySize];
+ private long[] mFrameTimesNanosBg = new long[mMemorySize];
+ private ViewPropertyRef[] mNodesBg = new ViewPropertyRef[mMemorySize];
private boolean mIsActive = true;
private final Consumer<ViewRef> mCaptureCallback = this::captureViewPropertiesBg;
@@ -382,6 +400,7 @@ public abstract class ViewCapture {
}
void attachToRoot() {
+ if (mRoot == null) return;
mIsActive = true;
if (mRoot.isAttachedToWindow()) {
safelyEnableOnDrawListener();
@@ -410,8 +429,10 @@ public abstract class ViewCapture {
}
private void safelyEnableOnDrawListener() {
- mRoot.getViewTreeObserver().removeOnDrawListener(this);
- mRoot.getViewTreeObserver().addOnDrawListener(this);
+ if (mRoot != null) {
+ mRoot.getViewTreeObserver().removeOnDrawListener(this);
+ mRoot.getViewTreeObserver().addOnDrawListener(this);
+ }
}
@WorkerThread
@@ -462,6 +483,30 @@ public abstract class ViewCapture {
return ref;
}
}
+
+ @Override
+ public void onTrimMemory(int level) {
+ if (ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL == level
+ || ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW == level) {
+ mNodesBg = new ViewPropertyRef[0];
+ mFrameTimesNanosBg = new long[0];
+ if (mRoot != null && mRoot.getContext() != null) {
+ mRoot.getContext().unregisterComponentCallbacks(this);
+ }
+ detachFromRoot();
+ mRoot = null;
+ }
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration configuration) {
+ // No Operation
+ }
+
+ @Override
+ public void onLowMemory() {
+ onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW);
+ }
}
private static class ViewPropertyRef {
@@ -552,6 +597,7 @@ public abstract class ViewCapture {
private static class ViewRef implements Runnable {
public View view;
public int childCount = 0;
+ @Nullable
public ViewRef next;
public Consumer<ViewRef> callback = null;
diff --git a/weathereffects/Android.bp b/weathereffects/Android.bp
new file mode 100644
index 0000000..fb894fa
--- /dev/null
+++ b/weathereffects/Android.bp
@@ -0,0 +1,95 @@
+// 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 {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_library {
+ name: "WeatherEffectsLib",
+ manifest: "AndroidManifest.xml",
+ sdk_version: "system_current",
+ // min_sdk version must be specified to not compile against platform apis.
+ // Using HardwareBufferRenderer requires minimum of 34.
+ min_sdk_version: "34",
+ static_libs: [
+ "androidx.slice_slice-core",
+ "androidx.slice_slice-builders",
+ "dagger2",
+ "jsr330", // Dagger inject annotations.
+ "kotlinx_coroutines_android",
+ "kotlinx_coroutines",
+ "androidx.core_core-ktx",
+ "androidx.appcompat_appcompat",
+ "androidx-constraintlayout_constraintlayout",
+ "toruslib",
+ ],
+ srcs: [
+ "src/**/*.java",
+ "src/**/*.kt",
+ // TODO(b/300991599): Split out debug source.
+ "debug/src/**/*.java",
+ "debug/src/**/*.kt"
+ ],
+ resource_dirs: [
+ "res",
+ // TODO(b/300991599): Split out debug resources.
+ "debug/res"
+ ],
+ javacflags: ["-Adagger.fastInit=enabled"],
+ kotlincflags: ["-Xjvm-default=all"],
+ plugins: ["dagger2-compiler"],
+ dxflags: ["--multi-dex"],
+ // This library is meant to access only public APIs, do not flip this flag to true.
+ platform_apis: false
+}
+
+android_app {
+ name: "WeatherEffects",
+ owner: "google",
+ privileged: false,
+ sdk_version: "system_current",
+ min_sdk_version: "34",
+ static_libs: [
+ "WeatherEffectsLib"
+ ],
+ use_embedded_native_libs: true,
+ optimize: {
+ enabled: true,
+ shrink: true,
+ shrink_resources: true,
+ },
+}
+
+android_test {
+ name: "weathereffects_tests",
+ instrumentation_for: "WeatherEffects",
+ manifest: "AndroidManifest.xml",
+ test_suites: ["general-tests"],
+ sdk_version: "current",
+ srcs: [
+ "tests/src/**/*.java",
+ "tests/src/**/*.kt",
+ ],
+ static_libs: [
+ "WeatherEffectsLib",
+ "androidx.test.rules",
+ "androidx.test.ext.junit",
+ "androidx.test.core",
+ "androidx.test.runner",
+ "kotlinx_coroutines_test",
+ "truth"
+ ],
+}
+
diff --git a/weathereffects/AndroidManifest.xml b/weathereffects/AndroidManifest.xml
new file mode 100644
index 0000000..bdcd956
--- /dev/null
+++ b/weathereffects/AndroidManifest.xml
@@ -0,0 +1,53 @@
+<?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.google.android.wallpaper.weathereffects">
+
+ <!--TODO: Add privileged permission-->
+
+ <uses-feature
+ android:name="android.software.live_wallpaper"
+ android:required="true" />
+
+ <queries>
+ <package android:name="com.google.android.apps.wallpaper" />
+ <package android:name="com.android.wallpaper" />
+ </queries>
+
+ <application
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name">
+ <provider
+ android:name="com.google.android.wallpaper.weathereffects.provider.WeatherEffectsContentProvider"
+ android:authorities="${applicationId}.effectprovider"
+ android:exported="true" />
+ <service android:name="com.google.android.wallpaper.weathereffects.WeatherWallpaperService"
+ android:directBootAware="true"
+ android:exported="true"
+ android:label="@string/app_name"
+ android:permission="android.permission.BIND_WALLPAPER">
+ <intent-filter>
+ <action android:name="android.service.wallpaper.WallpaperService" />
+ </intent-filter>
+
+ <meta-data
+ android:name="android.service.wallpaper"
+ android:resource="@xml/weather_wallpaper" />
+ </service>
+
+ </application>
+</manifest>
diff --git a/weathereffects/TEST_MAPPING b/weathereffects/TEST_MAPPING
new file mode 100644
index 0000000..9035803
--- /dev/null
+++ b/weathereffects/TEST_MAPPING
@@ -0,0 +1,7 @@
+{
+ "presubmit": [
+ {
+ "name": "weathereffects_tests"
+ }
+ ]
+} \ No newline at end of file
diff --git a/weathereffects/assets/shaders/color_grading_lut.agsl b/weathereffects/assets/shaders/color_grading_lut.agsl
new file mode 100644
index 0000000..4b74a6f
--- /dev/null
+++ b/weathereffects/assets/shaders/color_grading_lut.agsl
@@ -0,0 +1,82 @@
+/*
+ * 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.
+ */
+
+uniform shader texture;
+uniform shader lut;
+uniform float intensity;
+/*
+ * The LUT cube size, in pixels (depth, width and height). If we use the OpenGL coordinate
+ * system (right-handed, Y-up, X-right, negative Z-forward), each direction represents a color change:
+ * - Right changes (width) are related to red changes.
+ * - Up changes (height) are related to blue changes.
+ * - Forward changes (depth) are related to green changes.
+ */
+const float LUT_CUBE_SIZE = 32.0;
+
+/*
+ * Each height unit of the LUT cube is sliced into an LUT_CUBE_SIZE x LUT_CUBE_SIZE image;
+ * then all the images are concatenated on the texture image width. Thus, the image height
+ * is LUT_CUBE_SIZE and the width is LUT_CUBE_SIZE x LUT_CUBE_SIZE. Each slice will have a
+ * different blue value, and inside each slice, vertical direction means green color changes and
+ * horizontal direction means red color changes.
+ */
+const float IMAGE_WIDTH = LUT_CUBE_SIZE * LUT_CUBE_SIZE;
+
+// The last slice first pixel index.
+const float LAST_SLICE_FIRST_IDX = IMAGE_WIDTH - LUT_CUBE_SIZE;
+
+// The last pixel index of a slice.
+const float SLICE_LAST_IDX = LUT_CUBE_SIZE - 1.;
+const float SLICE_LAST_IDX_INV = 1. / SLICE_LAST_IDX;
+
+// Applies lut, pass in texture color to apply to.
+vec4 main(float2 fragCoord) {
+ vec4 color = texture.eval(fragCoord);
+
+ /*
+ * When we fetch the new color on the LUT cube, each color can fall in between two values
+ * (pixels) which might not be one next to each other. we need to calculate both values and
+ * interpolate.
+ */
+ vec3 colorTmp = color.rgb * SLICE_LAST_IDX;
+
+ // Calculate the floor UVs.
+ vec3 colorFloor = floor(colorTmp) * SLICE_LAST_IDX_INV;
+ ivec2 uvFloor = ivec2(
+ int(colorFloor.b * LAST_SLICE_FIRST_IDX + colorFloor.r * SLICE_LAST_IDX),
+ int(colorFloor.g * SLICE_LAST_IDX)
+ );
+
+ // Calculate the ceil UVs.
+ vec3 colorCeil = ceil(colorTmp) * SLICE_LAST_IDX_INV;
+ ivec2 uvCeil = ivec2(
+ int(colorCeil.b * LAST_SLICE_FIRST_IDX + colorCeil.r * SLICE_LAST_IDX),
+ int(colorCeil.g * SLICE_LAST_IDX)
+ );
+
+ /*
+ * Fetch the color from the LUT, and combine both floor and ceiling options based on the
+ * fractional part.
+ */
+ vec4 colorAfterLut = vec4(0., 0., 0., color.a);
+ colorAfterLut.rgb = mix(
+ lut.eval(vec2(uvFloor.x, uvFloor.y)).rgb,
+ lut.eval(vec2(uvCeil.x, uvCeil.y)).rgb,
+ fract(colorTmp)
+ );
+
+ return mix(color, colorAfterLut, intensity);
+}
diff --git a/weathereffects/assets/shaders/constants.agsl b/weathereffects/assets/shaders/constants.agsl
new file mode 100644
index 0000000..5f710d8
--- /dev/null
+++ b/weathereffects/assets/shaders/constants.agsl
@@ -0,0 +1,19 @@
+/*
+ * 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.
+ */
+
+/* Constants. */
+const float PI = 3.14159265359;
+const float TAU = 6.2831853072;
diff --git a/weathereffects/assets/shaders/fog_effect.agsl b/weathereffects/assets/shaders/fog_effect.agsl
new file mode 100644
index 0000000..eab926b
--- /dev/null
+++ b/weathereffects/assets/shaders/fog_effect.agsl
@@ -0,0 +1,69 @@
+/*
+ * 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.
+ */
+
+uniform shader foreground;
+uniform shader background;
+uniform float2 uvOffsetFgd;
+uniform float2 uvScaleFgd;
+uniform float2 uvOffsetBgd;
+uniform float2 uvScaleBgd;
+uniform half timeForeground;
+uniform half timeBackground;
+uniform half screenAspectRatio;
+uniform half2 screenSize;
+uniform half pixelDensity;
+
+#include "shaders/constants.agsl"
+#include "shaders/utils.agsl"
+#include "shaders/simplex2d.agsl"
+
+const int numOctaves = 2;
+
+float fbm(vec2 p, half time) {
+ float a = 0.5;
+ float result = 0.0;
+ float rot = 1.2;
+
+ for (int i = 0; i < numOctaves; i++) {
+ result += a * simplex2d_flow(p, rot, time);
+ rot *= 1.25;
+ p *= 2.0152;
+ a *= 0.5;
+ }
+
+ return result;
+}
+
+vec4 main(float2 fragCoord) {
+ float2 uv = fragCoord / screenSize;
+ uv.y /= screenAspectRatio;
+
+ vec4 colorForeground = foreground.eval(fragCoord * uvScaleFgd + uvOffsetFgd);
+ vec4 color = background.eval(fragCoord * uvScaleBgd + uvOffsetBgd);
+
+ float frontFog = smoothstep(-0.616, 0.552, fbm(uv * 0.8, timeForeground));
+ float bgdFog = smoothstep(-0.744, 0.28, fbm(uv * 1.2, timeBackground));
+
+ float dither = 1. - triangleNoise(fragCoord * pixelDensity) * 0.161;
+
+ color.rgb = normalBlendWithWhiteSrc(color.rgb, 0.8 * dither * bgdFog);
+ // Add the foreground. Any effect from here will be in front of the subject.
+ color.rgb = normalBlend(color.rgb, colorForeground.rgb, colorForeground.a);
+ // foreground fog.
+ color.rgb = normalBlendWithWhiteSrc(color.rgb, 0.5 * frontFog);
+
+ return color;
+}
diff --git a/weathereffects/assets/shaders/glass_rain.agsl b/weathereffects/assets/shaders/glass_rain.agsl
new file mode 100644
index 0000000..9e0b9c5
--- /dev/null
+++ b/weathereffects/assets/shaders/glass_rain.agsl
@@ -0,0 +1,147 @@
+/*
+ * 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.
+ */
+
+struct GlassRain {
+ highp vec2 drop;
+ highp float dropMask;
+ highp vec2 dropplets;
+ highp float droppletsMask;
+ highp float trailMask;
+ highp vec2 cellUv;
+};
+
+/**
+ * Generates information to show some rain running down a foggy glass surface.
+ *
+ * @param uv the UV of the fragment where we will display the rain effect.
+ * @param screenAspectRatio the aspect ratio of the fragment where we will display the effect.
+ * @param time the elapsed time.
+ * @param rainGridSize the size of the grid, where each cell contains a main drop and some
+ * dropplets.
+ * @param rainIntensity how many of the cells will contain drops. Value from 0 (no rain) to 1
+ * (each cell contains a drop).
+ *
+ * @returns GlassRain an object containing all the info to draw the rain.
+ */
+GlassRain generateGlassRain(
+ // UVs of the target fragment (normalized).
+ in vec2 uv,
+ in float screenAspectRatio,
+ in float time,
+ in vec2 rainGridSize,
+ in float rainIntensity
+) {
+ vec2 dropPos = vec2(0.);
+ float cellMainDropMask = 0.0;
+ vec2 trailDropsPos = vec2(0.);
+ float cellDroppletsMask = 0.0;
+ float cellTrailMask = 0.0;
+
+ /* Grid. */
+ // Number of rows and columns (each one is a cell, a drop).
+ float cellAspectRatio = rainGridSize.x / rainGridSize.y;
+ // Aspect ratio impacts visible cells.
+ rainGridSize.y /= screenAspectRatio;
+ // scale the UV to allocate number of rows and columns.
+ vec2 gridUv = uv * rainGridSize;
+ // Invert y (otherwise it goes from 0=top to 1=bottom).
+ gridUv.y = 1. - gridUv.y;
+ float verticalGridPos = 2.4 * time / 5.0;
+ // Move grid vertically down.
+ gridUv.y += verticalGridPos;
+
+ /* Cell. */
+ // Get the cell ID based on the grid position. Value from 0 to 1.
+ float cellId = idGenerator(floor(gridUv));
+ // For each cell, we set the internal UV from -0.5 (left, bottom) to 0.5 (right, top).
+ vec2 cellUv = fract(gridUv) - 0.5;
+
+ /* Cell-id-based variations. */
+ // Adjust time based on cellId.
+ time += cellId * 7.1203;
+ // Adjusts UV.y based on cell ID. This will make that the wiggle variation is different for
+ // each cell.
+ uv.y += cellId * 3.83027;
+ // Adjusts scale of each drop (higher is smaller).
+ float scaleVariation = 1.0 + 0.7 * cellId;
+ // Make some cells to not have drops.
+ if (cellId < 1. - rainIntensity) {
+ return GlassRain(dropPos, cellMainDropMask, trailDropsPos, cellDroppletsMask,
+ cellTrailMask, cellUv);
+ }
+
+ /* Cell main drop. */
+ // vertical movement: Fourier Series-Sawtooth Wave (ascending: /|/|/|).
+ float verticalSpeed = TAU / 5.0;
+ float verticalPosVariation = 0.45 * 0.63 * (
+ -1.2 * sin(verticalSpeed * time)
+ -0.5 * sin(2. * verticalSpeed * time)
+ -0.3333 * sin(3. * verticalSpeed * time)
+ );
+
+ // Horizontal movement: Wiggle.
+ float wiggleSpeed = 6.0;
+ float wiggleAmp = 0.5;
+ // Define the start based on the cell id.
+ float horizontalStartAmp = 0.5;
+ float horizontalStart = (cellId - 0.5) * 2.0 * horizontalStartAmp / cellAspectRatio;
+ // Add the wiggle (equation decided by testing in Grapher).
+ float horizontalWiggle = wiggle(uv.y, wiggleSpeed);
+
+ // Add the start and wiggle and make that when we are closer to the edge, we don't wiggle much
+ // (so the drop doesn't go outside it's cell).
+ horizontalWiggle = horizontalStart
+ + (horizontalStartAmp - abs(horizontalStart)) * wiggleAmp * horizontalWiggle;
+
+ // Calculate main cell drop.
+ float dropPosUncorrected = (cellUv.x - horizontalWiggle);
+ dropPos.x = dropPosUncorrected / cellAspectRatio;
+ // Create tear drop shape.
+ verticalPosVariation -= dropPosUncorrected * dropPosUncorrected / cellAspectRatio;
+ dropPos.y = cellUv.y - verticalPosVariation;
+ // Adjust scale.
+ dropPos *= scaleVariation;
+ // Create a circle for the main drop in the cell, based on position.
+ cellMainDropMask = smoothstep(0.06, 0.04, length(dropPos));
+
+ /* Cell trail dropplets. */
+ trailDropsPos.x = (cellUv.x - horizontalWiggle)/ cellAspectRatio;
+ // Substract verticalGridPos to mage the dropplets stick in place.
+ trailDropsPos.y = cellUv.y -verticalGridPos;
+ trailDropsPos.y = (fract(trailDropsPos.y * 4.) - 0.5) / 4.;
+ // Adjust scale.
+ trailDropsPos *= scaleVariation;
+ cellDroppletsMask = smoothstep(0.03, 0.02, length(trailDropsPos));
+ // Fade the dropplets frop the top the farther they are from the main drop.
+ // Multiply by 1.2 so we show more of the trail.
+ float verticalFading = 1.2 * smoothstep(0.5, verticalPosVariation, cellUv.y);
+ cellDroppletsMask *= verticalFading;
+ // Mask dropplets that are under main cell drop.
+ cellDroppletsMask *= smoothstep(-0.06, 0.08, dropPos.y);
+
+ /* Cell trail mask (it will show the image unblurred). */
+ // Gradient for top of the main drop.
+ cellTrailMask = smoothstep(-0.04, 0.04, dropPos.y);
+ // Fades out the closer we get to the top of the cell.
+ cellTrailMask *= verticalFading;
+ // Only show the main section of the trail.
+ cellTrailMask *= smoothstep(0.07, 0.02, abs(dropPos.x));
+
+ cellDroppletsMask *= cellTrailMask;
+
+ return GlassRain(
+ dropPos, cellMainDropMask, trailDropsPos, cellDroppletsMask, cellTrailMask, cellUv);
+}
diff --git a/weathereffects/assets/shaders/rain.agsl b/weathereffects/assets/shaders/rain.agsl
new file mode 100644
index 0000000..eed39f8
--- /dev/null
+++ b/weathereffects/assets/shaders/rain.agsl
@@ -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.
+ */
+
+struct Rain {
+ highp float dropMask;
+ highp vec2 cellUv;
+};
+
+/**
+ * Pouring rain.
+ *
+ * @param uv the UV of the fragment where we will display the rain effect.
+ * @param screenAspectRatio the aspect ratio of the fragment where we will display the effect.
+ * @param time the elapsed time.
+ * @param rainGridSize the size of the grid, where each cell contains a main drop and some
+ * dropplets.
+ * @param rainIntensity how many of the cells will contain drops. Value from 0 (no rain) to 1
+ * (each cell contains a drop).
+ *
+ * @returns float with the rain info.
+ */
+Rain generateRain(
+ // UVs of the target fragment (normalized).
+ in vec2 uv,
+ in float screenAspectRatio,
+ in float time,
+ in vec2 rainGridSize,
+ in float rainIntensity
+) {
+ /* Grid. */
+ // Number of rows and columns (each one is a cell, a drop).
+ float cellAspectRatio = rainGridSize.x / rainGridSize.y;
+ // Aspect ratio impacts visible cells.
+ rainGridSize.y /= screenAspectRatio;
+ // scale the UV to allocate number of rows and columns.
+ vec2 gridUv = uv * rainGridSize;
+ // Invert y (otherwise it goes from 0=top to 1=bottom).
+ gridUv.y = 1. - gridUv.y;
+ float verticalGridPos = 0.4 * time;
+ // Move grid vertically down.
+ gridUv.y += verticalGridPos;
+ // Generate column id, to offset columns vertically (so rain is not aligned).
+ float columnId = idGenerator(floor(gridUv.x));
+ gridUv.y += columnId * 2.6;
+
+ /* Cell. */
+ // Get the cell ID based on the grid position. Value from 0 to 1.
+ float cellId = idGenerator(floor(gridUv));
+ // For each cell, we set the internal UV from -0.5 (left, bottom) to 0.5 (right, top).
+ vec2 cellUv = fract(gridUv) - 0.5;
+
+ float intensity = idGenerator(floor(vec2(cellId * 8.16, 27.2)));
+ if (intensity < 1. - rainIntensity) {
+ return Rain(0.0, cellUv);
+ }
+
+ /* Cell-id-based variations. */
+ // Adjust time based on columnId.
+ time += columnId * 7.1203;
+ // Adjusts scale of each drop (higher is smaller).
+ float scaleVariation = 1.0 - 0.3 * cellId;
+ float opacityVariation = (1. - 0.9 * cellId);
+
+ /* Cell drop. */
+ // Define the start based on the cell id.
+ float horizontalStart = 0.8 * (cellId - 0.5);
+
+ // Calculate drop.
+ vec2 dropPos = cellUv;
+ dropPos.y += -0.052;
+ dropPos.x += horizontalStart;
+ dropPos *= scaleVariation * vec2(14.2, 2.728);
+ // Create the drop.
+ float dropMask = smoothstep(
+ 0.,
+ // Adjust the opacity.
+ .80 + 3. * cellId,
+ // Adjust the shape.
+ 1. - length(vec2(dropPos.x, (dropPos.y - dropPos.x * dropPos.x)))
+ );
+
+ return Rain(dropMask, cellUv);
+}
diff --git a/weathereffects/assets/shaders/rain_effect.agsl b/weathereffects/assets/shaders/rain_effect.agsl
new file mode 100644
index 0000000..90a4f8b
--- /dev/null
+++ b/weathereffects/assets/shaders/rain_effect.agsl
@@ -0,0 +1,185 @@
+/*
+ * 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.
+ */
+
+uniform shader foreground;
+uniform shader background;
+uniform shader blurredBackground;
+uniform float2 uvOffsetFgd;
+uniform float2 uvScaleFgd;
+uniform float2 uvOffsetBgd;
+uniform float2 uvScaleBgd;
+uniform float time;
+uniform float screenAspectRatio;
+uniform float2 screenSize;
+
+#include "shaders/constants.agsl"
+#include "shaders/utils.agsl"
+#include "shaders/glass_rain.agsl"
+#include "shaders/rain.agsl"
+
+/* Constants that can be modified. */
+// The color of the highlight of each drop.
+const vec4 highlightColor = vec4(vec3(1.), 1.); // white
+// The color of the contact ambient occlusion shadow.
+const vec4 contactShadowColor = vec4(vec3(0.), 1.); // black
+// The color of the contact ambient occlusion shadow.
+const vec4 dropTint = vec4(vec3(1.), 1.); // white
+// Glass tint.
+const vec4 glassTint = vec4(vec3(0.5), 1.); // gray
+// rain tint.
+const vec4 rainTint = vec4(vec3(0.5), 1.); // gray
+
+// How much of tint we want in the drops.
+const float dropTintIntensity = 0.09;
+// How much of highlight we want.
+const float uHighlightIntensity = 0.7;
+// How much of contact shadow we want.
+const float uDropShadowIntensity = 0.5;
+// rain visibility (how visible it is).
+const float rainVisibility = 0.4;
+// How heavy it rains. 1: heavy showers; 0: no rain.
+const float rainIntensity = 0.21;
+
+vec4 main(float2 fragCoord) {
+ float2 uv = fragCoord / screenSize;
+ // Adjusts the UVs to have the expected rect of the image.
+ float2 uvTextureBgd = fragCoord * uvScaleBgd + uvOffsetBgd;
+ vec4 colorForeground = foreground.eval(fragCoord * uvScaleFgd + uvOffsetFgd);
+ vec4 color = vec4(0., 0., 0., 1.);
+
+ // Generate small glass rain.
+ GlassRain drippingRain = generateGlassRain(
+ uv,
+ screenAspectRatio,
+ time,
+ /* Grid size = */ vec2(4.0, 2.0),
+ /* rain intensity = */ 0.8);
+ float dropMask = drippingRain.dropMask;
+ float droppletsMask = drippingRain.droppletsMask;
+ float trailMask = drippingRain.trailMask;
+ vec2 dropUvMasked = drippingRain.drop * drippingRain.dropMask;
+ vec2 droppletsUvMasked = drippingRain.dropplets * drippingRain.droppletsMask;
+
+ // Generate medium glass rain and combine with small one.
+ drippingRain = generateGlassRain(
+ uv,
+ screenAspectRatio,
+ time * 1.267,
+ /* Grid size = */ vec2(3.0, 1.0),
+ /* rain intensity = */ 0.6);
+ dropMask = max(drippingRain.dropMask, dropMask);
+ droppletsMask = max(drippingRain.droppletsMask, droppletsMask);
+ trailMask = max(drippingRain.trailMask, trailMask);
+ dropUvMasked = mix(dropUvMasked,
+ drippingRain.drop * drippingRain.dropMask, drippingRain.dropMask);
+ droppletsUvMasked = mix(droppletsUvMasked,
+ drippingRain.dropplets * drippingRain.droppletsMask, drippingRain.droppletsMask);
+
+ /* Generate distortion UVs. */
+ // UV distortion for the drop and dropplets.
+ float distortionDrop = 1.0;
+ float distortionDropplets = 0.6;
+ vec2 uvDiffractionOffsets =
+ distortionDrop * dropUvMasked + distortionDropplets * droppletsUvMasked;
+
+ // Get color of the background texture.
+ color = background.eval(uvTextureBgd + uvDiffractionOffsets * screenSize);
+
+ // Add some slight tint to the fog glass. Since we use gray, we reduce the contrast.
+ vec3 blurredImage = mix(blurredBackground.eval(uvTextureBgd).rgb, glassTint.rgb, 0.07);
+ // The blur mask (when we will show the regular background).
+ float blurMask = smoothstep(0.5, 1., max(trailMask, max(dropMask, droppletsMask)));
+ color.rgb = mix(blurredImage, color.rgb, blurMask);
+
+ /*
+ * Drop coloring. This section is important to make the drops visible when we have a solid
+ * color as a background (since we rely normally on the UV distortion).
+ */
+ // Tint the rain drops.
+ color.rgb = mix(
+ color.rgb,
+ dropTint.rgb,
+ dropTintIntensity * smoothstep(0.7, 1., max(dropMask, droppletsMask)));
+ // add highlight to drops.
+ color.rgb = mix(
+ color.rgb,
+ highlightColor.rgb,
+ uHighlightIntensity
+ // Adjust this scalars to make it visible.
+ * smoothstep(0.05, 0.08, max(dropUvMasked * 1.4, droppletsUvMasked * 1.7)).x);
+ // add shadows to drops.
+ color.rgb = mix(
+ color.rgb,
+ contactShadowColor.rgb,
+ uDropShadowIntensity *
+ // Adjust this scalars to make it visible.
+ smoothstep(0.055, 0.1, max(length(dropUvMasked * 1.7),
+ length(droppletsUvMasked * 1.9))));
+
+ // Add rotation for the rain (as a default sin(time * 0.05) can be used).
+ float variation = wiggle(time - uv.y * 1.1, 0.10);
+ uv = rotateAroundPoint(uv, vec2(0.5, -1.42), variation * PI / 9.);
+
+ // Generate rain behind the subject.
+ Rain rain = generateRain(
+ uv,
+ screenAspectRatio,
+ time * 18.,
+ /* Grid size = */ vec2(30.0, 4.0),
+ /* rain intensity = */ rainIntensity);
+
+ color.rgb = mix(color.rgb, highlightColor.rgb, rainVisibility * rain.dropMask);
+
+ // Add the foreground. Any effect from here will be in front of the subject.
+ color.rgb = normalBlend(color.rgb, colorForeground.rgb, colorForeground.a);
+
+ // Generate rain in front of the subject (bigger and faster).
+ rain = generateRain(
+ uv,
+ screenAspectRatio,
+ time * 27.,
+ /* Grid size = */ vec2(8.0, 3.0),
+ /* rain intensity = */ rainIntensity);
+
+ // The rain that is closer, make it less visible
+ color.rgb = mix(color.rgb, highlightColor.rgb, 0.7 * rainVisibility * rain.dropMask);
+
+ /* Debug rain drops on glass */
+ // resets color.
+ // color.rgb *= 0.;
+ // Shows the UV of each cell.
+ // color.rg = drippingRain.cellUv.xy;
+ // Shows the grid.
+ // if (drippingRain.cellUv.x > 0.49 || drippingRain.cellUv.y > 0.49) color.r = 1.0;
+ // Shows the main drop mask.
+ // color.rgb += drippingRain.dropMask;
+ // Shows the dropplets mask.
+ // color.rgb += drippingRain.droppletsMask;
+ // Shows the trail.
+ // color.rgb += drippingRain.trailMask * 0.3;
+ // background blurMask.
+ // color.rgb += blurMask;
+ // tears uv.
+ // color.rg += -droppletsUvMasked;
+
+ /* Debug rain */
+ // resets color.
+ // color.rgb *= 0.;
+ // color.rgb += rain.dropMask;
+ // if (rain.cellUv.x > 0.49 || rain.cellUv.y > 0.49) color.r = 1.0;
+
+ return color;
+}
diff --git a/weathereffects/assets/shaders/simplex2d.agsl b/weathereffects/assets/shaders/simplex2d.agsl
new file mode 100644
index 0000000..0ec3316
--- /dev/null
+++ b/weathereffects/assets/shaders/simplex2d.agsl
@@ -0,0 +1,109 @@
+/*
+ * 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.
+ */
+
+const float SKEW = 0.366025404; // (sqrt(3)-1)/2
+const float UNSKEW = 0.211324865; // (3-sqrt(3))/6
+
+half2 hash2d(vec2 p) {
+ p = vec2(dot(p,vec2(157.1, 235.7)), dot(p,vec2(573.5, 13.3)));
+ return fract(sin(p) * 877.343) * 2. -1.; // [-1, 1]
+}
+
+half hash1d(vec2 p) {
+ return fract(sin(dot(p, vec2(343.0, 49.0)))) * 2. -1.; // [-1, 1]
+}
+
+vec2 getVectorFromAngle(float theta) {
+ return vec2(cos(theta), sin(theta));
+}
+
+// Returns kernel summation from the given simplex vertices (v0, v1, v2), and their corresponding
+// gradients (g0, g1, g2).
+float kernel_summation(vec2 v0, vec2 v1, vec2 v2, vec2 g0, vec2 g1, vec2 g2) {
+ vec3 w = max(0.5 - vec3(dot(v0, v0), dot(v1, v1), dot(v2, v2)), 0.0);
+
+ w = w*w*w*w;
+ vec3 n = w * vec3(dot(v0, g0), dot(v1, g1), dot(v2, g2));
+
+ return dot(n, vec3(32.0));
+}
+
+// 2D Simplex noise with dynamic gradient vectors. Return value [-1, 1].
+//
+// This method produces similar visuals to Simplex noise 3D, but at a lower computational cost.
+// The snapshot of the noise is the same as a regular Simplex noise. However, when animated, it
+// creates a swirling motion that is more suitable for flow-y effects.
+//
+// The difference in motion is not noticeable unless the following conditions are met:
+// 1) The rotation offset is identical for all vertex gradients.
+// 2) The noise is moving quickly.
+// 3) The noise is tiled.
+//
+// This method is recommended for use because it is significantly more performant than 3D Simplex
+// noise. It is especially useful for simulating fire, clouds, and fog, which all have advection.
+//
+// rot is an angle in radian that you want to step for each dt.
+float simplex2d_flow(vec2 p, float rot, float time) {
+ // Skew the input coordinate and find the simplex index.
+ vec2 i = floor(p + (p.x + p.y) * SKEW);
+ // First vertex of the triangle.
+ vec2 v0 = p - i + (i.x + i.y) * UNSKEW;
+
+ // Find two other vertices.
+ // Determines which triangle we should walk.
+ // If y>x, m=0 upper triangle, x>y, m=1 lower triangle.
+ float side = step(v0.y, v0.x);
+ vec2 walk = vec2(side, 1.0 - side);
+
+ vec2 v1 = v0 - walk + UNSKEW;
+ vec2 v2 = v0 - 1.0 + 2.*UNSKEW;
+
+ // Get random gradient vector.
+ vec2 g0 = hash2d(i);
+ vec2 g1 = hash2d(i+walk);
+ vec2 g2 = hash2d(i+1.0);
+
+ // Make the gradient vectors dynamic by adding rotations.
+ g0 += getVectorFromAngle(rot * time * hash1d(i));
+ g1 += getVectorFromAngle(rot * time * hash1d(i+walk));
+ g2 += getVectorFromAngle(rot * time * hash1d(i+1.));
+
+ return kernel_summation(v0, v1, v2, g0, g1, g2);
+}
+
+// 2D Simplex noise
+float simplex2d(vec2 p) {
+ // Skew the input coordinate and find the simplex index.
+ vec2 i = floor(p + (p.x + p.y) * SKEW);
+ // First vertex of the triangle.
+ vec2 v0 = p - i + (i.x + i.y) * UNSKEW;
+
+ // Find two other vertices.
+ // Determines which triangle we should walk.
+ // If y>x, m=0 upper triangle, x>y, m=1 lower triangle.
+ float side = step(v0.y, v0.x);
+ vec2 walk = vec2(side, 1.0 - side);
+
+ vec2 v1 = v0 - walk + UNSKEW;
+ vec2 v2 = v0 - 1.0 + 2.*UNSKEW;
+
+ // Get random gradient vector.
+ vec2 g0 = hash2d(i);
+ vec2 g1 = hash2d(i+walk);
+ vec2 g2 = hash2d(i+1.0);
+
+ return kernel_summation(v0, v1, v2, g0, g1, g2);
+}
diff --git a/weathereffects/assets/shaders/simplex3d.agsl b/weathereffects/assets/shaders/simplex3d.agsl
new file mode 100644
index 0000000..7c696f9
--- /dev/null
+++ b/weathereffects/assets/shaders/simplex3d.agsl
@@ -0,0 +1,110 @@
+/*
+ * 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.
+ */
+
+// Copied from frameworks/base/packages/SystemUI/animation/src/com/android/systemui/surfaceeffects/
+// shaderutil/ShaderUtilLibrary.kt
+
+// Return range [-1, 1].
+vec3 hash(vec3 p) {
+ p = fract(p * vec3(.3456, .1234, .9876));
+ p += dot(p, p.yxz + 43.21);
+ p = (p.xxy + p.yxx) * p.zyx;
+ return (fract(sin(p) * 4567.1234567) - .5) * 2.;
+}
+// Skew factors (non-uniform).
+const float SKEW = 0.3333333; // 1/3
+const float UNSKEW = 0.1666667; // 1/6
+
+// Return range roughly [-1,1].
+// It's because the hash function (that returns a random gradient vector) returns
+// different magnitude of vectors. Noise doesn't have to be in the precise range thus
+// skipped normalize.
+float simplex3d(vec3 p) {
+ // Skew the input coordinate, so that we get squashed cubical grid
+ vec3 s = floor(p + (p.x + p.y + p.z) * SKEW);
+
+ // Unskew back
+ vec3 u = s - (s.x + s.y + s.z) * UNSKEW;
+
+ // Unskewed coordinate that is relative to p, to compute the noise contribution
+ // based on the distance.
+ vec3 c0 = p - u;
+
+ // We have six simplices (in this case tetrahedron, since we are in 3D) that we
+ // could possibly in.
+ // Here, we are finding the correct tetrahedron (simplex shape), and traverse its
+ // four vertices (c0..3) when computing noise contribution.
+ // The way we find them is by comparing c0's x,y,z values.
+ // For example in 2D, we can find the triangle (simplex shape in 2D) that we are in
+ // by comparing x and y values. i.e. x>y lower, x<y, upper triangle.
+ // Same applies in 3D.
+ //
+ // Below indicates the offsets (or offset directions) when c0=(x0,y0,z0)
+ // x0>y0>z0: (1,0,0), (1,1,0), (1,1,1)
+ // x0>z0>y0: (1,0,0), (1,0,1), (1,1,1)
+ // z0>x0>y0: (0,0,1), (1,0,1), (1,1,1)
+ // z0>y0>x0: (0,0,1), (0,1,1), (1,1,1)
+ // y0>z0>x0: (0,1,0), (0,1,1), (1,1,1)
+ // y0>x0>z0: (0,1,0), (1,1,0), (1,1,1)
+ //
+ // The rule is:
+ // * For offset1, set 1 at the max component, otherwise 0.
+ // * For offset2, set 0 at the min component, otherwise 1.
+ // * For offset3, set 1 for all.
+ //
+ // Encode x0-y0, y0-z0, z0-x0 in a vec3
+ vec3 en = c0 - c0.yzx;
+
+ // Each represents whether x0>y0, y0>z0, z0>x0
+ en = step(vec3(0.), en);
+
+ // en.zxy encodes z0>x0, x0>y0, y0>x0
+ vec3 offset1 = en * (1. - en.zxy); // find max
+ vec3 offset2 = 1. - en.zxy * (1. - en); // 1-(find min)
+ vec3 offset3 = vec3(1.);
+
+ vec3 c1 = c0 - offset1 + UNSKEW;
+ vec3 c2 = c0 - offset2 + UNSKEW * 2.;
+ vec3 c3 = c0 - offset3 + UNSKEW * 3.;
+
+ // Kernel summation: dot(max(0, r^2-d^2))^4, noise contribution)
+ //
+ // First compute d^2, squared distance to the point.
+ vec4 w; // w = max(0, r^2 - d^2))
+ w.x = dot(c0, c0);
+ w.y = dot(c1, c1);
+ w.z = dot(c2, c2);
+ w.w = dot(c3, c3);
+
+ // Noise contribution should decay to zero before they cross the simplex boundary.
+ // Usually r^2 is 0.5 or 0.6;
+ // 0.5 ensures continuity but 0.6 increases the visual quality for the application
+ // where discontinuity isn't noticeable.
+ w = max(0.6 - w, 0.);
+
+ // Noise contribution from each point.
+ vec4 nc;
+ nc.x = dot(hash(s), c0);
+ nc.y = dot(hash(s + offset1), c1);
+ nc.z = dot(hash(s + offset2), c2);
+ nc.w = dot(hash(s + offset3), c3);
+
+ nc *= w * w * w * w;
+
+ // Add all the noise contributions.
+ // Should multiply by the possible max contribution to adjust the range in [-1,1].
+ return dot(vec4(32.), nc);
+}
diff --git a/weathereffects/assets/shaders/snow.agsl b/weathereffects/assets/shaders/snow.agsl
new file mode 100644
index 0000000..7e54a86
--- /dev/null
+++ b/weathereffects/assets/shaders/snow.agsl
@@ -0,0 +1,116 @@
+/*
+ * 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.
+ */
+
+struct Snow {
+ highp float flakeMask;
+ highp vec2 cellUv;
+};
+
+const mat2 rot45 = mat2(
+ 0.7071067812, 0.7071067812, // First column.
+ -0.7071067812, 0.7071067812 // second column.
+);
+
+/**
+ * Generates snow flakes.
+ *
+ * @param uv the UV of the fragment where we will display the snow effect.
+ * @param screenAspectRatio the aspect ratio of the fragment where we will display the effect.
+ * @param time the elapsed time.
+ * @param snowGridSize the size of the grid, where each cell contains a snow flake.
+ * @param layerNumber the layer of snow that we want to draw. Higher is farther from camera.
+ *
+ * @returns Snow with the snow info.
+ */
+Snow generateSnow(
+ // UVs of the target fragment (normalized).
+ in vec2 uv,
+ in float screenAspectRatio,
+ in float time,
+ in vec2 snowGridSize,
+ in float layerNumber
+) {
+ /* Grid. */
+ // Increase the last number to make each layer more separate from the previous one.
+ float depth = 1. + layerNumber * 0.35;
+ float speedAdj = 1. + layerNumber * 0.15;
+ float layerR = idGenerator(layerNumber);
+ snowGridSize *= depth;
+ time += layerR * 58.3;
+ // Number of rows and columns (each one is a cell, a drop).
+ float cellAspectRatio = snowGridSize.x / snowGridSize.y;
+ // Aspect ratio impacts visible cells.
+ snowGridSize.y /= screenAspectRatio;
+ // Skew uv.x so it goes to left or right
+ uv.x += uv.y * (0.8 * layerR - 0.4);
+ // scale the UV to allocate number of rows and columns.
+ vec2 gridUv = uv * snowGridSize;
+ // Invert y (otherwise it goes from 0=top to 1=bottom).
+ gridUv.y = 1. - gridUv.y;
+ float verticalGridPos = 0.4 * time / speedAdj;
+ // Move grid vertically down.
+ gridUv.y += verticalGridPos;
+ // Generate column id, to offset columns vertically (so snow flakes are not aligned).
+ float columnId = idGenerator(floor(gridUv.x));
+ gridUv.y += columnId * 2.6;
+
+ /* Cell. */
+ // Get the cell ID based on the grid position. Value from 0 to 1.
+ float cellId = idGenerator(floor(gridUv));
+ // For each cell, we set the internal UV from -0.5 (left, bottom) to 0.5 (right, top).
+ vec2 cellUv = fract(gridUv) - 0.5;
+ cellUv.y *= -1.;
+
+ /* Cell-id-based variations. */
+ // Adjust time based on columnId.
+ // Adjusts scale of each snow flake (higher is smaller).
+ float scaleVariation = 2.0 + 2.7 * cellId;
+ float opacityVariation = (1. - 0.9 * cellId);
+
+ /* Cell snow flake. */
+
+ // Horizontal movement: Wiggle.
+ float wiggleSpeed = 3.0;
+ // Adjust wiggle based on layer number (0 = closer to screen => we want less movement).
+ float wiggleAmp = 0.4 + 0.4 * smoothstep(0.5, 2.5, layerNumber);
+ // Define the start based on the cell id.
+ float horizontalStartAmp = 0.5;
+ // Add the wiggle (equation decided by testing in Grapher).
+ float horizontalWiggle = wiggle(uv.y + cellId * 2.1, wiggleSpeed * speedAdj);
+
+ // Add the start and wiggle and make that when we are closer to the edge, we don't wiggle much
+ // (so the drop doesn't go outside it's cell).
+ horizontalWiggle = horizontalStartAmp * wiggleAmp * horizontalWiggle;
+
+ // Calculate main cell drop.
+ float snowFlakePosUncorrected = (cellUv.x - horizontalWiggle);
+
+ // Calculate snow flake.
+ vec2 snowFlakeShape = vec2(1., 1.2);
+ vec2 snowFlakePos = vec2(snowFlakePosUncorrected / cellAspectRatio, cellUv.y);
+ snowFlakePos -= vec2(0., uv.y - 0.5) * cellId;
+ snowFlakePos *= snowFlakeShape * scaleVariation;
+ vec2 snowFlakePosR = 1.016 * abs(rot45 * (snowFlakePos + (cellId * 2. - 1.) * vec2(0.050)));
+ snowFlakePos = abs(snowFlakePos);
+ // Create the snowFlake mask.
+ float flakeMask = smoothstep(
+ 0.3,
+ 0.200 - 0.3 * opacityVariation,
+ snowFlakePos.x + snowFlakePos.y + snowFlakePosR.x + snowFlakePosR.y
+ ) * opacityVariation;
+
+ return Snow(flakeMask, cellUv);
+}
diff --git a/weathereffects/assets/shaders/snow_accumulation.agsl b/weathereffects/assets/shaders/snow_accumulation.agsl
new file mode 100644
index 0000000..baa34e6
--- /dev/null
+++ b/weathereffects/assets/shaders/snow_accumulation.agsl
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+
+uniform shader foreground;
+uniform float2 imageSize;
+
+#include "shaders/simplex2d.agsl"
+#include "shaders/utils.agsl"
+
+float random(vec2 uv) {
+ return fract(sin(dot(uv.xy, vec2(14.53898, 56.233))) * 45312.644263742);
+}
+
+vec4 main(float2 fragCoord) {
+ // fragCoord should be already the adjusted UVs to have the expected rect of the image.
+ float variation = 0.5 + 0.5 * simplex2d(25. * fragCoord / imageSize.xx);
+ float distance = 8. * variation;
+ float aN = foreground.eval(fragCoord + vec2(0., distance)).a;
+ float aS = foreground.eval(fragCoord + vec2(0., -distance)).a;
+ float dY = (aN - aS) * 0.5;
+ dY = max(dY, 0.0);
+
+ float accumulatedSnow = smoothstep(0.1, 1.8, sqrt(dY * dY) * 5.0);
+ vec4 color = vec4(0., 0., 0., 1.);
+ color.r = accumulatedSnow;
+ color.g = random(fragCoord / imageSize.xx);
+ color.b = variation;
+ return color;
+}
diff --git a/weathereffects/assets/shaders/snow_effect.agsl b/weathereffects/assets/shaders/snow_effect.agsl
new file mode 100644
index 0000000..af040a6
--- /dev/null
+++ b/weathereffects/assets/shaders/snow_effect.agsl
@@ -0,0 +1,98 @@
+/*
+ * 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.
+ */
+
+uniform shader foreground;
+uniform shader background;
+uniform shader accumulatedSnow;
+uniform shader blurredBackground;
+uniform float2 uvOffsetFgd;
+uniform float2 uvScaleFgd;
+uniform float2 uvOffsetBgd;
+uniform float2 uvScaleBgd;
+uniform float time;
+uniform float screenAspectRatio;
+uniform float2 screenSize;
+
+#include "shaders/constants.agsl"
+#include "shaders/utils.agsl"
+#include "shaders/snow.agsl"
+
+/* Constants that can be modified. */
+// Snow tint.
+const vec4 snowColor = vec4(vec3(0.9), 1.);
+// Glass tint.
+const vec4 glassTint = vec4(vec3(0.8), 1.); // gray
+
+// snow opacity (how visible it is).
+const float snowOpacity = 1.4;
+
+// how frosted the glass is.
+const float frostedGlassIntensity = 0.07;
+
+vec4 main(float2 fragCoord) {
+ float2 uv = fragCoord / screenSize;
+ // Adjusts the UVs to have the expected rect of the image.
+ float2 adjustedUvForeground = fragCoord * uvScaleFgd + uvOffsetFgd;
+ vec4 colorForeground = foreground.eval(adjustedUvForeground);
+ vec4 colorBackground = background.eval(fragCoord * uvScaleBgd + uvOffsetBgd);
+
+ vec4 color = vec4(0., 0., 0., 1.);
+
+ // Add some slight tint to the frosted glass.
+
+ // Get color of the background texture.
+ color.rgb = mix(colorBackground.rgb, glassTint.rgb, frostedGlassIntensity);
+ for (float i = 9.; i > 2.; i--) {
+ // Generate snow behind the subject.
+ Snow snow = generateSnow(
+ uv,
+ screenAspectRatio,
+ time * 1.25,
+ /* Grid size = */ vec2(2.1, 1.4),
+ /* layer number = */ i);
+
+ color.rgb = mix(color.rgb, snowColor.rgb, snowOpacity * snow.flakeMask);
+ }
+
+ // Add the foreground. Any effect from here will be in front of the subject.
+ color.rgb = mix(color.rgb, colorForeground.rgb, colorForeground.a);
+
+ // Add accumulated snow.
+ vec2 accSnow = accumulatedSnow.eval(adjustedUvForeground).rg;
+ float snowLayer = smoothstep(0.2, 0.8, accSnow.r);
+ float snowTexture = smoothstep(0.2, 0.7, accSnow.g);
+ color.rgb = mix(color.rgb, vec3(0.95), 0.98 * snowLayer * (0.05 + 0.95 * snowTexture));
+
+ for (float i = 2.; i >= 0.; i--) {
+ // Generate snow behind the subject.
+ Snow snow = generateSnow(
+ uv,
+ screenAspectRatio,
+ time * 1.25,
+ /* Grid size = */ vec2(2.1, 1.4),
+ /* layer number = */ i);
+
+ color.rgb = mix(color.rgb, snowColor.rgb, snowOpacity * snow.flakeMask);
+ }
+
+ /* Debug snow */
+ // resets color.
+ // color.rgb *= 0.;
+ // color.rgb += snow.flakeMask;
+ // if (snow.cellUv.x > 0.49 || snow.cellUv.y > 0.49) color.r = 1.0;
+
+ return color;
+}
diff --git a/weathereffects/assets/shaders/utils.agsl b/weathereffects/assets/shaders/utils.agsl
new file mode 100644
index 0000000..ffc651a
--- /dev/null
+++ b/weathereffects/assets/shaders/utils.agsl
@@ -0,0 +1,73 @@
+/*
+ * 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.
+ */
+
+highp float idGenerator(vec2 point) {
+ vec2 p = fract(point * vec2(723.123, 236.209));
+ p += dot(p, p + 17.1512);
+ return fract(p.x * p.y);
+}
+
+highp float idGenerator(float value) {
+ return idGenerator(vec2(value, 1.412));
+}
+
+mat2 rotationMat(float angleRad) {
+ float c=cos(angleRad);
+ float s=sin(angleRad);
+ // | c -s |
+ // | s c |
+ return mat2(
+ c, s, // First column.
+ -s, c // second column.
+ );
+}
+
+vec2 rotateAroundPoint(vec2 point, vec2 centerPoint, float angleRad) {
+ return (point - centerPoint) * rotationMat(angleRad) + centerPoint;
+}
+
+// function created on Grapher (equation decided by testing in Grapher).
+float wiggle(float time, float wiggleSpeed) {
+ return sin(wiggleSpeed * time + 0.5 * sin(wiggleSpeed * 5. * time))
+ * sin(wiggleSpeed * time) - 0.5;
+}
+
+// Noise range of [-1.0, 1.0[ with triangle distribution.
+float triangleNoise(vec2 n) {
+ n = fract(n * vec2(5.3987, 5.4421));
+ n += dot(n.yx, n.xy + vec2(21.5351, 14.3137));
+ float xy = n.x * n.y;
+ // compute in [0..2[ and remap to [-1.0..1.0[
+ return fract(xy * 95.4307) + fract(xy * 75.04961) - 1.0;
+}
+
+/*
+ * This is the normal blend mode in which the foreground is painted on top of the background based
+ * on the foreground opacity.
+ *
+ * @param b the background color.
+ * @param f the foreground color.
+ * @param o the mask or the foreground alpha.
+ *
+ * Note: this blending function expects the foreground to have premultiplied alpha.
+ */
+vec3 normalBlend(vec3 b, vec3 f, float o) {
+ return b * (1. - o) + f;
+}
+
+vec3 normalBlendWithWhiteSrc(vec3 b, float o) {
+ return b * (1. - o) + o;
+}
diff --git a/weathereffects/assets/textures/lut_rain_and_fog.png b/weathereffects/assets/textures/lut_rain_and_fog.png
new file mode 100644
index 0000000..7e8af9c
--- /dev/null
+++ b/weathereffects/assets/textures/lut_rain_and_fog.png
Binary files differ
diff --git a/weathereffects/build.gradle b/weathereffects/build.gradle
new file mode 100644
index 0000000..ee64e35
--- /dev/null
+++ b/weathereffects/build.gradle
@@ -0,0 +1,161 @@
+// 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.
+buildscript {
+ ext.versions = [
+ 'gradle' : '7.4.2',
+ 'minSdk' : 34,
+ 'targetSdk' : 34,
+ 'compileSdk' : 34,
+ 'buildTools' : '30.0.3',
+ 'kotlin' : '1.6.21',
+ 'ktx' : '1.10.1',
+ 'coroutines' : '1.6.4',
+ 'appcompat' : '1.6.1',
+ 'androidXLib' : '1.1.0-alpha02',
+ 'androidXRun' : '1.1.0-alpha4',
+ 'guava' : '31.0.1-android',
+ 'filament' : '1.12.5',
+ 'dagger' : '2.44',
+ 'material' : '1.9.0',
+ 'junit' : '4.13.2',
+ 'androidXTest' : '1.5.0',
+ 'mockito' : '2.28.3',
+ ]
+
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath "com.android.tools.build:gradle:$versions.gradle"
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0"
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'org.jetbrains.kotlin.kapt'
+
+android {
+ compileSdk versions.compileSdk
+
+ defaultConfig {
+ applicationId 'com.google.android.wallpaper.weathereffects'
+ minSdk versions.minSdk
+ targetSdk versions.targetSdk
+ versionCode 1
+ versionName '0.1.0'
+ signingConfig signingConfigs.debug
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ sourceSets {
+ main {
+ // TODO: Split out debug source.
+ java.srcDirs = ["${rootDir}/src", "${rootDir}/debug/src"]
+ res.srcDirs = ["${rootDir}/res", "${rootDir}/debug/res"]
+ assets.srcDirs = ["${rootDir}/assets"]
+ manifest.srcFile "AndroidManifest.xml"
+ }
+
+ debug {
+ java.srcDirs = ["${rootDir}/debug/src"]
+ res.srcDirs = ["${rootDir}/debug/res"]
+ assets.srcDirs = ["${rootDir}/debug/assets"]
+ manifest.srcFile "debug/AndroidManifest.xml"
+ }
+
+ test {
+ java.srcDirs = ["${rootDir}/unitTests/src"]
+ res.srcDirs = ["${rootDir}/unitTests/res"]
+ }
+
+ androidTest {
+ java.srcDirs = ["${rootDir}/tests/src"]
+ res.srcDirs = ["${rootDir}/tests/res"]
+ }
+ }
+
+ buildTypes {
+ debug {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ testCoverageEnabled true
+ }
+
+ release {
+ minifyEnabled true
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ testCoverageEnabled true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
+}
+
+dependencies {
+ implementation project(':toruslib')
+
+ implementation "androidx.slice:slice-builders:$versions.androidXLib"
+ implementation "androidx.slice:slice-core:$versions.androidXLib"
+ implementation "androidx.core:core-ktx:$versions.ktx"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutines"
+ implementation "androidx.appcompat:appcompat:$versions.appcompat"
+ implementation "androidx.constraintlayout:constraintlayout:2.1.4"
+
+ debugImplementation "com.google.android.material:material:$versions.material"
+
+ androidTestImplementation "junit:junit:$versions.junit"
+ androidTestImplementation "androidx.test:core:$versions.androidXTest"
+ androidTestImplementation "androidx.test:rules:$versions.androidXTest"
+ androidTestImplementation "androidx.test:runner:1.5.2"
+ androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
+ androidTestImplementation "com.google.truth:truth:1.1.3"
+ androidTestImplementation "org.mockito:mockito-core:5.3.1"
+ androidTestImplementation "com.linkedin.dexmaker:dexmaker-mockito-inline:$versions.mockito"
+ androidTestImplementation "com.linkedin.dexmaker:dexmaker-mockito-inline-extended:$versions.mockito"
+ androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$versions.coroutines"
+ androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$versions.coroutines"
+
+ // Dagger
+ api "com.google.dagger:dagger:$versions.dagger"
+ api "com.google.dagger:dagger-android:$versions.dagger"
+ kapt "com.google.dagger:dagger-compiler:$versions.dagger"
+ kapt "com.google.dagger:dagger-android-processor:$versions.dagger"
+ kaptAndroidTest "com.google.dagger:dagger-compiler:$versions.dagger"
+ kaptAndroidTest "com.google.dagger:dagger-android-processor:$versions.dagger"
+}
diff --git a/weathereffects/debug/AndroidManifest.xml b/weathereffects/debug/AndroidManifest.xml
new file mode 100644
index 0000000..91b19cd
--- /dev/null
+++ b/weathereffects/debug/AndroidManifest.xml
@@ -0,0 +1,41 @@
+<?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"
+ xmlns:tools="http://schemas.android.com/tools"
+ package="com.google.android.wallpaper.weathereffects">
+ <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
+
+ <application
+ android:name=".WallpaperEffectsDebugApplication"
+ tools:replace="android:name">
+ <activity
+ android:name=".WallpaperEffectsDebugActivity"
+ android:configChanges="uiMode"
+ android:exported="true"
+ android:theme="@style/Theme.WeatherTheme.NoActionBar"
+ android:finishOnTaskLaunch="true"
+ android:label="Weather Effects"
+ android:launchMode="singleInstance">
+
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+ </application>
+
+</manifest>
diff --git a/weathereffects/debug/assets/test-background.png b/weathereffects/debug/assets/test-background.png
new file mode 100644
index 0000000..7d72050
--- /dev/null
+++ b/weathereffects/debug/assets/test-background.png
Binary files differ
diff --git a/weathereffects/debug/assets/test-foreground.png b/weathereffects/debug/assets/test-foreground.png
new file mode 100644
index 0000000..1a774f5
--- /dev/null
+++ b/weathereffects/debug/assets/test-foreground.png
Binary files differ
diff --git a/weathereffects/debug/res/drawable/ic_baseline_check_24.xml b/weathereffects/debug/res/drawable/ic_baseline_check_24.xml
new file mode 100644
index 0000000..c885f06
--- /dev/null
+++ b/weathereffects/debug/res/drawable/ic_baseline_check_24.xml
@@ -0,0 +1,25 @@
+<!--
+ 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="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:tint="?attr/colorControlNormal">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
+</vector>
diff --git a/weathereffects/debug/res/drawable/ic_baseline_image_search_24.xml b/weathereffects/debug/res/drawable/ic_baseline_image_search_24.xml
new file mode 100644
index 0000000..fe3f420
--- /dev/null
+++ b/weathereffects/debug/res/drawable/ic_baseline_image_search_24.xml
@@ -0,0 +1,22 @@
+<?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 android:height="24dp" android:tint="#576501"
+ android:viewportHeight="24" android:viewportWidth="24"
+ android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillColor="@android:color/white" android:pathData="M18,13v7L4,20L4,6h5.02c0.05,-0.71 0.22,-1.38 0.48,-2L4,4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2v-5l-2,-2zM16.5,18h-11l2.75,-3.53 1.96,2.36 2.75,-3.54zM19.3,8.89c0.44,-0.7 0.7,-1.51 0.7,-2.39C20,4.01 17.99,2 15.5,2S11,4.01 11,6.5s2.01,4.5 4.49,4.5c0.88,0 1.7,-0.26 2.39,-0.7L21,13.42 22.42,12 19.3,8.89zM15.5,9C14.12,9 13,7.88 13,6.5S14.12,4 15.5,4 18,5.12 18,6.5 16.88,9 15.5,9z"/>
+</vector>
+
diff --git a/weathereffects/debug/res/layout/debug_activity.xml b/weathereffects/debug/res/layout/debug_activity.xml
new file mode 100644
index 0000000..a05fd5e
--- /dev/null
+++ b/weathereffects/debug/res/layout/debug_activity.xml
@@ -0,0 +1,109 @@
+<?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.
+-->
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/main_layout"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:keepScreenOn="true"
+ android:layout_height="match_parent"
+ tools:context=".WallpaperEffectsDebugActivity">
+
+ <FrameLayout
+ android:id="@+id/wallpaper_layout"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"/>
+
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:id="@+id/buttons"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <TextView
+ android:id="@+id/output"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="20dp"
+ android:padding="10dp"
+ android:background="#37000000"
+ android:textColor="#ffffff"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <Button
+ android:id="@+id/rain"
+ android:text="@string/button_rain"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:layout_constraintBottom_toTopOf="@id/fog"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginBottom="10dp"
+ android:layout_marginEnd="20dp" />
+
+ <Button
+ android:id="@+id/fog"
+ android:text="@string/button_fog"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:layout_constraintBottom_toTopOf="@id/snow"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginBottom="10dp"
+ android:layout_marginEnd="20dp" />
+
+ <Button
+ android:id="@+id/snow"
+ android:text="@string/button_snow"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:layout_constraintBottom_toTopOf="@id/clear"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginBottom="10dp"
+ android:layout_marginEnd="20dp" />
+
+ <Button
+ android:id="@+id/clear"
+ android:text="@string/button_clear"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:layout_constraintBottom_toTopOf="@id/change_asset"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginBottom="10dp"
+ android:layout_marginEnd="20dp" />
+
+ <Button
+ android:id="@+id/change_asset"
+ android:text="@string/change_asset"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:layout_constraintBottom_toTopOf="@id/set_wallpaper"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginBottom="30dp"
+ android:layout_marginEnd="20dp" />
+
+ <Button
+ android:id="@+id/set_wallpaper"
+ android:text="@string/set_wallpaper"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginBottom="30dp"
+ android:layout_marginEnd="20dp" />
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+</FrameLayout>
diff --git a/weathereffects/debug/res/values/colors.xml b/weathereffects/debug/res/values/colors.xml
new file mode 100644
index 0000000..297e7be
--- /dev/null
+++ b/weathereffects/debug/res/values/colors.xml
@@ -0,0 +1,21 @@
+<?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.
+-->
+<resources>
+ <color name="status_bar">#EDF2EB</color> <!-- 14% black -->
+ <color name="clear_camo_brown">#F0F4D0</color> <!-- 14% black -->
+ <color name="dark_camo_brown">#576500</color> <!-- 14% black -->
+</resources>
diff --git a/weathereffects/debug/res/values/strings.xml b/weathereffects/debug/res/values/strings.xml
new file mode 100644
index 0000000..b5bb889
--- /dev/null
+++ b/weathereffects/debug/res/values/strings.xml
@@ -0,0 +1,27 @@
+<?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.
+-->
+<resources>
+ <string name="debug_open_image" translatable="false">Edit</string>
+ <string name="generating" translatable="false">Generating...</string>
+ <string name="button_generate" translatable="false">Generate</string>
+ <string name="set_wallpaper" translatable="false">Set Wallpaper</string>
+ <string name="button_rain" translatable="false">Rain</string>
+ <string name="button_fog" translatable="false">Fog</string>
+ <string name="button_snow" translatable="false">Snow</string>
+ <string name="button_clear" translatable="false">Clear Weather</string>
+ <string name="change_asset" translatable="false">Change Asset</string>
+</resources>
diff --git a/weathereffects/debug/res/values/themes.xml b/weathereffects/debug/res/values/themes.xml
new file mode 100644
index 0000000..4158535
--- /dev/null
+++ b/weathereffects/debug/res/values/themes.xml
@@ -0,0 +1,23 @@
+<!--
+ 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.
+-->
+<resources>
+ <style name="Theme.WeatherTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
+ <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
+ <item name="android:statusBarColor">@color/status_bar</item>
+ <item name="android:navigationBarColor">@color/status_bar</item>
+ <item name="android:buttonStyle">@style/Widget.AppCompat.Button.Colored</item>
+ </style>
+</resources>
diff --git a/weathereffects/debug/src/com/google/android/wallpaper/weathereffects/WallpaperEffectsDebugActivity.kt b/weathereffects/debug/src/com/google/android/wallpaper/weathereffects/WallpaperEffectsDebugActivity.kt
new file mode 100644
index 0000000..afbd4e3
--- /dev/null
+++ b/weathereffects/debug/src/com/google/android/wallpaper/weathereffects/WallpaperEffectsDebugActivity.kt
@@ -0,0 +1,241 @@
+/*
+ * 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.google.android.wallpaper.weathereffects
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.annotation.SuppressLint
+import android.app.WallpaperManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.MotionEvent
+import android.view.SurfaceView
+import android.view.View
+import android.widget.Button
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import com.google.android.torus.core.activity.TorusViewerActivity
+import com.google.android.torus.core.engine.TorusEngine
+import com.google.android.torus.utils.extensions.setImmersiveFullScreen
+import com.google.android.wallpaper.weathereffects.dagger.BackgroundScope
+import com.google.android.wallpaper.weathereffects.dagger.MainScope
+import com.google.android.wallpaper.weathereffects.provider.WallpaperInfoContract
+import com.google.android.wallpaper.weathereffects.shared.model.WallpaperFileModel
+import com.google.android.wallpaper.weathereffects.domain.WeatherEffectsInteractor
+import java.io.File
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+class WallpaperEffectsDebugActivity : TorusViewerActivity() {
+
+ @Inject
+ @MainScope
+ lateinit var mainScope: CoroutineScope
+ @Inject
+ @BackgroundScope
+ lateinit var bgScope: CoroutineScope
+ @Inject
+ lateinit var context: Context
+ @Inject
+ lateinit var interactor: WeatherEffectsInteractor
+
+ private lateinit var rootView: FrameLayout
+ private lateinit var surfaceView: SurfaceView
+ private var engine: WeatherEngine? = null
+ private var weatherEffect: WallpaperInfoContract.WeatherEffect? = null
+ private var assetIndex = 0
+ private val fgCachedAssetPaths: ArrayList<String> = arrayListOf()
+ private val bgCachedAssetPaths: ArrayList<String> = arrayListOf()
+
+ override fun getWallpaperEngine(context: Context, surfaceView: SurfaceView): TorusEngine {
+ this.surfaceView = surfaceView
+ val engine = WeatherEngine(surfaceView.holder, context)
+ this.engine = engine
+ return engine
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ WallpaperEffectsDebugApplication.graph.inject(this)
+
+ setContentView(R.layout.debug_activity)
+ setImmersiveFullScreen()
+
+ writeAssetsToCache()
+
+ rootView = requireViewById(R.id.main_layout)
+ rootView.requireViewById<FrameLayout>(R.id.wallpaper_layout).addView(surfaceView)
+
+ rootView.requireViewById<Button>(R.id.rain).setOnClickListener {
+ weatherEffect = WallpaperInfoContract.WeatherEffect.RAIN
+ updateWallpaper()
+ setDebugText(context.getString(R.string.generating))
+ }
+ rootView.requireViewById<Button>(R.id.fog).setOnClickListener {
+ weatherEffect = WallpaperInfoContract.WeatherEffect.FOG
+ updateWallpaper()
+ setDebugText(context.getString(R.string.generating))
+ }
+ rootView.requireViewById<Button>(R.id.snow).setOnClickListener {
+ weatherEffect = WallpaperInfoContract.WeatherEffect.SNOW
+ updateWallpaper()
+ setDebugText(context.getString(R.string.generating))
+ }
+ rootView.requireViewById<Button>(R.id.clear).setOnClickListener {
+ weatherEffect = null
+
+ updateWallpaper()
+ }
+ rootView.requireViewById<Button>(R.id.change_asset).setOnClickListener {
+ assetIndex = (assetIndex + 1) % fgCachedAssetPaths.size
+
+ updateWallpaper()
+ }
+
+ rootView.requireViewById<Button>(R.id.set_wallpaper).setOnClickListener {
+ val i = Intent()
+ i.action = WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER
+ i.putExtra(
+ WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT,
+ ComponentName(this, WeatherWallpaperService::class.java)
+ )
+ this.startActivityForResult(i, SET_WALLPAPER_REQUEST_CODE)
+ saveWallpaper()
+ }
+
+ rootView.requireViewById<FrameLayout>(R.id.wallpaper_layout)
+ .setOnTouchListener { view, event ->
+ when (event?.action) {
+ MotionEvent.ACTION_DOWN -> {
+ if (rootView.requireViewById<ConstraintLayout>(R.id.buttons).visibility
+ == View.GONE) {
+ showButtons()
+ } else {
+ hideButtons()
+ }
+ }
+ }
+
+ view.onTouchEvent(event)
+ }
+
+ engine?.initialize(mainScope, interactor)
+ setDebugText()
+ }
+
+ private fun writeAssetsToCache() {
+ // Writes test files from assets to local cache dir
+ // (on the main thread!.. only ok for the debug app)
+ fgCachedAssetPaths.apply {
+ clear()
+ addAll(
+ listOf(
+ /* TODO(b/300991599): Add debug assets. */
+ FOREGROUND_IMAGE_1
+ ).map { getFileFromAssets(it).absolutePath })
+ }
+ bgCachedAssetPaths.apply {
+ clear()
+ addAll(
+ listOf(
+ /* TODO(b/300991599): Add debug assets. */
+ BACKGROUND_IMAGE_1
+ ).map { getFileFromAssets(it).absolutePath })
+ }
+ }
+
+ private fun getFileFromAssets(fileName: String): File {
+ return File(context.cacheDir, fileName).also {
+ if (!it.exists()) {
+ it.outputStream().use { cache ->
+ context.assets.open(fileName).use { inputStream ->
+ inputStream.copyTo(cache)
+ }
+ }
+ }
+ }
+ }
+
+ private fun updateWallpaper() {
+ mainScope.launch {
+ val fgPath = fgCachedAssetPaths[assetIndex]
+ val bgPath = bgCachedAssetPaths[assetIndex]
+ interactor.updateWallpaper(
+ WallpaperFileModel(
+ fgPath,
+ bgPath,
+ weatherEffect,
+ )
+ )
+ setDebugText("Wallpaper updated successfully.\n* Weather: " +
+ "$weatherEffect\n* Foreground: $fgPath\n* Background: $bgPath")
+ }
+ }
+
+ private fun saveWallpaper() {
+ bgScope.launch {
+ interactor.saveWallpaper()
+ }
+ }
+
+ private fun setDebugText(text: String? = null) {
+ val output = rootView.requireViewById<TextView>(R.id.output)
+ output.text = text
+
+ if (text.isNullOrEmpty()) {
+ output.visibility = View.INVISIBLE
+ } else {
+ output.visibility = View.VISIBLE
+ mainScope.launch {
+ // Make the text disappear after 3 sec.
+ delay(3000L)
+ output.visibility = View.INVISIBLE
+ }
+ }
+ }
+
+ private fun showButtons() {
+ val buttons = rootView.requireViewById<ConstraintLayout>(R.id.buttons)
+ buttons.visibility = View.VISIBLE
+ buttons.animate().alpha(1f).setDuration(400).setListener(null)
+ }
+
+ private fun hideButtons() {
+ val buttons = rootView.requireViewById<ConstraintLayout>(R.id.buttons)
+ buttons.animate()
+ .alpha(0f)
+ .setDuration(400)
+ .setListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ buttons.visibility = View.GONE
+ }
+ })
+ }
+
+ private companion object {
+ // TODO(b/300991599): Add debug assets.
+ private const val FOREGROUND_IMAGE_1 = "test-foreground.png"
+ private const val BACKGROUND_IMAGE_1 = "test-background.png"
+ private const val SET_WALLPAPER_REQUEST_CODE = 2
+ }
+}
diff --git a/weathereffects/debug/src/com/google/android/wallpaper/weathereffects/WallpaperEffectsDebugApplication.kt b/weathereffects/debug/src/com/google/android/wallpaper/weathereffects/WallpaperEffectsDebugApplication.kt
new file mode 100644
index 0000000..272ed3e
--- /dev/null
+++ b/weathereffects/debug/src/com/google/android/wallpaper/weathereffects/WallpaperEffectsDebugApplication.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.google.android.wallpaper.weathereffects
+
+import android.app.Application
+import android.content.Context
+import com.google.android.wallpaper.weathereffects.dagger.DaggerDebugApplicationComponent
+import com.google.android.wallpaper.weathereffects.dagger.DebugApplicationComponent
+import com.google.android.wallpaper.weathereffects.dagger.DependencyProvider
+
+class WallpaperEffectsDebugApplication: Application() {
+
+ override fun attachBaseContext(base: Context?) {
+ super.attachBaseContext(base)
+ graph = DaggerDebugApplicationComponent.builder()
+ .dependencyProvider(DependencyProvider(this))
+ .build()
+ }
+
+ companion object {
+ lateinit var graph: DebugApplicationComponent
+ }
+} \ No newline at end of file
diff --git a/weathereffects/debug/src/com/google/android/wallpaper/weathereffects/dagger/DebugApplicationComponent.kt b/weathereffects/debug/src/com/google/android/wallpaper/weathereffects/dagger/DebugApplicationComponent.kt
new file mode 100644
index 0000000..bf59a8b
--- /dev/null
+++ b/weathereffects/debug/src/com/google/android/wallpaper/weathereffects/dagger/DebugApplicationComponent.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.dagger
+
+import com.google.android.wallpaper.weathereffects.WallpaperEffectsDebugActivity
+import com.google.android.wallpaper.weathereffects.WeatherWallpaperService
+import com.google.android.wallpaper.weathereffects.provider.WeatherEffectsContentProvider
+import dagger.Component
+import javax.inject.Singleton
+
+@Singleton
+@Component(modules = [DependencyProvider::class])
+interface DebugApplicationComponent: ApplicationComponent {
+ fun inject(activity: WallpaperEffectsDebugActivity)
+
+ fun inject(contentProvider: WeatherEffectsContentProvider)
+
+ fun inject(wallpaperService: WeatherWallpaperService)
+}
diff --git a/weathereffects/gradle.properties b/weathereffects/gradle.properties
new file mode 100644
index 0000000..4df7b50
--- /dev/null
+++ b/weathereffects/gradle.properties
@@ -0,0 +1,18 @@
+# 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.
+
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.enableJetifier=true
+kotlin.code.style=official
diff --git a/weathereffects/includes.gradle b/weathereffects/includes.gradle
new file mode 100644
index 0000000..0b1ef66
--- /dev/null
+++ b/weathereffects/includes.gradle
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ */
+
+include(':toruslib')
+project(':toruslib').projectDir = new File(rootDir, '../toruslib/lib-torus')
diff --git a/weathereffects/res/drawable/ic_launcher_background.xml b/weathereffects/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..828968e
--- /dev/null
+++ b/weathereffects/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,185 @@
+<?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="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <path
+ android:fillColor="#3DDC84"
+ android:pathData="M0,0h108v108h-108z" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M9,0L9,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,0L19,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M29,0L29,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M39,0L39,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M49,0L49,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M59,0L59,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M69,0L69,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M79,0L79,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M89,0L89,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M99,0L99,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,9L108,9"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,19L108,19"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,29L108,29"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,39L108,39"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,49L108,49"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,59L108,59"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,69L108,69"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,79L108,79"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,89L108,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,99L108,99"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,29L89,29"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,39L89,39"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,49L89,49"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,59L89,59"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,69L89,69"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,79L89,79"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M29,19L29,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M39,19L39,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M49,19L49,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M59,19L59,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M69,19L69,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M79,19L79,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+</vector>
diff --git a/weathereffects/res/drawable/ic_launcher_foreground.xml b/weathereffects/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..38d3bae
--- /dev/null
+++ b/weathereffects/res/drawable/ic_launcher_foreground.xml
@@ -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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:aapt="http://schemas.android.com/aapt"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
+ <aapt:attr name="android:fillColor">
+ <gradient
+ android:endX="85.84757"
+ android:endY="92.4963"
+ android:startX="42.9492"
+ android:startY="49.59793"
+ android:type="linear">
+ <item
+ android:color="#44000000"
+ android:offset="0.0" />
+ <item
+ android:color="#00000000"
+ android:offset="1.0" />
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path
+ android:fillColor="#FFFFFF"
+ android:fillType="nonZero"
+ android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
+ android:strokeWidth="1"
+ android:strokeColor="#00000000" />
+</vector> \ No newline at end of file
diff --git a/iconloaderlib/res/drawable-v26/adaptive_icon_drawable_wrapper.xml b/weathereffects/res/mipmap-anydpi-v26/ic_launcher.xml
index 9f13cf5..678e9a6 100644
--- a/iconloaderlib/res/drawable-v26/adaptive_icon_drawable_wrapper.xml
+++ b/weathereffects/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- Copyright (C) 2017 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.
@@ -15,8 +15,6 @@
limitations under the License.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
- <background android:drawable="@color/legacy_icon_background"/>
- <foreground>
- <com.android.launcher3.icons.FixedScaleDrawable />
- </foreground>
-</adaptive-icon>
+ <background android:drawable="@drawable/ic_launcher_background" />
+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon> \ No newline at end of file
diff --git a/weathereffects/res/values/strings.xml b/weathereffects/res/values/strings.xml
new file mode 100644
index 0000000..fd86462
--- /dev/null
+++ b/weathereffects/res/values/strings.xml
@@ -0,0 +1,21 @@
+<?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.
+-->
+<resources>
+ <string name="app_name">Weather Effects</string>
+ <string name="wallpaper_description">Wallpaper with live weather effects</string>
+ <string name="google_author">Google</string>
+</resources> \ No newline at end of file
diff --git a/weathereffects/res/xml/weather_wallpaper.xml b/weathereffects/res/xml/weather_wallpaper.xml
new file mode 100644
index 0000000..3550669
--- /dev/null
+++ b/weathereffects/res/xml/weather_wallpaper.xml
@@ -0,0 +1,21 @@
+<?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.
+-->
+<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
+ android:author="@string/google_author"
+ android:description="@string/wallpaper_description"
+ android:showMetadataInPreview="true"
+ android:supportsAmbientMode="false"/>
diff --git a/weathereffects/settings.gradle b/weathereffects/settings.gradle
new file mode 100644
index 0000000..111921c
--- /dev/null
+++ b/weathereffects/settings.gradle
@@ -0,0 +1,17 @@
+// 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.
+
+apply from:'includes.gradle'
+
+rootProject.name = "Samsung Wallpaper Effects" \ No newline at end of file
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherEffect.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherEffect.kt
new file mode 100644
index 0000000..6c7b1aa
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherEffect.kt
@@ -0,0 +1,53 @@
+/*
+ * 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.google.android.wallpaper.weathereffects
+
+import android.graphics.Canvas
+import android.util.SizeF
+
+/** Defines a single weather effect with a main shader and a main LUT for color grading. */
+interface WeatherEffect {
+
+ /**
+ * Resizes the effect.
+ *
+ * @param newSurfaceSize the new size of the surface where we are showing the effect.
+ */
+ fun resize(newSurfaceSize: SizeF)
+
+ /**
+ * Updates the effect.
+ *
+ * @param deltaMillis The time in millis since the last time [onUpdate] was called.
+ * @param frameTimeNanos The time in nanoseconds from the previous Vsync frame, in the
+ * [System.nanoTime] timebase.
+ */
+ fun update(deltaMillis: Long, frameTimeNanos: Long)
+
+ /**
+ * Draw the effect.
+ *
+ * @param canvas the canvas where we have to draw the effect.
+ */
+ fun draw(canvas: Canvas)
+
+ /** Resets the effect. */
+ fun reset()
+
+ /** Releases the weather effect. */
+ fun release()
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherEngine.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherEngine.kt
new file mode 100644
index 0000000..5e82d4a
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherEngine.kt
@@ -0,0 +1,162 @@
+/*
+ * 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.google.android.wallpaper.weathereffects
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.util.Log
+import android.util.Size
+import android.util.SizeF
+import android.view.SurfaceHolder
+import com.google.android.torus.canvas.engine.CanvasWallpaperEngine
+import com.google.android.wallpaper.weathereffects.fog.FogEffect
+import com.google.android.wallpaper.weathereffects.fog.FogEffectConfig
+import com.google.android.wallpaper.weathereffects.none.NoEffect
+import com.google.android.wallpaper.weathereffects.provider.WallpaperInfoContract
+import com.google.android.wallpaper.weathereffects.rain.RainEffect
+import com.google.android.wallpaper.weathereffects.rain.RainEffectConfig
+import com.google.android.wallpaper.weathereffects.snow.SnowEffect
+import com.google.android.wallpaper.weathereffects.snow.SnowEffectConfig
+import com.google.android.wallpaper.weathereffects.shared.model.WallpaperImageModel
+import com.google.android.wallpaper.weathereffects.domain.WeatherEffectsInteractor
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+class WeatherEngine(
+ defaultHolder: SurfaceHolder,
+ private val context: Context,
+ hardwareAccelerated: Boolean = true
+) : CanvasWallpaperEngine(defaultHolder, hardwareAccelerated) {
+
+ private val currentAssets: WallpaperImageModel? = null
+ private var activeEffect: WeatherEffect? = null
+ private set(value) {
+ field = value
+ if (shouldTriggerUpdate()) {
+ startUpdateLoop()
+ } else {
+ stopUpdateLoop()
+ }
+ }
+
+ private var collectWallpaperImageJob: Job? = null
+ private lateinit var interactor: WeatherEffectsInteractor
+ private lateinit var applicationScope: CoroutineScope
+
+ override fun onCreate(isFirstActiveInstance: Boolean) {
+ Log.d(TAG, "Engine created.")
+ }
+
+ override fun onResize(size: Size) {
+ activeEffect?.resize(size.toSizeF())
+ if (activeEffect is NoEffect) {
+ render { canvas -> activeEffect!!.draw(canvas) }
+ }
+ }
+
+ fun initialize(
+ applicationScope: CoroutineScope,
+ interactor: WeatherEffectsInteractor,
+ ) {
+ this.interactor = interactor
+ this.applicationScope = applicationScope
+
+ if (interactor.wallpaperImageModel.value == null) {
+ applicationScope.launch {
+ interactor.loadWallpaper()
+ }
+ }
+ }
+
+ override fun onResume() {
+ if (shouldTriggerUpdate()) {
+ startUpdateLoop()
+ }
+ collectWallpaperImageJob = applicationScope.launch {
+ interactor.wallpaperImageModel.collect { asset ->
+ if (asset == null || asset == currentAssets) return@collect
+
+ createWeatherEffect(asset.foreground, asset.background, asset.weatherEffect)
+ }
+ }
+ }
+
+ override fun onPause() {
+ stopUpdateLoop()
+ activeEffect?.reset()
+ collectWallpaperImageJob?.cancel()
+ }
+
+
+ override fun onDestroy(isLastActiveInstance: Boolean) {
+ activeEffect?.release()
+ activeEffect = null
+ }
+
+ override fun onUpdate(deltaMillis: Long, frameTimeNanos: Long) {
+ super.onUpdate(deltaMillis, frameTimeNanos)
+ activeEffect?.update(deltaMillis, frameTimeNanos)
+
+ renderWithFpsLimit(frameTimeNanos) { canvas -> activeEffect?.draw(canvas) }
+ }
+
+ private fun createWeatherEffect(
+ foreground: Bitmap,
+ background: Bitmap,
+ weatherEffect: WallpaperInfoContract.WeatherEffect? = null
+ ) {
+ activeEffect?.release()
+ activeEffect = null
+
+ when (weatherEffect) {
+ WallpaperInfoContract.WeatherEffect.RAIN -> {
+ val rainConfig = RainEffectConfig.create(context, foreground, background)
+ activeEffect = RainEffect(rainConfig, screenSize.toSizeF())
+ }
+
+ WallpaperInfoContract.WeatherEffect.FOG -> {
+ val fogConfig = FogEffectConfig.create(
+ context.assets, foreground, background, context.resources.displayMetrics.density
+ )
+ activeEffect = FogEffect(fogConfig, screenSize.toSizeF())
+ }
+
+ WallpaperInfoContract.WeatherEffect.SNOW -> {
+ val snowConfig = SnowEffectConfig.create(context, foreground, background)
+ activeEffect = SnowEffect(snowConfig, screenSize.toSizeF(), context.mainExecutor)
+ }
+
+ else -> {
+ activeEffect = NoEffect(foreground, background, screenSize.toSizeF())
+ }
+ }
+
+ render { canvas -> activeEffect?.draw(canvas) }
+ }
+
+ private fun shouldTriggerUpdate(): Boolean {
+ return activeEffect != null && activeEffect !is NoEffect
+ }
+
+ private fun Size.toSizeF(): SizeF = SizeF(width.toFloat(), height.toFloat())
+
+ private companion object {
+
+ private val TAG = WeatherEngine::class.java.simpleName
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherWallpaperService.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherWallpaperService.kt
new file mode 100644
index 0000000..199a4c1
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/WeatherWallpaperService.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.google.android.wallpaper.weathereffects
+
+import android.content.Context
+import android.view.SurfaceHolder
+import com.google.android.torus.core.engine.TorusEngine
+import com.google.android.torus.core.wallpaper.LiveWallpaper
+import com.google.android.wallpaper.weathereffects.dagger.MainScope
+import com.google.android.wallpaper.weathereffects.domain.WeatherEffectsInteractor
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+
+class WeatherWallpaperService @Inject constructor(): LiveWallpaper() {
+
+ @Inject lateinit var interactor: WeatherEffectsInteractor
+ @Inject @MainScope lateinit var applicationScope: CoroutineScope
+
+ override fun onCreate() {
+ super.onCreate()
+ WallpaperEffectsDebugApplication.graph.inject(this)
+ }
+
+ override fun getWallpaperEngine(context: Context, surfaceHolder: SurfaceHolder): TorusEngine {
+ val engine = WeatherEngine(surfaceHolder, context)
+ engine.initialize(applicationScope, interactor)
+ return engine
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/ApplicationComponent.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/ApplicationComponent.kt
new file mode 100644
index 0000000..f76548b
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/ApplicationComponent.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.dagger
+
+import dagger.Component
+import javax.inject.Singleton
+
+@Singleton
+@Component(modules = [DependencyProvider::class])
+interface ApplicationComponent \ No newline at end of file
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/DependencyProvider.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/DependencyProvider.kt
new file mode 100644
index 0000000..22a3a9b
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/DependencyProvider.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.dagger
+
+import android.app.WallpaperManager
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainCoroutineDispatcher
+import javax.inject.Singleton
+
+@Module
+class DependencyProvider(private val context: Context) {
+
+ @Singleton
+ @Provides
+ fun context() = context
+
+ @Singleton
+ @Provides
+ @MainScope
+ fun mainScope(@Main mainDispatcher: MainCoroutineDispatcher) = CoroutineScope(mainDispatcher)
+
+ @Provides
+ @BackgroundScope
+ fun backgroundScope(@Background backgroundDispatcher: CoroutineDispatcher) =
+ CoroutineScope(backgroundDispatcher)
+
+ @Singleton
+ @Provides
+ @Main
+ fun mainDispatcher(): MainCoroutineDispatcher = Dispatchers.Main.immediate
+
+ @Singleton
+ @Provides
+ @Background
+ fun backgroundDispatcher(): CoroutineDispatcher = Dispatchers.IO
+
+ @Singleton
+ @Provides
+ fun resources() = context.resources
+
+ @Singleton
+ @Provides
+ fun provideWallpaperManager(): WallpaperManager {
+ return context.getSystemService(WallpaperManager::class.java)
+ }
+} \ No newline at end of file
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/Qualifiers.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/Qualifiers.kt
new file mode 100644
index 0000000..5239985
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/dagger/Qualifiers.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.dagger
+
+import javax.inject.Qualifier
+
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class MainScope()
+
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class Main()
+
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class BackgroundScope()
+
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class Background()
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/data/repository/WallpaperFileUtils.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/data/repository/WallpaperFileUtils.kt
new file mode 100644
index 0000000..39bacb0
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/data/repository/WallpaperFileUtils.kt
@@ -0,0 +1,136 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.data.repository
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Log
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+object WallpaperFileUtils {
+ /**
+ * Exports the [bitmap] to an image file in local storage.
+ * This method may take several seconds to complete, so it should be called from
+ * a background [dispatcher].
+ *
+ * @param context the [Context] of the caller
+ * @param bitmap the source to be exported
+ * @param dispatcher the dispatcher to run within.
+ * @return `true` when exported successfully
+ */
+ suspend fun export(
+ context: Context,
+ fileName: String,
+ bitmap: Bitmap,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO,
+ ): Boolean {
+ val protectedContext = asProtectedContext(context)
+ return try {
+ withContext(dispatcher) {
+ var success: Boolean
+ protectedContext
+ .openFileOutput(fileName, Context.MODE_PRIVATE)
+ .use {
+ success = bitmap.compress(
+ Bitmap.CompressFormat.PNG,
+ /* quality = */ 100,
+ it,
+ )
+ if (!success) {
+ Log.e(TAG, "Failed to write the bitmap to local storage")
+ } else {
+ Log.i(TAG, "Wrote bitmap to local storage. filename: $fileName")
+ }
+ }
+ success
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to export", e)
+ false
+ }
+ }
+
+ /**
+ * Imports the bitmap from an absolute path. This method may take several seconds to complete,
+ * so it should be called from a background [dispatcher].
+ *
+ * @param absolutePath the absolute file path of the bitmap to be imported.
+ * @param dispatcher the dispatcher to run within.
+ * @return the imported wallpaper bitmap, or `null` if importing failed.
+ */
+ suspend fun importBitmapFromAbsolutePath(
+ absolutePath: String,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO,
+ ): Bitmap? {
+ return try {
+ withContext(dispatcher) {
+ val bitmap = BitmapFactory.decodeFile(absolutePath)
+ if (bitmap == null) {
+ Log.e(TAG, "Failed to decode the bitmap")
+ }
+ bitmap
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to import the image", e)
+ null
+ }
+ }
+
+ /**
+ * Imports the bitmap from local storage. This method may take several seconds to complete,
+ * so it should be called from a background [dispatcher].
+ *
+ * @param fileName name of the bitmap file in local storage.
+ * @param dispatcher the dispatcher to run within.
+ * @return the imported wallpaper bitmap, or `null` if importing failed.
+ */
+ suspend fun importBitmapFromLocalStorage(
+ fileName: String,
+ context: Context,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO,
+ ): Bitmap? {
+ return try {
+ withContext(dispatcher) {
+ val protectedContext = asProtectedContext(context)
+ val inputStream = protectedContext.openFileInput(fileName)
+ val bitmap = BitmapFactory.decodeStream(inputStream)
+ if (bitmap == null) {
+ Log.e(TAG, "Failed to decode the bitmap")
+ }
+ bitmap
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to import the image", e)
+ null
+ }
+ }
+
+ private fun asProtectedContext(context: Context): Context {
+ return if (context.isDeviceProtectedStorage) {
+ context
+ } else {
+ context.createDeviceProtectedStorageContext()
+ }
+ }
+
+ private const val TAG = "WallpaperFileUtils"
+ const val FG_FILE_NAME = "fg_image"
+ const val BG_FILE_NAME = "bg_image"
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/data/repository/WeatherEffectsRepository.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/data/repository/WeatherEffectsRepository.kt
new file mode 100644
index 0000000..8b2fc6f
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/data/repository/WeatherEffectsRepository.kt
@@ -0,0 +1,133 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.data.repository
+
+import android.content.Context
+import android.util.Log
+import com.google.android.wallpaper.weathereffects.shared.model.WallpaperFileModel
+import com.google.android.wallpaper.weathereffects.shared.model.WallpaperImageModel
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+@Singleton
+class WeatherEffectsRepository @Inject constructor(
+ private val context: Context,
+) {
+ private val _wallpaperImage = MutableStateFlow<WallpaperImageModel?>(null)
+ val wallpaperImage: StateFlow<WallpaperImageModel?> = _wallpaperImage.asStateFlow()
+
+ /**
+ * Generates or updates a wallpaper from the provided [wallpaperFileModel].
+ */
+ suspend fun updateWallpaper(wallpaperFileModel: WallpaperFileModel) {
+ try {
+ // Use the existing images if the foreground and background are not supplied.
+ var fgBitmap = _wallpaperImage.value?.foreground
+ var bgBitmap = _wallpaperImage.value?.background
+
+ wallpaperFileModel.foregroundAbsolutePath?.let {
+ WallpaperFileUtils.importBitmapFromAbsolutePath(it)?.let { newFg ->
+ fgBitmap = newFg
+ }
+ }
+
+ wallpaperFileModel.backgroundAbsolutePath?.let {
+ WallpaperFileUtils.importBitmapFromAbsolutePath(it)?.let { newBg ->
+ bgBitmap = newBg
+ }
+ }
+
+ if (fgBitmap == null || bgBitmap == null) {
+ Log.w(TAG, "Cannot update wallpaper. asset: $wallpaperFileModel")
+ return
+ }
+
+ val foreground = fgBitmap!!
+ val background = bgBitmap!!
+ _wallpaperImage.value = WallpaperImageModel(
+ foreground,
+ background,
+ wallpaperFileModel.weatherEffect,
+ )
+ } catch (e: RuntimeException) {
+ Log.e(TAG, "Unable to load wallpaper: ", e)
+ } catch (e: OutOfMemoryError) {
+ Log.e(TAG, "Unable to load wallpaper: ", e)
+ }
+ }
+
+ /**
+ * Loads wallpaper from the persisted files in local storage.
+ * This assumes wallpaper assets exist in local storage under fixed names.
+ */
+ suspend fun loadWallpaperFromLocalStorage() {
+ try {
+ val fgBitmap = WallpaperFileUtils.importBitmapFromLocalStorage(
+ WallpaperFileUtils.FG_FILE_NAME, context
+ )
+ val bgBitmap = WallpaperFileUtils.importBitmapFromLocalStorage(
+ WallpaperFileUtils.BG_FILE_NAME, context
+ )
+ if (fgBitmap == null || bgBitmap == null) {
+ Log.w(TAG, "Cannot load wallpaper from local storage.")
+ return
+ }
+ _wallpaperImage.value = WallpaperImageModel(
+ fgBitmap,
+ bgBitmap,
+ // TODO: Add new API to change weather type dynamically
+ )
+ } catch (e: RuntimeException) {
+ Log.e(TAG, "Unable to load wallpaper: ", e)
+ } catch (e: OutOfMemoryError) {
+ Log.e(TAG, "Unable to load wallpaper: ", e)
+ }
+ }
+
+ suspend fun saveWallpaper() {
+ val foreground = _wallpaperImage.value?.foreground
+ val background = _wallpaperImage.value?.background
+
+ var success = true
+ success = success and (foreground?.let {
+ WallpaperFileUtils.export(
+ context,
+ WallpaperFileUtils.FG_FILE_NAME,
+ it,
+ )
+ } == true)
+ success = success and (background?.let {
+ WallpaperFileUtils.export(
+ context,
+ WallpaperFileUtils.BG_FILE_NAME,
+ it,
+ )
+ } == true)
+ if (success) {
+ Log.d(TAG, "Successfully save wallpaper")
+ } else {
+ Log.e(TAG, "Failed to save wallpaper")
+ }
+ }
+
+ companion object {
+ private const val TAG = "WeatherEffectsRepository"
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/domain/WeatherEffectsInteractor.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/domain/WeatherEffectsInteractor.kt
new file mode 100644
index 0000000..22e4b44
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/domain/WeatherEffectsInteractor.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.domain
+
+import com.google.android.wallpaper.weathereffects.data.repository.WeatherEffectsRepository
+import com.google.android.wallpaper.weathereffects.shared.model.WallpaperFileModel
+import com.google.android.wallpaper.weathereffects.shared.model.WallpaperImageModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.StateFlow
+
+class WeatherEffectsInteractor @Inject constructor(
+ private val repository: WeatherEffectsRepository,
+) {
+ val wallpaperImageModel: StateFlow<WallpaperImageModel?> = repository.wallpaperImage
+
+ suspend fun updateWallpaper(wallpaper: WallpaperFileModel) {
+ repository.updateWallpaper(wallpaper)
+ }
+
+ suspend fun loadWallpaper() {
+ repository.loadWallpaperFromLocalStorage()
+ }
+
+ suspend fun saveWallpaper() {
+ repository.saveWallpaper()
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/fog/FogEffect.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/fog/FogEffect.kt
new file mode 100644
index 0000000..b43c6f5
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/fog/FogEffect.kt
@@ -0,0 +1,143 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.fog
+
+import android.graphics.BitmapShader
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Shader
+import android.util.SizeF
+import com.google.android.torus.utils.extensions.getAspectRatio
+import com.google.android.wallpaper.weathereffects.WeatherEffect
+import com.google.android.wallpaper.weathereffects.utils.ImageCrop
+import kotlin.math.sin
+import kotlin.random.Random
+
+/** Defines and generates the fog weather effect animation. */
+class FogEffect(
+ private val fogConfig: FogEffectConfig,
+ /** The initial size of the surface where the effect will be shown. */
+ surfaceSize: SizeF
+) : WeatherEffect {
+
+ private val fogPaint = Paint().also { it.shader = fogConfig.colorGradingShader }
+ private var elapsedTime: Float = 0f
+
+ init {
+ updateTextureUniforms()
+ adjustCropping(surfaceSize)
+ prepareColorGrading()
+ }
+
+ override fun resize(newSurfaceSize: SizeF) = adjustCropping(newSurfaceSize)
+
+ override fun update(deltaMillis: Long, frameTimeNanos: Long) {
+ val time = 0.02f * frameTimeNanos.toFloat() * NANOS_TO_SECONDS
+
+ // Variation range [1, 1.5]. We don't want the variation to be 0.
+ val variation = (sin(time + sin(3f * time)) * 0.5f + 0.5f) * 1.5f
+ elapsedTime += variation * deltaMillis * MILLIS_TO_SECONDS
+
+ fogConfig.shader.setFloatUniform("timeBackground", elapsedTime * 1.5f)
+ fogConfig.shader.setFloatUniform("timeForeground", elapsedTime * 2.0f)
+
+ fogConfig.colorGradingShader.setInputShader("texture", fogConfig.shader)
+ }
+
+ override fun draw(canvas: Canvas) {
+ canvas.drawPaint(fogPaint)
+ }
+
+ override fun reset() {
+ elapsedTime = Random.nextFloat() * 90f
+ }
+
+ override fun release() {
+ fogConfig.lut?.recycle()
+ }
+
+ private fun adjustCropping(surfaceSize: SizeF) {
+ val imageCropFgd = ImageCrop.centerCoverCrop(
+ surfaceSize.width,
+ surfaceSize.height,
+ fogConfig.foreground.width.toFloat(),
+ fogConfig.foreground.height.toFloat()
+ )
+ fogConfig.shader.setFloatUniform(
+ "uvOffsetFgd",
+ imageCropFgd.leftOffset,
+ imageCropFgd.topOffset
+ )
+ fogConfig.shader.setFloatUniform(
+ "uvScaleFgd",
+ imageCropFgd.horizontalScale,
+ imageCropFgd.verticalScale
+ )
+ val imageCropBgd = ImageCrop.centerCoverCrop(
+ surfaceSize.width,
+ surfaceSize.height,
+ fogConfig.background.width.toFloat(),
+ fogConfig.background.height.toFloat()
+ )
+ fogConfig.shader.setFloatUniform(
+ "uvOffsetBgd",
+ imageCropBgd.leftOffset,
+ imageCropBgd.topOffset
+ )
+ fogConfig.shader.setFloatUniform(
+ "uvScaleBgd",
+ imageCropBgd.horizontalScale,
+ imageCropBgd.verticalScale
+ )
+ fogConfig.shader.setFloatUniform("screenSize", surfaceSize.width, surfaceSize.height)
+ fogConfig.shader.setFloatUniform("screenAspectRatio", surfaceSize.getAspectRatio())
+ }
+
+ private fun updateTextureUniforms() {
+ fogConfig.shader.setInputBuffer(
+ "foreground",
+ BitmapShader(fogConfig.foreground, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
+ )
+
+ fogConfig.shader.setInputBuffer(
+ "background",
+ BitmapShader(fogConfig.background, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
+ )
+
+ fogConfig.shader.setFloatUniform("pixelDensity", fogConfig.pixelDensity)
+ }
+
+ private fun prepareColorGrading() {
+ fogConfig.colorGradingShader.setInputShader("texture", fogConfig.shader)
+ fogConfig.lut?.let {
+ fogConfig.colorGradingShader.setInputShader(
+ "lut",
+ BitmapShader(it, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
+ )
+ }
+ fogConfig.colorGradingShader.setFloatUniform(
+ "intensity",
+ fogConfig.colorGradingIntensity
+ )
+ }
+
+ private companion object {
+
+ private const val MILLIS_TO_SECONDS = 1 / 1000f
+ private const val NANOS_TO_SECONDS = 1 / 1_000_000_000f
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/fog/FogEffectConfig.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/fog/FogEffectConfig.kt
new file mode 100644
index 0000000..cb866e7
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/fog/FogEffectConfig.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.fog
+
+import android.content.res.AssetManager
+import android.graphics.Bitmap
+import android.graphics.RuntimeShader
+import androidx.annotation.FloatRange
+import com.google.android.wallpaper.weathereffects.utils.GraphicsUtils
+
+/** Configuration for a fog effect. */
+data class FogEffectConfig(
+ /** The main shader of the effect. */
+ val shader: RuntimeShader,
+ /** The color grading shader. */
+ val colorGradingShader: RuntimeShader,
+ /** The main lut (color grading) for the effect. */
+ val lut: Bitmap?,
+ /** The intensity of the color grading. 0: no color grading, 1: color grading in full effect. */
+ @FloatRange(from = 0.0, to = 1.0)
+ val colorGradingIntensity: Float,
+ /** A bitmap containing the foreground of the image. */
+ val foreground: Bitmap,
+ /** A bitmap containing the background of the image. */
+ val background: Bitmap,
+ /** Pixel density of the display. Used for dithering. */
+ val pixelDensity: Float
+) {
+
+ companion object {
+
+ /**
+ * A convenient way for creating a [FogEffectConfig]. If the client does not want to use
+ * this constructor, a [FogEffectConfig] object can still be created a directly.
+ *
+ * @param assets the application [AssetManager].
+ * @param foreground a bitmap containing the foreground of the image.
+ * @param background a bitmap containing the background of the image.
+ * @param pixelDensity pixel density of the display.
+ *
+ * @return the [FogEffectConfig] object.
+ */
+ fun create(
+ assets: AssetManager,
+ foreground: Bitmap,
+ background: Bitmap,
+ pixelDensity: Float,
+ ): FogEffectConfig {
+ return FogEffectConfig(
+ shader = GraphicsUtils.loadShader(assets, "shaders/fog_effect.agsl"),
+ colorGradingShader = GraphicsUtils.loadShader(
+ assets,
+ "shaders/color_grading_lut.agsl"
+ ),
+ lut = GraphicsUtils.loadTexture(assets, "textures/lut_rain_and_fog.png"),
+ colorGradingIntensity = 0.7f,
+ foreground,
+ background,
+ pixelDensity
+ )
+ }
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/graphics/FrameBuffer.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/graphics/FrameBuffer.kt
new file mode 100644
index 0000000..46d9841
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/graphics/FrameBuffer.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.google.android.wallpaper.weathereffects.graphics
+
+import android.graphics.Bitmap
+import android.graphics.ColorSpace
+import android.graphics.HardwareBufferRenderer
+import android.graphics.RecordingCanvas
+import android.graphics.RenderEffect
+import android.graphics.RenderNode
+import android.hardware.HardwareBuffer
+import java.time.Duration
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+
+/** A wrapper that handles drawing into a [HardwareBuffer] and releasing resources. */
+class FrameBuffer(width: Int, height: Int, format: Int = HardwareBuffer.RGBA_8888) {
+
+ private val buffer = HardwareBuffer.create(
+ width,
+ height,
+ format,
+ /* layers = */ 1,
+ // USAGE_GPU_SAMPLED_IMAGE: buffer will be read by the GPU
+ // USAGE_GPU_COLOR_OUTPUT: buffer will be written by the GPU
+ /* usage= */ HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT
+ )
+ private val renderer = HardwareBufferRenderer(buffer)
+ private val node = RenderNode("content").also {
+ it.setPosition(0, 0, width, height)
+ renderer.setContentRoot(it)
+ }
+
+ private val executor = Executors.newFixedThreadPool(/* nThreads = */ 1)
+ private val colorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
+
+ /**
+ * Recording drawing commands.
+ * @return RecordingCanvas
+ */
+ fun beginDrawing(): RecordingCanvas {
+ return node.beginRecording()
+ }
+
+ /**
+ * Ends drawing. Must be paired with [beginDrawing].
+ */
+ fun endDrawing() {
+ node.endRecording()
+ }
+
+ /** Closes the [FrameBuffer]. */
+ fun close() {
+ buffer.close()
+ renderer.close()
+ executor.shutdown()
+ }
+
+ /**
+ * Invokes the [onImageReady] callback when the new image is acquired, which is associated with
+ * the frame buffer.
+ * @param onImageReady callback that will be called once the image is ready.
+ * @param callbackExecutor executor to use to trigger the callback. Likely to be the main
+ * executor.
+ */
+ fun tryObtainingImage(
+ onImageReady: (image: Bitmap) -> Unit,
+ callbackExecutor: Executor
+ ) {
+ renderer.obtainRenderRequest()
+ .setColorSpace(colorSpace)
+ .draw(executor) { result ->
+ if (result.status == HardwareBufferRenderer.RenderResult.SUCCESS) {
+ result.fence.await(Duration.ofMillis(3000))
+ Bitmap.wrapHardwareBuffer(buffer, colorSpace)?.let {
+ callbackExecutor.execute { onImageReady.invoke(it) }
+ }
+ }
+ }
+ }
+
+
+ /**
+ * Configure the [FrameBuffer] to apply to this RenderNode. This will apply a visual effect to
+ * the end result of the contents of this RenderNode before it is drawn into the destination.
+ *
+ * @param renderEffect to be applied to the [FrameBuffer]. Passing null clears all previously
+ * configured RenderEffects.
+ */
+ fun setRenderEffect(renderEffect: RenderEffect?) = node.setRenderEffect(renderEffect)
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/none/NoEffect.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/none/NoEffect.kt
new file mode 100644
index 0000000..a8ad135
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/none/NoEffect.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.none
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.util.SizeF
+import com.google.android.wallpaper.weathereffects.WeatherEffect
+import com.google.android.wallpaper.weathereffects.utils.MatrixUtils
+
+/** Simply draws foreground and background images with no weather effect. */
+class NoEffect(
+ val foreground: Bitmap,
+ val background: Bitmap,
+ private var surfaceSize: SizeF
+): WeatherEffect {
+ override fun resize(newSurfaceSize: SizeF) {
+ surfaceSize = newSurfaceSize
+ }
+
+ override fun update(deltaMillis: Long, frameTimeNanos: Long) {}
+
+ override fun draw(canvas: Canvas) {
+ canvas.drawBitmap(
+ background,
+ MatrixUtils.centerCropMatrix(
+ surfaceSize,
+ SizeF(
+ background.width.toFloat(),
+ background.height.toFloat()
+ )
+ ),
+ null
+ )
+
+ canvas.drawBitmap(
+ foreground,
+ MatrixUtils.centerCropMatrix(
+ surfaceSize,
+ SizeF(
+ foreground.width.toFloat(),
+ foreground.height.toFloat()
+ )
+ ),
+ null
+ )
+ }
+
+ override fun reset() {}
+
+ override fun release() {}
+} \ No newline at end of file
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/provider/WallpaperInfoContract.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/provider/WallpaperInfoContract.kt
new file mode 100644
index 0000000..d3a3174
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/provider/WallpaperInfoContract.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.provider
+
+import android.content.ContentResolver.SCHEME_CONTENT
+import android.net.Uri
+
+object WallpaperInfoContract {
+
+ /** Returns a [Uri.Builder] for updating a wallpaper. This will produce a uri starts with
+ * content://com.google.android.wallpaper.weathereffects.effectprovider/update_wallpaper.
+ * Append parameters such as foreground and background images, etc.
+ *
+ * All the parameters are optional.
+ * <ul>
+ * <li>For the initial generation, foreground and background images must be provided.
+ * <li>When foreground and background images are already provided, but no weather type is
+ * provided, it clears the existing weather effect (foreground & background images composed).
+ * </ul>
+ *
+ * Example uri: content://com.google.android.wallpaper.weathereffects.effectprovider/
+ * update_wallpaper?foreground_texture=<path_to_foreground_texture>&background_texture=
+ * <path_to_background_texture>
+ */
+ fun getUpdateWallpaperUri(): Uri.Builder {
+ return Uri.Builder().scheme(SCHEME_CONTENT)
+ .authority(AUTHORITY)
+ .appendPath(WeatherEffectsContentProvider.UPDATE_WALLPAPER)
+ }
+
+ enum class WeatherEffect(val value: String) {
+ RAIN("rain"),
+ FOG("fog"),
+ SNOW("snow");
+
+ companion object {
+
+ /**
+ * Converts the String value to an enum.
+ *
+ * @param value a String representing the [value] of an enum. Note that this is the
+ * value that we created [value] and it does not refer to the [valueOf] value, which
+ * corresponds to the [name]. i.e.
+ * - RAIN("rain"):
+ * -> [valueOf] needs [name] ("RAIN").
+ * -> [fromStringValue] needs [value] ("rain").
+ *
+ * @return the associated [WeatherEffect].
+ */
+ fun fromStringValue(value: String?): WeatherEffect? {
+ return when (value) {
+ RAIN.value -> RAIN
+ FOG.value -> FOG
+ SNOW.value -> SNOW
+ else -> null
+ }
+ }
+ }
+ }
+
+ const val AUTHORITY = "com.google.android.wallpaper.weathereffects.effectprovider"
+ const val FOREGROUND_TEXTURE_PARAM = "foreground_texture"
+ const val BACKGROUND_TEXTURE_PARAM = "background_texture"
+ const val WEATHER_EFFECT_PARAM = "weather_effect"
+
+ object WallpaperGenerationData {
+
+ const val FOREGROUND_TEXTURE = "foreground_texture"
+ const val BACKGROUND_TEXTURE = "background_texture"
+ const val WEATHER_EFFECT = "weather_effect"
+
+ val DEFAULT_PROJECTION = arrayOf(
+ FOREGROUND_TEXTURE, BACKGROUND_TEXTURE, WEATHER_EFFECT
+ )
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/provider/WeatherEffectsContentProvider.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/provider/WeatherEffectsContentProvider.kt
new file mode 100644
index 0000000..cc5c0c3
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/provider/WeatherEffectsContentProvider.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.provider
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.UriMatcher
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import com.google.android.wallpaper.weathereffects.WallpaperEffectsDebugApplication
+import com.google.android.wallpaper.weathereffects.dagger.MainScope
+import com.google.android.wallpaper.weathereffects.provider.WallpaperInfoContract.WallpaperGenerationData
+import com.google.android.wallpaper.weathereffects.shared.model.WallpaperFileModel
+import com.google.android.wallpaper.weathereffects.domain.WeatherEffectsInteractor
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class WeatherEffectsContentProvider: ContentProvider() {
+
+ @Inject @MainScope lateinit var mainScope: CoroutineScope
+ @Inject lateinit var interactor: WeatherEffectsInteractor
+
+ private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
+ addURI(
+ WallpaperInfoContract.AUTHORITY,
+ UPDATE_WALLPAPER,
+ UPDATE_WALLPAPER_ID
+ )
+ }
+
+ override fun onCreate(): Boolean {
+ WallpaperEffectsDebugApplication.graph.inject(this)
+ return true
+ }
+
+ override fun query(
+ uri: Uri,
+ projection: Array<out String>?,
+ selection: String?,
+ selectionArgs: Array<out String>?,
+ sortOrder: String?
+ ): Cursor {
+ return when (uriMatcher.match(uri)) {
+ UPDATE_WALLPAPER_ID -> updateWallpaper(uri)
+ // TODO(b/290939683): Add more URIs including save and load wallpapers.
+ else -> MatrixCursor(arrayOf())
+ }
+ }
+
+ override fun getType(uri: Uri): String? = null
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array<out String>?
+ ): Int {
+ return 0
+ }
+
+ private fun updateWallpaper(uri: Uri): MatrixCursor {
+ val foreground = uri.getQueryParameter(WallpaperInfoContract.FOREGROUND_TEXTURE_PARAM)
+ val background = uri.getQueryParameter(WallpaperInfoContract.BACKGROUND_TEXTURE_PARAM)
+ val weatherType = uri.getQueryParameter(WallpaperInfoContract.WEATHER_EFFECT_PARAM)
+
+ val projection = WallpaperGenerationData.DEFAULT_PROJECTION
+ val cursor = MatrixCursor(projection)
+ cursor.addRow(projection.map { column ->
+ when (column) {
+ WallpaperGenerationData.FOREGROUND_TEXTURE -> foreground
+ WallpaperGenerationData.BACKGROUND_TEXTURE -> background
+ WallpaperGenerationData.WEATHER_EFFECT -> weatherType
+ else -> null
+ }
+ })
+
+ mainScope.launch {
+ interactor.updateWallpaper(
+ WallpaperFileModel(
+ foreground,
+ background,
+ WallpaperInfoContract.WeatherEffect.fromStringValue(weatherType),
+ )
+ )
+ }
+
+ return cursor
+ }
+
+ companion object {
+ const val UPDATE_WALLPAPER = "update_wallpaper"
+ const val UPDATE_WALLPAPER_ID = 0
+ const val TAG = "WeatherEffectsContentProvider"
+ }
+} \ No newline at end of file
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/rain/RainEffect.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/rain/RainEffect.kt
new file mode 100644
index 0000000..4aeb3c1
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/rain/RainEffect.kt
@@ -0,0 +1,143 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.rain
+
+import android.graphics.BitmapShader
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Shader
+import android.util.SizeF
+import com.google.android.torus.utils.extensions.getAspectRatio
+import com.google.android.wallpaper.weathereffects.WeatherEffect
+import com.google.android.wallpaper.weathereffects.utils.ImageCrop
+import kotlin.random.Random
+
+/** Defines and generates the rain weather effect animation. */
+class RainEffect(
+ /** The config of the rain effect. */
+ private val rainConfig: RainEffectConfig,
+ /** The initial size of the surface where the effect will be shown. */
+ surfaceSize: SizeF
+) : WeatherEffect {
+
+ private val rainPaint = Paint().also { it.shader = rainConfig.colorGradingShader }
+ private var elapsedTime: Float = 0f
+
+ init {
+ updateTextureUniforms()
+ adjustCropping(surfaceSize)
+ prepareColorGrading()
+ }
+
+ override fun resize(newSurfaceSize: SizeF) = adjustCropping(newSurfaceSize)
+
+ override fun update(deltaMillis: Long, frameTimeNanos: Long) {
+ elapsedTime += deltaMillis * MILLIS_TO_SECONDS
+ rainConfig.shader.setFloatUniform("time", elapsedTime)
+ rainConfig.colorGradingShader.setInputShader("texture", rainConfig.shader)
+ }
+
+ override fun draw(canvas: Canvas) {
+ canvas.drawPaint(rainPaint)
+ }
+
+ override fun reset() {
+ elapsedTime = Random.nextFloat() * 90f
+ }
+
+ override fun release() {
+ rainConfig.lut?.recycle()
+ rainConfig.blurredBackground.recycle()
+ }
+
+ private fun adjustCropping(surfaceSize: SizeF) {
+ val imageCropFgd = ImageCrop.centerCoverCrop(
+ surfaceSize.width,
+ surfaceSize.height,
+ rainConfig.foreground.width.toFloat(),
+ rainConfig.foreground.height.toFloat()
+ )
+ rainConfig.shader.setFloatUniform(
+ "uvOffsetFgd",
+ imageCropFgd.leftOffset,
+ imageCropFgd.topOffset
+ )
+ rainConfig.shader.setFloatUniform(
+ "uvScaleFgd",
+ imageCropFgd.horizontalScale,
+ imageCropFgd.verticalScale
+ )
+ val imageCropBgd = ImageCrop.centerCoverCrop(
+ surfaceSize.width,
+ surfaceSize.height,
+ rainConfig.background.width.toFloat(),
+ rainConfig.background.height.toFloat()
+ )
+ rainConfig.shader.setFloatUniform(
+ "uvOffsetBgd",
+ imageCropBgd.leftOffset,
+ imageCropBgd.topOffset
+ )
+ rainConfig.shader.setFloatUniform(
+ "uvScaleBgd",
+ imageCropBgd.horizontalScale,
+ imageCropBgd.verticalScale
+ )
+ rainConfig.shader.setFloatUniform("screenSize", surfaceSize.width, surfaceSize.height)
+ rainConfig.shader.setFloatUniform("screenAspectRatio", surfaceSize.getAspectRatio())
+ }
+
+ private fun updateTextureUniforms() {
+ rainConfig.shader.setInputBuffer(
+ "foreground",
+ BitmapShader(rainConfig.foreground, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
+ )
+
+ rainConfig.shader.setInputBuffer(
+ "background",
+ BitmapShader(rainConfig.background, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
+ )
+
+ rainConfig.shader.setInputBuffer(
+ "blurredBackground",
+ BitmapShader(
+ rainConfig.blurredBackground,
+ Shader.TileMode.MIRROR,
+ Shader.TileMode.MIRROR
+ )
+ )
+ }
+
+ private fun prepareColorGrading() {
+ rainConfig.colorGradingShader.setInputShader("texture", rainConfig.shader)
+ rainConfig.lut?.let {
+ rainConfig.colorGradingShader.setInputShader(
+ "lut",
+ BitmapShader(it, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
+ )
+ }
+ rainConfig.colorGradingShader.setFloatUniform(
+ "intensity",
+ rainConfig.colorGradingIntensity
+ )
+ }
+
+ private companion object {
+
+ private const val MILLIS_TO_SECONDS = 1 / 1000f
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/rain/RainEffectConfig.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/rain/RainEffectConfig.kt
new file mode 100644
index 0000000..e312dd1
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/rain/RainEffectConfig.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.rain
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.RuntimeShader
+import androidx.annotation.FloatRange
+import com.google.android.wallpaper.weathereffects.utils.GraphicsUtils
+
+/** Configuration for a rain effect. */
+data class RainEffectConfig(
+ /** The main shader of the effect. */
+ val shader: RuntimeShader,
+ /** The color grading shader. */
+ val colorGradingShader: RuntimeShader,
+ /** The main lut (color grading) for the effect. */
+ val lut: Bitmap?,
+ /** The intensity of the color grading. 0: no color grading, 1: color grading in full effect. */
+ @FloatRange(from = 0.0, to = 1.0)
+ val colorGradingIntensity: Float,
+ /** A bitmap containing the foreground of the image. */
+ val foreground: Bitmap,
+ /** A bitmap containing the background of the image. */
+ val background: Bitmap,
+ /** A bitmap containing the blurred background. */
+ val blurredBackground: Bitmap
+) {
+
+ companion object {
+
+ /**
+ * A convenient way for creating a [RainEffectConfig]. If the client does not want to use
+ * this constructor, a [RainEffectConfig] object can still be created a directly.
+ *
+ * @param context the application context.
+ * @param foreground a bitmap containing the foreground of the image.
+ * @param background a bitmap containing the background of the image.
+ *
+ * @return the [RainEffectConfig] object.
+ */
+ fun create(context: Context, foreground: Bitmap, background: Bitmap): RainEffectConfig {
+ return RainEffectConfig(
+ shader = GraphicsUtils.loadShader(context.assets, "shaders/rain_effect.agsl"),
+ colorGradingShader = GraphicsUtils.loadShader(
+ context.assets,
+ "shaders/color_grading_lut.agsl"
+ ),
+ lut = GraphicsUtils.loadTexture(context.assets, "textures/lut_rain_and_fog.png"),
+ colorGradingIntensity = 0.7f,
+ foreground,
+ background,
+ GraphicsUtils.blurImage(context, background, 10f)
+ )
+ }
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/shared/model/WallpaperFileModel.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/shared/model/WallpaperFileModel.kt
new file mode 100644
index 0000000..f69755d
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/shared/model/WallpaperFileModel.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.shared.model
+
+import android.graphics.Bitmap
+import com.google.android.wallpaper.weathereffects.provider.WallpaperInfoContract
+
+/**
+ * Model representing assets.
+ *
+ * @param foregroundAbsolutePath Absolute file path of the foreground asset.
+ * @param backgroundAbsolutePath Absolute file path of the background asset.
+ * @param weatherEffect the weather effect type.
+ */
+data class WallpaperFileModel (
+ val foregroundAbsolutePath: String?,
+ val backgroundAbsolutePath: String?,
+ val weatherEffect: WallpaperInfoContract.WeatherEffect? = null
+)
+
+/**
+ * Model representing wallpapers with images loaded in memory.
+ *
+ * @param foreground Bitmap of the foreground image.
+ * @param background Bitmap of the background image.
+ * @param weatherEffect the weather effect type.
+ */
+data class WallpaperImageModel (
+ val foreground: Bitmap,
+ val background: Bitmap,
+ val weatherEffect: WallpaperInfoContract.WeatherEffect? = null
+)
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/snow/SnowEffect.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/snow/SnowEffect.kt
new file mode 100644
index 0000000..4906874
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/snow/SnowEffect.kt
@@ -0,0 +1,176 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.snow
+
+import android.graphics.BitmapShader
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RenderEffect
+import android.graphics.Shader
+import android.util.SizeF
+import com.google.android.torus.utils.extensions.getAspectRatio
+import com.google.android.wallpaper.weathereffects.WeatherEffect
+import com.google.android.wallpaper.weathereffects.graphics.FrameBuffer
+import com.google.android.wallpaper.weathereffects.utils.ImageCrop
+import java.util.concurrent.Executor
+import kotlin.random.Random
+
+/** Defines and generates the rain weather effect animation. */
+class SnowEffect(
+ /** The config of the snow effect. */
+ private val snowConfig: SnowEffectConfig,
+ /** The initial size of the surface where the effect will be shown. */
+ surfaceSize: SizeF,
+ /** App main executor. */
+ private val mainExecutor: Executor
+) : WeatherEffect {
+
+ private val snowPaint = Paint().also { it.shader = snowConfig.colorGradingShader }
+ private var elapsedTime: Float = 0f
+
+ private val frameBuffer = FrameBuffer(snowConfig.background.width, snowConfig.background.height)
+ private val frameBufferPaint = Paint().also { it.shader = snowConfig.accumulatedSnowShader }
+
+
+ init {
+ frameBuffer.setRenderEffect(RenderEffect.createBlurEffect(4f, 4f, Shader.TileMode.CLAMP))
+ generateAccumulatedSnow()
+ updateTextureUniforms()
+ adjustCropping(surfaceSize)
+ prepareColorGrading()
+ }
+
+ override fun resize(newSurfaceSize: SizeF) = adjustCropping(newSurfaceSize)
+
+ override fun update(deltaMillis: Long, frameTimeNanos: Long) {
+ elapsedTime += deltaMillis * MILLIS_TO_SECONDS
+ snowConfig.shader.setFloatUniform("time", elapsedTime)
+ snowConfig.colorGradingShader.setInputShader("texture", snowConfig.shader)
+ }
+
+ override fun draw(canvas: Canvas) {
+ canvas.drawPaint(snowPaint)
+ }
+
+ override fun reset() {
+ elapsedTime = Random.nextFloat() * 90f
+ }
+
+ override fun release() {
+ snowConfig.lut?.recycle()
+ snowConfig.blurredBackground.recycle()
+ frameBuffer.close()
+ }
+
+ private fun adjustCropping(surfaceSize: SizeF) {
+ val imageCropFgd = ImageCrop.centerCoverCrop(
+ surfaceSize.width,
+ surfaceSize.height,
+ snowConfig.foreground.width.toFloat(),
+ snowConfig.foreground.height.toFloat()
+ )
+ snowConfig.shader.setFloatUniform(
+ "uvOffsetFgd",
+ imageCropFgd.leftOffset,
+ imageCropFgd.topOffset
+ )
+ snowConfig.shader.setFloatUniform(
+ "uvScaleFgd",
+ imageCropFgd.horizontalScale,
+ imageCropFgd.verticalScale
+ )
+ val imageCropBgd = ImageCrop.centerCoverCrop(
+ surfaceSize.width,
+ surfaceSize.height,
+ snowConfig.background.width.toFloat(),
+ snowConfig.background.height.toFloat()
+ )
+ snowConfig.shader.setFloatUniform(
+ "uvOffsetBgd",
+ imageCropBgd.leftOffset,
+ imageCropBgd.topOffset
+ )
+ snowConfig.shader.setFloatUniform(
+ "uvScaleBgd",
+ imageCropBgd.horizontalScale,
+ imageCropBgd.verticalScale
+ )
+ snowConfig.shader.setFloatUniform("screenSize", surfaceSize.width, surfaceSize.height)
+ snowConfig.shader.setFloatUniform("screenAspectRatio", surfaceSize.getAspectRatio())
+ }
+
+ private fun updateTextureUniforms() {
+ snowConfig.shader.setInputBuffer(
+ "foreground",
+ BitmapShader(snowConfig.foreground, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
+ )
+
+ snowConfig.shader.setInputBuffer(
+ "background",
+ BitmapShader(snowConfig.background, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
+ )
+
+ snowConfig.shader.setInputBuffer(
+ "blurredBackground",
+ BitmapShader(
+ snowConfig.blurredBackground, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR
+ )
+ )
+ }
+
+ private fun prepareColorGrading() {
+ snowConfig.colorGradingShader.setInputShader("texture", snowConfig.shader)
+ snowConfig.lut?.let {
+ snowConfig.colorGradingShader.setInputShader(
+ "lut",
+ BitmapShader(it, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
+ )
+ }
+ snowConfig.colorGradingShader.setFloatUniform(
+ "intensity",
+ snowConfig.colorGradingIntensity
+ )
+ }
+
+ private fun generateAccumulatedSnow() {
+ val renderingCanvas = frameBuffer.beginDrawing()
+ snowConfig.accumulatedSnowShader.setFloatUniform(
+ "imageSize",
+ renderingCanvas.width.toFloat(),
+ renderingCanvas.height.toFloat()
+ )
+ snowConfig.accumulatedSnowShader.setInputBuffer(
+ "foreground",
+ BitmapShader(snowConfig.foreground, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
+ )
+ renderingCanvas.drawPaint(frameBufferPaint)
+ frameBuffer.endDrawing()
+
+ frameBuffer.tryObtainingImage(
+ { image ->
+ snowConfig.shader.setInputBuffer(
+ "accumulatedSnow",
+ BitmapShader(image, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
+ )
+ }, mainExecutor
+ )
+ }
+
+ private companion object {
+ private const val MILLIS_TO_SECONDS = 1 / 1000f
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/snow/SnowEffectConfig.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/snow/SnowEffectConfig.kt
new file mode 100644
index 0000000..3174096
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/snow/SnowEffectConfig.kt
@@ -0,0 +1,76 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.snow
+
+import androidx.annotation.FloatRange
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.RuntimeShader
+import com.google.android.wallpaper.weathereffects.utils.GraphicsUtils
+
+/** Configuration for a snow effect. */
+data class SnowEffectConfig(
+ /** The main shader of the effect. */
+ val shader: RuntimeShader,
+ /** The shader of accumulated snow effect. */
+ val accumulatedSnowShader: RuntimeShader,
+ /** The color grading shader. */
+ val colorGradingShader: RuntimeShader,
+ /** The main lut (color grading) for the effect. */
+ val lut: Bitmap?,
+ /** The intensity of the color grading. 0: no color grading, 1: color grading in full effect. */
+ @FloatRange(from = 0.0, to = 1.0)
+ val colorGradingIntensity: Float,
+ /** A bitmap containing the foreground of the image. */
+ val foreground: Bitmap,
+ /** A bitmap containing the background of the image. */
+ val background: Bitmap,
+ /** A bitmap containing the blurred background. */
+ val blurredBackground: Bitmap
+) {
+
+ companion object {
+
+ /**
+ * A convenient way for creating a [SnowEffectConfig]. If the client does not want to use
+ * this constructor, a [SnowEffectConfig] object can still be created a directly.
+ *
+ * @param context the application context.
+ * @param foreground a bitmap containing the foreground of the image.
+ * @param background a bitmap containing the background of the image.
+ *
+ * @return the [SnowEffectConfig] object.
+ */
+ fun create(context: Context, foreground: Bitmap, background: Bitmap): SnowEffectConfig {
+ return SnowEffectConfig(
+ shader = GraphicsUtils.loadShader(context.assets, "shaders/snow_effect.agsl"),
+ accumulatedSnowShader = GraphicsUtils.loadShader(
+ context.assets, "shaders/snow_accumulation.agsl"
+ ),
+ colorGradingShader = GraphicsUtils.loadShader(
+ context.assets,
+ "shaders/color_grading_lut.agsl"
+ ),
+ lut = GraphicsUtils.loadTexture(context.assets, "textures/lut_rain_and_fog.png"),
+ colorGradingIntensity = 0.7f,
+ foreground,
+ background,
+ GraphicsUtils.blurImage(context, background, 20f)
+ )
+ }
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/utils/GraphicsUtils.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/utils/GraphicsUtils.kt
new file mode 100644
index 0000000..3abbd1a
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/utils/GraphicsUtils.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.utils
+
+import android.content.Context
+import android.content.res.AssetManager
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Rect
+import android.graphics.RuntimeShader
+import android.renderscript.Allocation
+import android.renderscript.Element
+import android.renderscript.RenderScript
+import android.renderscript.ScriptIntrinsicBlur
+import androidx.annotation.FloatRange
+
+/** Contains functions for rendering. */
+object GraphicsUtils {
+
+ /**
+ * Loads a shader from an asset file.
+ *
+ * @param assetManager an [AssetManager] instance.
+ * @param path path to the shader to load.
+ *
+ * @return returns a [RuntimeShader] object.
+ */
+ fun loadShader(assetManager: AssetManager, path: String): RuntimeShader {
+ val shader = loadRawShader(assetManager, path)
+ val finalShader = resolveShaderIncludes(assetManager, shader)
+ return RuntimeShader(finalShader)
+ }
+
+ /**
+ * Loads a Bitmap from an asset file.
+ *
+ * @param assetManager an [AssetManager] instance.
+ * @param path path to the texture bitmap to load.
+ *
+ * @return returns a Bitmap.
+ */
+ fun loadTexture(assetManager: AssetManager, path: String): Bitmap? {
+ return assetManager.open(path).use {
+ BitmapFactory.decodeStream(
+ it,
+ Rect(),
+ BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.HARDWARE }
+ )
+ }
+ }
+
+ /**
+ * Blurs an image and returns it as a new one.
+ *
+ * @param context the application.
+ * @param sourceBitmap the original image that we want to blur.
+ * @param blurRadius the amount that we want to blur (only values from 0 to 25).
+ * @param config the bitmap config (optional).
+ *
+ * @return returns a Bitmap.
+ */
+ fun blurImage(
+ context: Context,
+ sourceBitmap: Bitmap,
+ @FloatRange(from = 0.0, to = 25.0)
+ blurRadius: Float,
+ config: Bitmap.Config = Bitmap.Config.ARGB_8888
+ ): Bitmap {
+ //TODO: This might not be the ideal option, find a better one.
+ val blurredImage = Bitmap.createBitmap(
+ sourceBitmap.copy(config, true)
+ )
+ val renderScript = RenderScript.create(context)
+ val blur = ScriptIntrinsicBlur.create(renderScript, Element.U8_4(renderScript))
+ val allocationIn = Allocation.createFromBitmap(renderScript, sourceBitmap)
+ val allocationOut = Allocation.createFromBitmap(renderScript, blurredImage)
+ blur.setRadius(blurRadius)
+ blur.setInput(allocationIn)
+ blur.forEach(allocationOut)
+ allocationOut.copyTo(blurredImage)
+ return blurredImage
+ }
+
+ private fun resolveShaderIncludes(assetManager: AssetManager, string: String): String {
+ val match = Regex("[ \\t]*#include +\"([\\w\\d./]+)\"")
+ return string.replace(match) { m ->
+ val (includePath) = m.destructured
+ getResolvedShaderPath(assetManager, includePath)
+ }
+ }
+
+ private fun getResolvedShaderPath(assetManager: AssetManager, includePath: String): String {
+ val string = loadRawShader(assetManager, includePath)
+ return resolveShaderIncludes(assetManager, string)
+ }
+
+ private fun loadRawShader(assetManager: AssetManager, path: String): String {
+ return assetManager.open(path).bufferedReader().use { it.readText() }
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/utils/ImageCrop.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/utils/ImageCrop.kt
new file mode 100644
index 0000000..0304ce9
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/utils/ImageCrop.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.utils
+
+/** Contains offsets and scales to position image inside a surface. */
+class ImageCrop(
+ /**
+ * The left start of the image relatively to the left edge of the surface that will contain
+ * the image.
+ */
+ val leftOffset: Float = 0f,
+ /**
+ * The top distance start of the image relatively to the top edge of the surface that will
+ * contain the image.
+ */
+ val topOffset: Float = 0f,
+ /** The horizontal scale applied to the image. */
+ val horizontalScale: Float = 1f,
+ /** The vertical scale applied to the image. */
+ val verticalScale: Float = 1f,
+) {
+
+ companion object {
+
+ /**
+ * Calculates the [ImageCrop] that would make the image cover the surface (that is, the
+ * image will be scaled to the smallest possible size so it fills the container,
+ * preserving the aspect ratio and cropping the image vertically or horizontally if
+ * necessary) and center its content.
+ *
+ * @param surfaceWidth the width of the surface where the image will be displayed.
+ * @param surfaceWidth the height of the surface where the image will be displayed.
+ * @param imageWidth the width of the image that we want to display.
+ * @param imageHeight the height of the image that we want to display.
+ *
+ * @return the [ImageCrop] that will center cover the image into the surface.
+ */
+ fun centerCoverCrop(
+ surfaceWidth: Float,
+ surfaceHeight: Float,
+ imageWidth: Float,
+ imageHeight: Float
+ ): ImageCrop {
+ val uvScaleHeight: Float = imageHeight / surfaceHeight
+ val uvScaleWidth: Float = imageWidth / surfaceWidth
+
+ val uvScale = if (imageWidth / imageHeight > surfaceWidth / surfaceHeight) {
+ uvScaleHeight
+ } else {
+ uvScaleWidth
+ }
+
+ val horizontalOffset = (imageWidth - surfaceWidth * uvScale) / 2f
+ val verticalOffset = (imageHeight - surfaceHeight * uvScale) / 2f
+
+ return ImageCrop(horizontalOffset, verticalOffset, uvScale, uvScale)
+ }
+ }
+}
diff --git a/weathereffects/src/com/google/android/wallpaper/weathereffects/utils/MatrixUtils.kt b/weathereffects/src/com/google/android/wallpaper/weathereffects/utils/MatrixUtils.kt
new file mode 100644
index 0000000..b36b62a
--- /dev/null
+++ b/weathereffects/src/com/google/android/wallpaper/weathereffects/utils/MatrixUtils.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.utils
+
+import android.graphics.Matrix
+import android.util.SizeF
+
+/** Helper functions for matrix operations. */
+object MatrixUtils {
+ /**
+ * Returns a [Matrix] that crops the image and centers to the screen.
+ */
+ fun centerCropMatrix(surfaceSize: SizeF, imageSize: SizeF): Matrix {
+ val widthScale = surfaceSize.width / imageSize.width
+ val heightScale = surfaceSize.height / imageSize.height
+ val scale = maxOf(widthScale, heightScale)
+
+ return Matrix(Matrix.IDENTITY_MATRIX).apply {
+ // Move the origin of the image to its center.
+ postTranslate(-imageSize.width / 2f, -imageSize.height / 2f)
+ // Apply scale.
+ postScale(scale, scale)
+ // Translate back to the center of the screen.
+ postTranslate(surfaceSize.width / 2f, surfaceSize.height / 2f)
+ }
+ }
+}
diff --git a/weathereffects/tests/src/com/google/android/wallpaper/weathereffects/provider/WeatherEffectsContentProviderTest.kt b/weathereffects/tests/src/com/google/android/wallpaper/weathereffects/provider/WeatherEffectsContentProviderTest.kt
new file mode 100644
index 0000000..bb01154
--- /dev/null
+++ b/weathereffects/tests/src/com/google/android/wallpaper/weathereffects/provider/WeatherEffectsContentProviderTest.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.google.android.wallpaper.weathereffects.provider
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.wallpaper.weathereffects.provider.WallpaperInfoContract.WallpaperGenerationData
+import com.google.android.wallpaper.weathereffects.provider.WallpaperInfoContract.WeatherEffect
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+class WeatherEffectsContentProviderTest {
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val testScope = TestScope(testDispatcher)
+ private lateinit var weatherEffectsContentProvider: WeatherEffectsContentProvider
+
+ @Before
+ fun setup() {
+ weatherEffectsContentProvider = WeatherEffectsContentProvider()
+ weatherEffectsContentProvider.onCreate()
+ }
+
+ @Test
+ fun query_updateWallpaper_returnsCorrectData() {
+ testScope.runTest {
+ val expectedForegroundPath = "fake_directory/foreground.png"
+ val expectedBackgroundPath = "fake_directory/background.png"
+ val expectedWeatherEffect = WeatherEffect.SNOW.value
+ val uri = WallpaperInfoContract.getUpdateWallpaperUri()
+ .appendQueryParameter(
+ WallpaperInfoContract.FOREGROUND_TEXTURE_PARAM, expectedForegroundPath
+ )
+ .appendQueryParameter(
+ WallpaperInfoContract.BACKGROUND_TEXTURE_PARAM, expectedBackgroundPath
+ )
+ .appendQueryParameter(
+ WallpaperInfoContract.WEATHER_EFFECT_PARAM, expectedWeatherEffect
+ )
+ .build()
+
+ val cursor = weatherEffectsContentProvider.query(
+ uri,
+ projection = null,
+ selection = null,
+ selectionArgs = null,
+ sortOrder = null
+ )
+
+ assertThat(cursor.count).isEqualTo(1)
+ cursor.moveToFirst()
+
+ assertThat(cursor.getString(
+ cursor.getColumnIndex(WallpaperGenerationData.FOREGROUND_TEXTURE))
+ ).isEqualTo(expectedForegroundPath)
+ assertThat(cursor.getString(
+ cursor.getColumnIndex(WallpaperGenerationData.BACKGROUND_TEXTURE))
+ ).isEqualTo(expectedBackgroundPath)
+ assertThat(cursor.getString(
+ cursor.getColumnIndex(WallpaperGenerationData.WEATHER_EFFECT))
+ ).isEqualTo(expectedWeatherEffect)
+ assertThat(cursor.columnNames).isEqualTo(WallpaperGenerationData.DEFAULT_PROJECTION)
+ }
+ }
+
+ @Test
+ fun query_updateWallpaper_withNoParams_returnsCorrectData() {
+ testScope.runTest {
+ val uri = WallpaperInfoContract.getUpdateWallpaperUri().build()
+
+ val cursor = weatherEffectsContentProvider.query(
+ uri,
+ projection = null,
+ selection = null,
+ selectionArgs = null,
+ sortOrder = null
+ )
+
+ assertThat(cursor.count).isEqualTo(1)
+ cursor.moveToFirst()
+
+ assertThat(cursor.getString(
+ cursor.getColumnIndex(WallpaperGenerationData.FOREGROUND_TEXTURE))
+ ).isNull()
+ assertThat(cursor.getString(
+ cursor.getColumnIndex(WallpaperGenerationData.BACKGROUND_TEXTURE))
+ ).isNull()
+ assertThat(cursor.getString(
+ cursor.getColumnIndex(WallpaperGenerationData.WEATHER_EFFECT))
+ ).isNull()
+ assertThat(cursor.columnNames).isEqualTo(WallpaperGenerationData.DEFAULT_PROJECTION)
+ }
+ }
+} \ No newline at end of file