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
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -17,4 +17,7 @@
|
||||
<resources>
|
||||
<!-- The dream component used when the device is low light environment. -->
|
||||
<string translatable="false" name="config_lowLightDreamComponent"/>
|
||||
<!-- The max number of milliseconds to wait for the low light transition before setting
|
||||
the system dream component -->
|
||||
<integer name="config_lowLightTransitionTimeoutMs">2000</integer>
|
||||
</resources>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 <T> suspendCoroutineWithTimeout(
|
||||
timeout: Duration,
|
||||
crossinline block: (CancellableContinuation<T>) -> Unit
|
||||
) = withTimeout(timeout) {
|
||||
suspendCancellableCoroutine(block = block)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Animator.AnimatorListener> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Animator.AnimatorListener>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 <T> eq(obj: T): T = Mockito.eq<T>(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 <T> any(type: Class<T>): T = Mockito.any<T>(type)
|
||||
inline fun <reified T> 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 <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher)
|
||||
|
||||
/**
|
||||
* Kotlin type-inferred version of Mockito.nullable()
|
||||
*/
|
||||
inline fun <reified T> 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 <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
|
||||
|
||||
/**
|
||||
* Helper function for creating an argumentCaptor in kotlin.
|
||||
*
|
||||
* Generic T is nullable because implicitly bounded by Any?.
|
||||
*/
|
||||
inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> =
|
||||
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 <reified T : Any> 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 <T> whenever(methodCall: T): OngoingStubbing<T> = `when`(methodCall)
|
||||
fun <T> 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<T> constructor(clazz: Class<T>) {
|
||||
private val wrapped: ArgumentCaptor<T> = ArgumentCaptor.forClass(clazz)
|
||||
fun capture(): T = wrapped.capture()
|
||||
val value: T
|
||||
get() = wrapped.value
|
||||
val allValues: List<T>
|
||||
get() = wrapped.allValues
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for creating an argumentCaptor in kotlin.
|
||||
*
|
||||
* Generic T is nullable because implicitly bounded by Any?.
|
||||
*/
|
||||
inline fun <reified T : Any> kotlinArgumentCaptor(): KotlinArgumentCaptor<T> =
|
||||
KotlinArgumentCaptor(T::class.java)
|
||||
|
||||
/**
|
||||
* Helper function for creating and using a single-use ArgumentCaptor in kotlin.
|
||||
*
|
||||
* val captor = argumentCaptor<Foo>()
|
||||
* verify(...).someMethod(captor.capture())
|
||||
* val captured = captor.value
|
||||
*
|
||||
* becomes:
|
||||
*
|
||||
* val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) }
|
||||
*
|
||||
* NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException.
|
||||
*/
|
||||
inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() -> Unit): T =
|
||||
kotlinArgumentCaptor<T>().apply { block() }.value
|
||||
|
||||
/**
|
||||
* Variant of [withArgCaptor] for capturing multiple arguments.
|
||||
*
|
||||
* val captor = argumentCaptor<Foo>()
|
||||
* verify(...).someMethod(captor.capture())
|
||||
* val captured: List<Foo> = captor.allValues
|
||||
*
|
||||
* becomes:
|
||||
*
|
||||
* val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) }
|
||||
*/
|
||||
inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> =
|
||||
kotlinArgumentCaptor<T>().apply{ block() }.allValues
|
||||
Reference in New Issue
Block a user