Merge "Fix race condition in lowlight trigger logic" into udc-dev

This commit is contained in:
Lucas Silva
2023-03-29 17:11:03 +00:00
committed by Android (Google) Code Review
17 changed files with 873 additions and 516 deletions

View File

@@ -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"],

View File

@@ -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>

View File

@@ -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));
}
}

View File

@@ -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
}
}

View File

@@ -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();
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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);
}
}

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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",

View File

@@ -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());
}
}

View File

@@ -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
}
}

View File

@@ -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();
}
}

View File

@@ -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)
}
}

View File

@@ -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