ScreenOrientationAction.kt
/*
* Copyright (C) 2021 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 androidx.test.espresso.device.action
import android.app.Activity
import android.content.ComponentCallbacks
import android.content.ContentResolver
import android.content.Context
import android.content.pm.ActivityInfo.CONFIG_ORIENTATION
import android.content.res.Configuration
import android.database.ContentObserver
import android.os.Handler
import android.os.HandlerThread
import android.provider.Settings.System
import android.util.Log
import androidx.test.espresso.device.context.ActionContext
import androidx.test.espresso.device.util.executeShellCommand
import androidx.test.espresso.device.util.getDeviceApiLevel
import androidx.test.espresso.device.util.getResumedActivityOrNull
import androidx.test.espresso.device.util.isConfigurationChangeHandled
import androidx.test.espresso.device.util.isRobolectricTest
import androidx.test.platform.device.DeviceController
import androidx.test.platform.device.UnsupportedDeviceOperationException
import androidx.test.runner.lifecycle.ActivityLifecycleCallback
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
import androidx.test.runner.lifecycle.Stage
import java.util.concurrent.CountDownLatch
/** Action to set the test device to the provided screen orientation. */
internal class ScreenOrientationAction(val screenOrientation: ScreenOrientation) : DeviceAction {
/**
* Performs a screen rotation to the provided orientation.
*
* <p>If the device is already in the requested orientation, it's a no-op. Otherwise, the device
* will be set to the provided orientation.
*
* <p>Note, this method takes care of synchronization with the device and the app/activity under
* test after rotating the screen. Specifically, <ul>
* <li>if no activity is found in a RESUMED state, this method waits for the application's
* orientation to change to the requested orientation. </li>
* <li>if the activity handles device orientation change,this method waits for the application's
* orientation to change to the requested orientation, and relies on Espresso's {@code onView()}
* method to ensure it synchronizes properly with the updated activity. </li>
* <li>if the activity doesn't handle device orientation change, it waits until the activity is
* PAUSED and relies on Espresso's {@code onView()} method to ensure it synchronizes properly
* with the recreated activity. </li>
*
* </ul>
*
* @param context the ActionContext containing the context for this application and test app.
* @param deviceController the controller to use to interact with the device.
*/
override fun perform(context: ActionContext, deviceController: DeviceController) {
var currentOrientation =
context.applicationContext.getResources().getConfiguration().orientation
val requestedOrientation =
if (screenOrientation == ScreenOrientation.LANDSCAPE) Configuration.ORIENTATION_LANDSCAPE
else Configuration.ORIENTATION_PORTRAIT
if (currentOrientation == requestedOrientation) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "Device screen is already in the requested orientation, no need to rotate.")
}
return
}
if (isRobolectricTest()) {
deviceController.setScreenOrientation(screenOrientation.orientation)
return
}
var oldAccelRotationSetting = getAccelerometerRotationSetting(context.applicationContext)
if (oldAccelRotationSetting != AccelerometerRotation.ENABLED) {
// Executing shell commands requires API 21+.
if (getDeviceApiLevel() >= 21) {
Log.d(TAG, "Enabling auto-rotate.")
setAccelerometerRotation(AccelerometerRotation.ENABLED, context.applicationContext)
} else {
throw UnsupportedDeviceOperationException(
"Screen orientation cannot be set on this device because auto-rotate is disabled. Please manually enable auto-rotate and try again."
)
}
}
val currentActivity = getResumedActivityOrNull()
val currentActivityName: String? = currentActivity?.getLocalClassName()
val configChangesHandled =
if (currentActivity != null) {
currentActivity.isConfigurationChangeHandled(CONFIG_ORIENTATION)
} else {
false
}
val latch: CountDownLatch = CountDownLatch(1)
if (currentActivity == null || configChangesHandled) {
if (currentActivity == null) {
Log.d(TAG, "No activity was found in the RESUMED stage.")
} else if (configChangesHandled) {
Log.d(TAG, "The current activity handles configuration changes.")
}
context.applicationContext.registerComponentCallbacks(
object : ComponentCallbacks {
override fun onConfigurationChanged(newConfig: Configuration) {
if (newConfig.orientation == requestedOrientation) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "Application's orientation was set to the requested orientation.")
}
context.applicationContext.unregisterComponentCallbacks(this)
latch.countDown()
}
}
override fun onLowMemory() {}
}
)
} else {
Log.d(
TAG,
"The current activity does not handle configuration changes and will be recreated when " +
"its orientation changes."
)
ActivityLifecycleMonitorRegistry.getInstance()
.addLifecycleCallback(
object : ActivityLifecycleCallback {
override fun onActivityLifecycleChanged(activity: Activity, stage: Stage) {
if (
activity.getLocalClassName() == currentActivityName &&
stage == Stage.RESUMED &&
activity.getResources().getConfiguration().orientation == requestedOrientation
) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "Test activity was resumed in the requested orientation.")
}
ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(this)
latch.countDown()
}
}
}
)
}
deviceController.setScreenOrientation(screenOrientation.orientation)
latch.await()
if (
getDeviceApiLevel() >= 21 &&
oldAccelRotationSetting != getAccelerometerRotationSetting(context.applicationContext)
) {
setAccelerometerRotation(oldAccelRotationSetting, context.applicationContext)
}
}
private fun getAccelerometerRotationSetting(context: Context): AccelerometerRotation =
if (System.getInt(context.getContentResolver(), System.ACCELEROMETER_ROTATION, 0) == 1) {
AccelerometerRotation.ENABLED
} else {
AccelerometerRotation.DISABLED
}
private fun setAccelerometerRotation(
accelerometerRotation: AccelerometerRotation,
context: Context
) {
val settingsLatch: CountDownLatch = CountDownLatch(1)
val thread: HandlerThread = HandlerThread("Observer_Thread")
thread.start()
val runnableHandler: Handler = Handler(thread.getLooper())
val settingsObserver: SettingsObserver =
SettingsObserver(runnableHandler, context, settingsLatch, System.ACCELEROMETER_ROTATION)
settingsObserver.observe()
executeShellCommand("settings put system accelerometer_rotation ${accelerometerRotation.value}")
settingsLatch.await()
settingsObserver.stopObserver()
thread.quitSafely()
}
private class SettingsObserver(
handler: Handler,
val context: Context,
val latch: CountDownLatch,
val settingToObserve: String
) : ContentObserver(handler) {
fun observe() {
val resolver: ContentResolver = context.getContentResolver()
resolver.registerContentObserver(System.getUriFor(settingToObserve), false, this)
}
fun stopObserver() {
val resolver: ContentResolver = context.getContentResolver()
resolver.unregisterContentObserver(this)
}
override fun onChange(selfChange: Boolean) {
latch.countDown()
}
}
companion object {
private val TAG = ScreenOrientationAction::class.java.simpleName
private enum class AccelerometerRotation(val value: Int) {
DISABLED(0),
ENABLED(1)
}
}
}