From 6dd113cc323717886702a395513a0e4355d3a2f0 Mon Sep 17 00:00:00 2001 From: Lucas Silva Date: Wed, 22 Mar 2023 19:02:21 -0400 Subject: [PATCH] Fix race condition in lowlight trigger logic If the ambient light changes quickly between regular/lowlight before the animations are done, the user can become stuck in a bad state. This is reproducible with adb by forcing/clearing lowlight very quickly. This fix updates the logic to use kotlin, since kotlin suspend functions and coroutines are cancellable and make implementing this kind of cancellation straightforward. Leaning into the kotlin framework to ensure we get cancellation correct, instead of implementing something similar in Java. We also add an additional safeguard of with a 2s timeout, in case the animation never finishes. This ensures that we reset the state and aren't stuck waiting on the animation forever. Bug: 274024126 Test: atest LowLightDreamManagerTest Test: atest LowLightTransitionCoordinatorTest Change-Id: I8078ce9a71074d123d27584d05a9a7f960255812 --- libs/dream/lowlight/Android.bp | 6 + libs/dream/lowlight/res/values/config.xml | 3 + .../dream/lowlight/LowLightDreamManager.java | 122 ------------ .../dream/lowlight/LowLightDreamManager.kt | 138 +++++++++++++ .../LowLightTransitionCoordinator.java | 111 ----------- .../lowlight/LowLightTransitionCoordinator.kt | 118 +++++++++++ .../lowlight/dagger/LowLightDreamModule.java | 61 ------ .../lowlight/dagger/LowLightDreamModule.kt | 82 ++++++++ .../lowlight/dagger/qualifiers/Application.kt | 9 + .../dream/lowlight/dagger/qualifiers/Main.kt | 8 + .../dream/lowlight/util/KotlinUtils.kt | 29 +++ libs/dream/lowlight/tests/Android.bp | 2 + .../LowLightDreamManagerTest.java | 109 ----------- .../LowLightDreamManagerTest.kt | 169 ++++++++++++++++ .../LowLightTransitionCoordinatorTest.java | 113 ----------- .../LowLightTransitionCoordinatorTest.kt | 184 ++++++++++++++++++ .../lowlight/utils/KotlinMockitoHelpers.kt | 125 ++++++++++++ 17 files changed, 873 insertions(+), 516 deletions(-) delete mode 100644 libs/dream/lowlight/src/com/android/dream/lowlight/LowLightDreamManager.java create mode 100644 libs/dream/lowlight/src/com/android/dream/lowlight/LowLightDreamManager.kt delete mode 100644 libs/dream/lowlight/src/com/android/dream/lowlight/LowLightTransitionCoordinator.java create mode 100644 libs/dream/lowlight/src/com/android/dream/lowlight/LowLightTransitionCoordinator.kt delete mode 100644 libs/dream/lowlight/src/com/android/dream/lowlight/dagger/LowLightDreamModule.java create mode 100644 libs/dream/lowlight/src/com/android/dream/lowlight/dagger/LowLightDreamModule.kt create mode 100644 libs/dream/lowlight/src/com/android/dream/lowlight/dagger/qualifiers/Application.kt create mode 100644 libs/dream/lowlight/src/com/android/dream/lowlight/dagger/qualifiers/Main.kt create mode 100644 libs/dream/lowlight/src/com/android/dream/lowlight/util/KotlinUtils.kt delete mode 100644 libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightDreamManagerTest.java create mode 100644 libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightDreamManagerTest.kt delete mode 100644 libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightTransitionCoordinatorTest.java create mode 100644 libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightTransitionCoordinatorTest.kt create mode 100644 libs/dream/lowlight/tests/src/com/android/dream/lowlight/utils/KotlinMockitoHelpers.kt diff --git a/libs/dream/lowlight/Android.bp b/libs/dream/lowlight/Android.bp index 5b5b0f07cabdc..e4d2e022cd76b 100644 --- a/libs/dream/lowlight/Android.bp +++ b/libs/dream/lowlight/Android.bp @@ -25,6 +25,7 @@ filegroup { name: "low_light_dream_lib-sources", srcs: [ "src/**/*.java", + "src/**/*.kt", ], path: "src", } @@ -37,10 +38,15 @@ android_library { resource_dirs: [ "res", ], + libs: [ + "kotlin-annotations", + ], static_libs: [ "androidx.arch.core_core-runtime", "dagger2", "jsr330", + "kotlinx-coroutines-android", + "kotlinx-coroutines-core", ], manifest: "AndroidManifest.xml", plugins: ["dagger2-compiler"], diff --git a/libs/dream/lowlight/res/values/config.xml b/libs/dream/lowlight/res/values/config.xml index 70fe0738a6f41..78fefbf411418 100644 --- a/libs/dream/lowlight/res/values/config.xml +++ b/libs/dream/lowlight/res/values/config.xml @@ -17,4 +17,7 @@ + + 2000 diff --git a/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightDreamManager.java b/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightDreamManager.java deleted file mode 100644 index 3125f088c72b2..0000000000000 --- a/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightDreamManager.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.dream.lowlight; - -import static com.android.dream.lowlight.dagger.LowLightDreamModule.LOW_LIGHT_DREAM_COMPONENT; - -import android.annotation.IntDef; -import android.annotation.RequiresPermission; -import android.app.DreamManager; -import android.content.ComponentName; -import android.util.Log; - -import androidx.annotation.Nullable; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - -import javax.inject.Inject; -import javax.inject.Named; - -/** - * Maintains the ambient light mode of the environment the device is in, and sets a low light dream - * component, if present, as the system dream when the ambient light mode is low light. - * - * @hide - */ -public final class LowLightDreamManager { - private static final String TAG = "LowLightDreamManager"; - private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); - - /** - * @hide - */ - @Retention(RetentionPolicy.SOURCE) - @IntDef(prefix = { "AMBIENT_LIGHT_MODE_" }, value = { - AMBIENT_LIGHT_MODE_UNKNOWN, - AMBIENT_LIGHT_MODE_REGULAR, - AMBIENT_LIGHT_MODE_LOW_LIGHT - }) - public @interface AmbientLightMode {} - - /** - * Constant for ambient light mode being unknown. - * @hide - */ - public static final int AMBIENT_LIGHT_MODE_UNKNOWN = 0; - - /** - * Constant for ambient light mode being regular / bright. - * @hide - */ - public static final int AMBIENT_LIGHT_MODE_REGULAR = 1; - - /** - * Constant for ambient light mode being low light / dim. - * @hide - */ - public static final int AMBIENT_LIGHT_MODE_LOW_LIGHT = 2; - - private final DreamManager mDreamManager; - private final LowLightTransitionCoordinator mLowLightTransitionCoordinator; - - @Nullable - private final ComponentName mLowLightDreamComponent; - - private int mAmbientLightMode = AMBIENT_LIGHT_MODE_UNKNOWN; - - @Inject - public LowLightDreamManager( - DreamManager dreamManager, - LowLightTransitionCoordinator lowLightTransitionCoordinator, - @Named(LOW_LIGHT_DREAM_COMPONENT) @Nullable ComponentName lowLightDreamComponent) { - mDreamManager = dreamManager; - mLowLightTransitionCoordinator = lowLightTransitionCoordinator; - mLowLightDreamComponent = lowLightDreamComponent; - } - - /** - * Sets the current ambient light mode. - * @hide - */ - @RequiresPermission(android.Manifest.permission.WRITE_DREAM_STATE) - public void setAmbientLightMode(@AmbientLightMode int ambientLightMode) { - if (mLowLightDreamComponent == null) { - if (DEBUG) { - Log.d(TAG, "ignore ambient light mode change because low light dream component " - + "is empty"); - } - return; - } - - if (mAmbientLightMode == ambientLightMode) { - return; - } - - if (DEBUG) { - Log.d(TAG, "ambient light mode changed from " + mAmbientLightMode + " to " - + ambientLightMode); - } - - mAmbientLightMode = ambientLightMode; - - boolean shouldEnterLowLight = mAmbientLightMode == AMBIENT_LIGHT_MODE_LOW_LIGHT; - mLowLightTransitionCoordinator.notifyBeforeLowLightTransition(shouldEnterLowLight, - () -> mDreamManager.setSystemDreamComponent( - shouldEnterLowLight ? mLowLightDreamComponent : null)); - } -} diff --git a/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightDreamManager.kt b/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightDreamManager.kt new file mode 100644 index 0000000000000..96bfb78eff0d3 --- /dev/null +++ b/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightDreamManager.kt @@ -0,0 +1,138 @@ +/* + * 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.dream.lowlight + +import android.Manifest +import android.annotation.IntDef +import android.annotation.RequiresPermission +import android.app.DreamManager +import android.content.ComponentName +import android.util.Log +import com.android.dream.lowlight.dagger.LowLightDreamModule +import com.android.dream.lowlight.dagger.qualifiers.Application +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Named +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +/** + * Maintains the ambient light mode of the environment the device is in, and sets a low light dream + * component, if present, as the system dream when the ambient light mode is low light. + * + * @hide + */ +class LowLightDreamManager @Inject constructor( + @Application private val coroutineScope: CoroutineScope, + private val dreamManager: DreamManager, + private val lowLightTransitionCoordinator: LowLightTransitionCoordinator, + @param:Named(LowLightDreamModule.LOW_LIGHT_DREAM_COMPONENT) + private val lowLightDreamComponent: ComponentName?, + @param:Named(LowLightDreamModule.LOW_LIGHT_TRANSITION_TIMEOUT_MS) + private val lowLightTransitionTimeoutMs: Long +) { + /** + * @hide + */ + @Retention(AnnotationRetention.SOURCE) + @IntDef( + prefix = ["AMBIENT_LIGHT_MODE_"], + value = [ + AMBIENT_LIGHT_MODE_UNKNOWN, + AMBIENT_LIGHT_MODE_REGULAR, + AMBIENT_LIGHT_MODE_LOW_LIGHT + ] + ) + annotation class AmbientLightMode + + private var mTransitionJob: Job? = null + private var mAmbientLightMode = AMBIENT_LIGHT_MODE_UNKNOWN + private val mLowLightTransitionTimeout = + lowLightTransitionTimeoutMs.toDuration(DurationUnit.MILLISECONDS) + + /** + * Sets the current ambient light mode. + * + * @hide + */ + @RequiresPermission(Manifest.permission.WRITE_DREAM_STATE) + fun setAmbientLightMode(@AmbientLightMode ambientLightMode: Int) { + if (lowLightDreamComponent == null) { + if (DEBUG) { + Log.d( + TAG, + "ignore ambient light mode change because low light dream component is empty" + ) + } + return + } + if (mAmbientLightMode == ambientLightMode) { + return + } + if (DEBUG) { + Log.d( + TAG, "ambient light mode changed from $mAmbientLightMode to $ambientLightMode" + ) + } + mAmbientLightMode = ambientLightMode + val shouldEnterLowLight = mAmbientLightMode == AMBIENT_LIGHT_MODE_LOW_LIGHT + + // Cancel any previous transitions + mTransitionJob?.cancel() + mTransitionJob = coroutineScope.launch { + try { + lowLightTransitionCoordinator.waitForLowLightTransitionAnimation( + timeout = mLowLightTransitionTimeout, + entering = shouldEnterLowLight + ) + } catch (ex: TimeoutCancellationException) { + Log.e(TAG, "timed out while waiting for low light animation", ex) + } + dreamManager.setSystemDreamComponent( + if (shouldEnterLowLight) lowLightDreamComponent else null + ) + } + } + + companion object { + private const val TAG = "LowLightDreamManager" + private val DEBUG = Log.isLoggable(TAG, Log.DEBUG) + + /** + * Constant for ambient light mode being unknown. + * + * @hide + */ + const val AMBIENT_LIGHT_MODE_UNKNOWN = 0 + + /** + * Constant for ambient light mode being regular / bright. + * + * @hide + */ + const val AMBIENT_LIGHT_MODE_REGULAR = 1 + + /** + * Constant for ambient light mode being low light / dim. + * + * @hide + */ + const val AMBIENT_LIGHT_MODE_LOW_LIGHT = 2 + } +} diff --git a/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightTransitionCoordinator.java b/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightTransitionCoordinator.java deleted file mode 100644 index 874a2d5af75e2..0000000000000 --- a/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightTransitionCoordinator.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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.dream.lowlight; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.annotation.Nullable; - -import javax.inject.Inject; -import javax.inject.Singleton; - -/** - * Helper class that allows listening and running animations before entering or exiting low light. - */ -@Singleton -public class LowLightTransitionCoordinator { - /** - * Listener that is notified before low light entry. - */ - public interface LowLightEnterListener { - /** - * Callback that is notified before the device enters low light. - * - * @return an optional animator that will be waited upon before entering low light. - */ - Animator onBeforeEnterLowLight(); - } - - /** - * Listener that is notified before low light exit. - */ - public interface LowLightExitListener { - /** - * Callback that is notified before the device exits low light. - * - * @return an optional animator that will be waited upon before exiting low light. - */ - Animator onBeforeExitLowLight(); - } - - private LowLightEnterListener mLowLightEnterListener; - private LowLightExitListener mLowLightExitListener; - - @Inject - public LowLightTransitionCoordinator() { - } - - /** - * Sets the listener for the low light enter event. - * - * Only one listener can be set at a time. This method will overwrite any previously set - * listener. Null can be used to unset the listener. - */ - public void setLowLightEnterListener(@Nullable LowLightEnterListener lowLightEnterListener) { - mLowLightEnterListener = lowLightEnterListener; - } - - /** - * Sets the listener for the low light exit event. - * - * Only one listener can be set at a time. This method will overwrite any previously set - * listener. Null can be used to unset the listener. - */ - public void setLowLightExitListener(@Nullable LowLightExitListener lowLightExitListener) { - mLowLightExitListener = lowLightExitListener; - } - - /** - * Notifies listeners that the device is about to enter or exit low light. - * - * @param entering true if listeners should be notified before entering low light, false if this - * is notifying before exiting. - * @param callback callback that will be run after listeners complete. - */ - void notifyBeforeLowLightTransition(boolean entering, Runnable callback) { - Animator animator = null; - - if (entering && mLowLightEnterListener != null) { - animator = mLowLightEnterListener.onBeforeEnterLowLight(); - } else if (!entering && mLowLightExitListener != null) { - animator = mLowLightExitListener.onBeforeExitLowLight(); - } - - // If the listener returned an animator to indicate it was running an animation, run the - // callback after the animation completes, otherwise call the callback directly. - if (animator != null) { - animator.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animator) { - callback.run(); - } - }); - } else { - callback.run(); - } - } -} diff --git a/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightTransitionCoordinator.kt b/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightTransitionCoordinator.kt new file mode 100644 index 0000000000000..26efb55fa560d --- /dev/null +++ b/libs/dream/lowlight/src/com/android/dream/lowlight/LowLightTransitionCoordinator.kt @@ -0,0 +1,118 @@ +/* + * 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.dream.lowlight + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import com.android.dream.lowlight.util.suspendCoroutineWithTimeout +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.time.Duration + +/** + * Helper class that allows listening and running animations before entering or exiting low light. + */ +@Singleton +class LowLightTransitionCoordinator @Inject constructor() { + /** + * Listener that is notified before low light entry. + */ + interface LowLightEnterListener { + /** + * Callback that is notified before the device enters low light. + * + * @return an optional animator that will be waited upon before entering low light. + */ + fun onBeforeEnterLowLight(): Animator? + } + + /** + * Listener that is notified before low light exit. + */ + interface LowLightExitListener { + /** + * Callback that is notified before the device exits low light. + * + * @return an optional animator that will be waited upon before exiting low light. + */ + fun onBeforeExitLowLight(): Animator? + } + + private var mLowLightEnterListener: LowLightEnterListener? = null + private var mLowLightExitListener: LowLightExitListener? = null + + /** + * Sets the listener for the low light enter event. + * + * Only one listener can be set at a time. This method will overwrite any previously set + * listener. Null can be used to unset the listener. + */ + fun setLowLightEnterListener(lowLightEnterListener: LowLightEnterListener?) { + mLowLightEnterListener = lowLightEnterListener + } + + /** + * Sets the listener for the low light exit event. + * + * Only one listener can be set at a time. This method will overwrite any previously set + * listener. Null can be used to unset the listener. + */ + fun setLowLightExitListener(lowLightExitListener: LowLightExitListener?) { + mLowLightExitListener = lowLightExitListener + } + + /** + * Notifies listeners that the device is about to enter or exit low light, and waits for the + * animation to complete. If this function is cancelled, the animation is also cancelled. + * + * @param timeout the maximum duration to wait for the transition animation. If the animation + * does not complete within this time period, a + * @param entering true if listeners should be notified before entering low light, false if this + * is notifying before exiting. + */ + suspend fun waitForLowLightTransitionAnimation(timeout: Duration, entering: Boolean) = + suspendCoroutineWithTimeout(timeout) { continuation -> + var animator: Animator? = null + if (entering && mLowLightEnterListener != null) { + animator = mLowLightEnterListener!!.onBeforeEnterLowLight() + } else if (!entering && mLowLightExitListener != null) { + animator = mLowLightExitListener!!.onBeforeExitLowLight() + } + + if (animator == null) { + continuation.resume(Unit) + return@suspendCoroutineWithTimeout + } + + // If the listener returned an animator to indicate it was running an animation, run the + // callback after the animation completes, otherwise call the callback directly. + val listener = object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animator: Animator) { + continuation.resume(Unit) + } + + override fun onAnimationCancel(animation: Animator) { + continuation.cancel() + } + } + animator.addListener(listener) + continuation.invokeOnCancellation { + animator.removeListener(listener) + animator.cancel() + } + } +} diff --git a/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/LowLightDreamModule.java b/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/LowLightDreamModule.java deleted file mode 100644 index c183a04cb2f9b..0000000000000 --- a/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/LowLightDreamModule.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.dream.lowlight.dagger; - -import android.app.DreamManager; -import android.content.ComponentName; -import android.content.Context; - -import androidx.annotation.Nullable; - -import com.android.dream.lowlight.R; - -import javax.inject.Named; - -import dagger.Module; -import dagger.Provides; - -/** - * Dagger module for low light dream. - * - * @hide - */ -@Module -public interface LowLightDreamModule { - String LOW_LIGHT_DREAM_COMPONENT = "low_light_dream_component"; - - /** - * Provides dream manager. - */ - @Provides - static DreamManager providesDreamManager(Context context) { - return context.getSystemService(DreamManager.class); - } - - /** - * Provides the component name of the low light dream, or null if not configured. - */ - @Provides - @Named(LOW_LIGHT_DREAM_COMPONENT) - @Nullable - static ComponentName providesLowLightDreamComponent(Context context) { - final String lowLightDreamComponent = context.getResources().getString( - R.string.config_lowLightDreamComponent); - return lowLightDreamComponent.isEmpty() ? null - : ComponentName.unflattenFromString(lowLightDreamComponent); - } -} diff --git a/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/LowLightDreamModule.kt b/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/LowLightDreamModule.kt new file mode 100644 index 0000000000000..dd274bd9d5091 --- /dev/null +++ b/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/LowLightDreamModule.kt @@ -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. + */ +package com.android.dream.lowlight.dagger + +import android.app.DreamManager +import android.content.ComponentName +import android.content.Context +import com.android.dream.lowlight.R +import com.android.dream.lowlight.dagger.qualifiers.Application +import com.android.dream.lowlight.dagger.qualifiers.Main +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import javax.inject.Named + +/** + * Dagger module for low light dream. + * + * @hide + */ +@Module +object LowLightDreamModule { + /** + * Provides dream manager. + */ + @Provides + fun providesDreamManager(context: Context): DreamManager { + return requireNotNull(context.getSystemService(DreamManager::class.java)) + } + + /** + * Provides the component name of the low light dream, or null if not configured. + */ + @Provides + @Named(LOW_LIGHT_DREAM_COMPONENT) + fun providesLowLightDreamComponent(context: Context): ComponentName? { + val lowLightDreamComponent = context.resources.getString( + R.string.config_lowLightDreamComponent + ) + return if (lowLightDreamComponent.isEmpty()) { + null + } else { + ComponentName.unflattenFromString(lowLightDreamComponent) + } + } + + @Provides + @Named(LOW_LIGHT_TRANSITION_TIMEOUT_MS) + fun providesLowLightTransitionTimeout(context: Context): Long { + return context.resources.getInteger(R.integer.config_lowLightTransitionTimeoutMs).toLong() + } + + @Provides + @Main + fun providesMainDispatcher(): CoroutineDispatcher { + return Dispatchers.Main.immediate + } + + @Provides + @Application + fun providesApplicationScope(@Main dispatcher: CoroutineDispatcher): CoroutineScope { + return CoroutineScope(dispatcher) + } + + const val LOW_LIGHT_DREAM_COMPONENT = "low_light_dream_component" + const val LOW_LIGHT_TRANSITION_TIMEOUT_MS = "low_light_transition_timeout" +} diff --git a/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/qualifiers/Application.kt b/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/qualifiers/Application.kt new file mode 100644 index 0000000000000..541fe4017e6e3 --- /dev/null +++ b/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/qualifiers/Application.kt @@ -0,0 +1,9 @@ +package com.android.dream.lowlight.dagger.qualifiers + +import android.content.Context +import javax.inject.Qualifier + +/** + * Used to qualify a context as [Context.getApplicationContext] + */ +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Application diff --git a/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/qualifiers/Main.kt b/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/qualifiers/Main.kt new file mode 100644 index 0000000000000..ccd0710bdc605 --- /dev/null +++ b/libs/dream/lowlight/src/com/android/dream/lowlight/dagger/qualifiers/Main.kt @@ -0,0 +1,8 @@ +package com.android.dream.lowlight.dagger.qualifiers + +import javax.inject.Qualifier + +/** + * Used to qualify code running on the main thread. + */ +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Main diff --git a/libs/dream/lowlight/src/com/android/dream/lowlight/util/KotlinUtils.kt b/libs/dream/lowlight/src/com/android/dream/lowlight/util/KotlinUtils.kt new file mode 100644 index 0000000000000..ff675ccfaffb0 --- /dev/null +++ b/libs/dream/lowlight/src/com/android/dream/lowlight/util/KotlinUtils.kt @@ -0,0 +1,29 @@ +/* + * 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.dream.lowlight.util + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration + +suspend inline fun suspendCoroutineWithTimeout( + timeout: Duration, + crossinline block: (CancellableContinuation) -> Unit +) = withTimeout(timeout) { + suspendCancellableCoroutine(block = block) +} diff --git a/libs/dream/lowlight/tests/Android.bp b/libs/dream/lowlight/tests/Android.bp index bd6f05eabac5c..2d79090cd7d4c 100644 --- a/libs/dream/lowlight/tests/Android.bp +++ b/libs/dream/lowlight/tests/Android.bp @@ -20,6 +20,7 @@ android_test { name: "LowLightDreamTests", srcs: [ "**/*.java", + "**/*.kt", ], static_libs: [ "LowLightDreamLib", @@ -28,6 +29,7 @@ android_test { "androidx.test.ext.junit", "frameworks-base-testutils", "junit", + "kotlinx_coroutines_test", "mockito-target-extended-minus-junit4", "platform-test-annotations", "testables", diff --git a/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightDreamManagerTest.java b/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightDreamManagerTest.java deleted file mode 100644 index 4b95d8c84bace..0000000000000 --- a/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightDreamManagerTest.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.dream.lowlight; - -import static com.android.dream.lowlight.LowLightDreamManager.AMBIENT_LIGHT_MODE_LOW_LIGHT; -import static com.android.dream.lowlight.LowLightDreamManager.AMBIENT_LIGHT_MODE_REGULAR; -import static com.android.dream.lowlight.LowLightDreamManager.AMBIENT_LIGHT_MODE_UNKNOWN; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.clearInvocations; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -import android.app.DreamManager; -import android.content.ComponentName; -import android.testing.AndroidTestingRunner; - -import androidx.test.filters.SmallTest; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -@SmallTest -@RunWith(AndroidTestingRunner.class) -public class LowLightDreamManagerTest { - @Mock - private DreamManager mDreamManager; - - @Mock - private LowLightTransitionCoordinator mTransitionCoordinator; - - @Mock - private ComponentName mDreamComponent; - - LowLightDreamManager mLowLightDreamManager; - - @Before - public void setUp() { - MockitoAnnotations.initMocks(this); - - // Automatically run any provided Runnable to mTransitionCoordinator to simplify testing. - doAnswer(invocation -> { - ((Runnable) invocation.getArgument(1)).run(); - return null; - }).when(mTransitionCoordinator).notifyBeforeLowLightTransition(anyBoolean(), - any(Runnable.class)); - - mLowLightDreamManager = new LowLightDreamManager(mDreamManager, mTransitionCoordinator, - mDreamComponent); - } - - @Test - public void setAmbientLightMode_lowLight_setSystemDream() { - mLowLightDreamManager.setAmbientLightMode(AMBIENT_LIGHT_MODE_LOW_LIGHT); - - verify(mTransitionCoordinator).notifyBeforeLowLightTransition(eq(true), any()); - verify(mDreamManager).setSystemDreamComponent(mDreamComponent); - } - - @Test - public void setAmbientLightMode_regularLight_clearSystemDream() { - mLowLightDreamManager.setAmbientLightMode(AMBIENT_LIGHT_MODE_REGULAR); - - verify(mTransitionCoordinator).notifyBeforeLowLightTransition(eq(false), any()); - verify(mDreamManager).setSystemDreamComponent(null); - } - - @Test - public void setAmbientLightMode_defaultUnknownMode_clearSystemDream() { - // Set to low light first. - mLowLightDreamManager.setAmbientLightMode(AMBIENT_LIGHT_MODE_LOW_LIGHT); - clearInvocations(mDreamManager); - - // Return to default unknown mode. - mLowLightDreamManager.setAmbientLightMode(AMBIENT_LIGHT_MODE_UNKNOWN); - - verify(mDreamManager).setSystemDreamComponent(null); - } - - @Test - public void setAmbientLightMode_dreamComponentNotSet_doNothing() { - final LowLightDreamManager lowLightDreamManager = new LowLightDreamManager(mDreamManager, - mTransitionCoordinator, null /*dream component*/); - - lowLightDreamManager.setAmbientLightMode(AMBIENT_LIGHT_MODE_LOW_LIGHT); - - verify(mDreamManager, never()).setSystemDreamComponent(any()); - } -} diff --git a/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightDreamManagerTest.kt b/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightDreamManagerTest.kt new file mode 100644 index 0000000000000..2a886bc317886 --- /dev/null +++ b/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightDreamManagerTest.kt @@ -0,0 +1,169 @@ +/* + * 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.dream.lowlight + +import android.animation.Animator +import android.app.DreamManager +import android.content.ComponentName +import android.testing.AndroidTestingRunner +import androidx.test.filters.SmallTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.clearInvocations +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import src.com.android.dream.lowlight.utils.any +import src.com.android.dream.lowlight.utils.withArgCaptor + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(AndroidTestingRunner::class) +class LowLightDreamManagerTest { + @Mock + private lateinit var mDreamManager: DreamManager + @Mock + private lateinit var mEnterAnimator: Animator + @Mock + private lateinit var mExitAnimator: Animator + + private lateinit var mTransitionCoordinator: LowLightTransitionCoordinator + private lateinit var mLowLightDreamManager: LowLightDreamManager + private lateinit var testScope: TestScope + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + testScope = TestScope(StandardTestDispatcher()) + + mTransitionCoordinator = LowLightTransitionCoordinator() + mTransitionCoordinator.setLowLightEnterListener( + object : LowLightTransitionCoordinator.LowLightEnterListener { + override fun onBeforeEnterLowLight() = mEnterAnimator + }) + mTransitionCoordinator.setLowLightExitListener( + object : LowLightTransitionCoordinator.LowLightExitListener { + override fun onBeforeExitLowLight() = mExitAnimator + }) + + mLowLightDreamManager = LowLightDreamManager( + coroutineScope = testScope, + dreamManager = mDreamManager, + lowLightTransitionCoordinator = mTransitionCoordinator, + lowLightDreamComponent = DREAM_COMPONENT, + lowLightTransitionTimeoutMs = LOW_LIGHT_TIMEOUT_MS + ) + } + + @Test + fun setAmbientLightMode_lowLight_setSystemDream() = testScope.runTest { + mLowLightDreamManager.setAmbientLightMode(LowLightDreamManager.AMBIENT_LIGHT_MODE_LOW_LIGHT) + runCurrent() + verify(mDreamManager, never()).setSystemDreamComponent(DREAM_COMPONENT) + completeEnterAnimations() + runCurrent() + verify(mDreamManager).setSystemDreamComponent(DREAM_COMPONENT) + } + + @Test + fun setAmbientLightMode_regularLight_clearSystemDream() = testScope.runTest { + mLowLightDreamManager.setAmbientLightMode(LowLightDreamManager.AMBIENT_LIGHT_MODE_REGULAR) + runCurrent() + verify(mDreamManager, never()).setSystemDreamComponent(null) + completeExitAnimations() + runCurrent() + verify(mDreamManager).setSystemDreamComponent(null) + } + + @Test + fun setAmbientLightMode_defaultUnknownMode_clearSystemDream() = testScope.runTest { + // Set to low light first. + mLowLightDreamManager.setAmbientLightMode(LowLightDreamManager.AMBIENT_LIGHT_MODE_LOW_LIGHT) + runCurrent() + completeEnterAnimations() + runCurrent() + verify(mDreamManager).setSystemDreamComponent(DREAM_COMPONENT) + clearInvocations(mDreamManager) + + // Return to default unknown mode. + mLowLightDreamManager.setAmbientLightMode(LowLightDreamManager.AMBIENT_LIGHT_MODE_UNKNOWN) + runCurrent() + completeExitAnimations() + runCurrent() + verify(mDreamManager).setSystemDreamComponent(null) + } + + @Test + fun setAmbientLightMode_dreamComponentNotSet_doNothing() = testScope.runTest { + val lowLightDreamManager = LowLightDreamManager( + coroutineScope = testScope, + dreamManager = mDreamManager, + lowLightTransitionCoordinator = mTransitionCoordinator, + lowLightDreamComponent = null, + lowLightTransitionTimeoutMs = LOW_LIGHT_TIMEOUT_MS + ) + lowLightDreamManager.setAmbientLightMode(LowLightDreamManager.AMBIENT_LIGHT_MODE_LOW_LIGHT) + runCurrent() + verify(mEnterAnimator, never()).addListener(any()) + verify(mDreamManager, never()).setSystemDreamComponent(any()) + } + + @Test + fun setAmbientLightMode_multipleTimesBeforeAnimationEnds_cancelsPrevious() = testScope.runTest { + mLowLightDreamManager.setAmbientLightMode(LowLightDreamManager.AMBIENT_LIGHT_MODE_LOW_LIGHT) + runCurrent() + // If we reset the light mode back to regular before the previous animation finishes, it + // should be ignored. + mLowLightDreamManager.setAmbientLightMode(LowLightDreamManager.AMBIENT_LIGHT_MODE_REGULAR) + runCurrent() + completeEnterAnimations() + completeExitAnimations() + runCurrent() + verify(mDreamManager, times(1)).setSystemDreamComponent(null) + } + + @Test + fun setAmbientLightMode_animatorNeverFinishes_timesOut() = testScope.runTest { + mLowLightDreamManager.setAmbientLightMode(LowLightDreamManager.AMBIENT_LIGHT_MODE_LOW_LIGHT) + advanceTimeBy(delayTimeMillis = LOW_LIGHT_TIMEOUT_MS + 1) + // Animation never finishes, but we should still set the system dream + verify(mDreamManager).setSystemDreamComponent(DREAM_COMPONENT) + } + + private fun completeEnterAnimations() { + val listener = withArgCaptor { verify(mEnterAnimator).addListener(capture()) } + listener.onAnimationEnd(mEnterAnimator) + } + + private fun completeExitAnimations() { + val listener = withArgCaptor { verify(mExitAnimator).addListener(capture()) } + listener.onAnimationEnd(mExitAnimator) + } + + companion object { + private val DREAM_COMPONENT = ComponentName("test_package", "test_dream") + private const val LOW_LIGHT_TIMEOUT_MS: Long = 1000 + } +} \ No newline at end of file diff --git a/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightTransitionCoordinatorTest.java b/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightTransitionCoordinatorTest.java deleted file mode 100644 index 81e1e33d62204..0000000000000 --- a/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightTransitionCoordinatorTest.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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.dream.lowlight; - -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyZeroInteractions; -import static org.mockito.Mockito.when; - -import android.animation.Animator; -import android.testing.AndroidTestingRunner; - -import androidx.test.filters.SmallTest; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -@SmallTest -@RunWith(AndroidTestingRunner.class) -public class LowLightTransitionCoordinatorTest { - @Mock - private LowLightTransitionCoordinator.LowLightEnterListener mEnterListener; - - @Mock - private LowLightTransitionCoordinator.LowLightExitListener mExitListener; - - @Mock - private Animator mAnimator; - - @Captor - private ArgumentCaptor mAnimatorListenerCaptor; - - @Mock - private Runnable mRunnable; - - @Before - public void setUp() { - MockitoAnnotations.initMocks(this); - } - - @Test - public void onEnterCalledOnListeners() { - LowLightTransitionCoordinator coordinator = new LowLightTransitionCoordinator(); - - coordinator.setLowLightEnterListener(mEnterListener); - - coordinator.notifyBeforeLowLightTransition(true, mRunnable); - - verify(mEnterListener).onBeforeEnterLowLight(); - verify(mRunnable).run(); - } - - @Test - public void onExitCalledOnListeners() { - LowLightTransitionCoordinator coordinator = new LowLightTransitionCoordinator(); - - coordinator.setLowLightExitListener(mExitListener); - - coordinator.notifyBeforeLowLightTransition(false, mRunnable); - - verify(mExitListener).onBeforeExitLowLight(); - verify(mRunnable).run(); - } - - @Test - public void listenerNotCalledAfterRemoval() { - LowLightTransitionCoordinator coordinator = new LowLightTransitionCoordinator(); - - coordinator.setLowLightEnterListener(mEnterListener); - coordinator.setLowLightEnterListener(null); - - coordinator.notifyBeforeLowLightTransition(true, mRunnable); - - verifyZeroInteractions(mEnterListener); - verify(mRunnable).run(); - } - - @Test - public void runnableCalledAfterAnimationEnds() { - when(mEnterListener.onBeforeEnterLowLight()).thenReturn(mAnimator); - - LowLightTransitionCoordinator coordinator = new LowLightTransitionCoordinator(); - coordinator.setLowLightEnterListener(mEnterListener); - - coordinator.notifyBeforeLowLightTransition(true, mRunnable); - - // Animator listener is added and the runnable is not run yet. - verify(mAnimator).addListener(mAnimatorListenerCaptor.capture()); - verifyZeroInteractions(mRunnable); - - // Runnable is run once the animation ends. - mAnimatorListenerCaptor.getValue().onAnimationEnd(null); - verify(mRunnable).run(); - } -} diff --git a/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightTransitionCoordinatorTest.kt b/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightTransitionCoordinatorTest.kt new file mode 100644 index 0000000000000..4c526a6ac69d0 --- /dev/null +++ b/libs/dream/lowlight/tests/src/com.android.dream.lowlight/LowLightTransitionCoordinatorTest.kt @@ -0,0 +1,184 @@ +/* + * 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.dream.lowlight + +import android.animation.Animator +import android.testing.AndroidTestingRunner +import androidx.test.filters.SmallTest +import com.android.dream.lowlight.LowLightTransitionCoordinator.LowLightEnterListener +import com.android.dream.lowlight.LowLightTransitionCoordinator.LowLightExitListener +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import src.com.android.dream.lowlight.utils.whenever +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +@SmallTest +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidTestingRunner::class) +class LowLightTransitionCoordinatorTest { + @Mock + private lateinit var mEnterListener: LowLightEnterListener + + @Mock + private lateinit var mExitListener: LowLightExitListener + + @Mock + private lateinit var mAnimator: Animator + + @Captor + private lateinit var mAnimatorListenerCaptor: ArgumentCaptor + + private lateinit var testScope: TestScope + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + testScope = TestScope(StandardTestDispatcher()) + } + + @Test + fun onEnterCalledOnListeners() = testScope.runTest { + val coordinator = LowLightTransitionCoordinator() + coordinator.setLowLightEnterListener(mEnterListener) + val job = launch { + coordinator.waitForLowLightTransitionAnimation(timeout = TIMEOUT, entering = true) + } + runCurrent() + verify(mEnterListener).onBeforeEnterLowLight() + assertThat(job.isCompleted).isTrue() + } + + @Test + fun onExitCalledOnListeners() = testScope.runTest { + val coordinator = LowLightTransitionCoordinator() + coordinator.setLowLightExitListener(mExitListener) + val job = launch { + coordinator.waitForLowLightTransitionAnimation(timeout = TIMEOUT, entering = false) + } + runCurrent() + verify(mExitListener).onBeforeExitLowLight() + assertThat(job.isCompleted).isTrue() + } + + @Test + fun listenerNotCalledAfterRemoval() = testScope.runTest { + val coordinator = LowLightTransitionCoordinator() + coordinator.setLowLightEnterListener(mEnterListener) + coordinator.setLowLightEnterListener(null) + val job = launch { + coordinator.waitForLowLightTransitionAnimation(timeout = TIMEOUT, entering = true) + } + runCurrent() + verify(mEnterListener, never()).onBeforeEnterLowLight() + assertThat(job.isCompleted).isTrue() + } + + @Test + fun waitsForAnimationToEnd() = testScope.runTest { + whenever(mEnterListener.onBeforeEnterLowLight()).thenReturn(mAnimator) + val coordinator = LowLightTransitionCoordinator() + coordinator.setLowLightEnterListener(mEnterListener) + val job = launch { + coordinator.waitForLowLightTransitionAnimation(timeout = TIMEOUT, entering = true) + } + runCurrent() + // Animator listener is added and the runnable is not run yet. + verify(mAnimator).addListener(mAnimatorListenerCaptor.capture()) + assertThat(job.isCompleted).isFalse() + + // Runnable is run once the animation ends. + mAnimatorListenerCaptor.value.onAnimationEnd(mAnimator) + runCurrent() + assertThat(job.isCompleted).isTrue() + assertThat(job.isCancelled).isFalse() + } + + @Test + fun waitsForTimeoutIfAnimationNeverEnds() = testScope.runTest { + whenever(mEnterListener.onBeforeEnterLowLight()).thenReturn(mAnimator) + val coordinator = LowLightTransitionCoordinator() + coordinator.setLowLightEnterListener(mEnterListener) + val job = launch { + coordinator.waitForLowLightTransitionAnimation(timeout = TIMEOUT, entering = true) + } + runCurrent() + assertThat(job.isCancelled).isFalse() + advanceTimeBy(delayTimeMillis = TIMEOUT.inWholeMilliseconds + 1) + // If animator doesn't complete within the timeout, we should cancel ourselves. + assertThat(job.isCancelled).isTrue() + } + + @Test + fun shouldCancelIfAnimationIsCancelled() = testScope.runTest { + whenever(mEnterListener.onBeforeEnterLowLight()).thenReturn(mAnimator) + val coordinator = LowLightTransitionCoordinator() + coordinator.setLowLightEnterListener(mEnterListener) + val job = launch { + coordinator.waitForLowLightTransitionAnimation(timeout = TIMEOUT, entering = true) + } + runCurrent() + // Animator listener is added and the runnable is not run yet. + verify(mAnimator).addListener(mAnimatorListenerCaptor.capture()) + assertThat(job.isCompleted).isFalse() + assertThat(job.isCancelled).isFalse() + + // Runnable is run once the animation ends. + mAnimatorListenerCaptor.value.onAnimationCancel(mAnimator) + runCurrent() + assertThat(job.isCompleted).isTrue() + assertThat(job.isCancelled).isTrue() + } + + @Test + fun shouldCancelAnimatorWhenJobCancelled() = testScope.runTest { + whenever(mEnterListener.onBeforeEnterLowLight()).thenReturn(mAnimator) + val coordinator = LowLightTransitionCoordinator() + coordinator.setLowLightEnterListener(mEnterListener) + val job = launch { + coordinator.waitForLowLightTransitionAnimation(timeout = TIMEOUT, entering = true) + } + runCurrent() + // Animator listener is added and the runnable is not run yet. + verify(mAnimator).addListener(mAnimatorListenerCaptor.capture()) + verify(mAnimator, never()).cancel() + assertThat(job.isCompleted).isFalse() + + job.cancel() + // We should have removed the listener and cancelled the animator + verify(mAnimator).removeListener(mAnimatorListenerCaptor.value) + verify(mAnimator).cancel() + } + + companion object { + private val TIMEOUT = 1.toDuration(DurationUnit.SECONDS) + } +} diff --git a/libs/dream/lowlight/tests/src/com/android/dream/lowlight/utils/KotlinMockitoHelpers.kt b/libs/dream/lowlight/tests/src/com/android/dream/lowlight/utils/KotlinMockitoHelpers.kt new file mode 100644 index 0000000000000..e5ec26ca4b417 --- /dev/null +++ b/libs/dream/lowlight/tests/src/com/android/dream/lowlight/utils/KotlinMockitoHelpers.kt @@ -0,0 +1,125 @@ +package src.com.android.dream.lowlight.utils + +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatcher +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.stubbing.OngoingStubbing +import org.mockito.stubbing.Stubber + +/** + * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun eq(obj: T): T = Mockito.eq(obj) + +/** + * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun any(type: Class): T = Mockito.any(type) +inline fun any(): T = any(T::class.java) + +/** + * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun argThat(matcher: ArgumentMatcher): T = Mockito.argThat(matcher) + +/** + * Kotlin type-inferred version of Mockito.nullable() + */ +inline fun nullable(): T? = Mockito.nullable(T::class.java) + +/** + * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException + * when null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun capture(argumentCaptor: ArgumentCaptor): T = argumentCaptor.capture() + +/** + * Helper function for creating an argumentCaptor in kotlin. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +inline fun argumentCaptor(): ArgumentCaptor = + ArgumentCaptor.forClass(T::class.java) + +/** + * Helper function for creating new mocks, without the need to pass in a [Class] instance. + * + * Generic T is nullable because implicitly bounded by Any?. + * + * @param apply builder function to simplify stub configuration by improving type inference. + */ +inline fun mock(apply: T.() -> Unit = {}): T = Mockito.mock(T::class.java) + .apply(apply) + +/** + * Helper function for stubbing methods without the need to use backticks. + * + * @see Mockito.when + */ +fun whenever(methodCall: T): OngoingStubbing = `when`(methodCall) +fun Stubber.whenever(mock: T): T = `when`(mock) + +/** + * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when + * kotlin tests are mocking kotlin objects and the methods take non-null parameters: + * + * java.lang.NullPointerException: capture() must not be null + */ +class KotlinArgumentCaptor constructor(clazz: Class) { + private val wrapped: ArgumentCaptor = ArgumentCaptor.forClass(clazz) + fun capture(): T = wrapped.capture() + val value: T + get() = wrapped.value + val allValues: List + get() = wrapped.allValues +} + +/** + * Helper function for creating an argumentCaptor in kotlin. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +inline fun kotlinArgumentCaptor(): KotlinArgumentCaptor = + KotlinArgumentCaptor(T::class.java) + +/** + * Helper function for creating and using a single-use ArgumentCaptor in kotlin. + * + * val captor = argumentCaptor() + * verify(...).someMethod(captor.capture()) + * val captured = captor.value + * + * becomes: + * + * val captured = withArgCaptor { verify(...).someMethod(capture()) } + * + * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException. + */ +inline fun withArgCaptor(block: KotlinArgumentCaptor.() -> Unit): T = + kotlinArgumentCaptor().apply { block() }.value + +/** + * Variant of [withArgCaptor] for capturing multiple arguments. + * + * val captor = argumentCaptor() + * verify(...).someMethod(captor.capture()) + * val captured: List = captor.allValues + * + * becomes: + * + * val capturedList = captureMany { verify(...).someMethod(capture()) } + */ +inline fun captureMany(block: KotlinArgumentCaptor.() -> Unit): List = + kotlinArgumentCaptor().apply{ block() }.allValues