blob: de50aacf33da7c6f03e0c48f9993d14e825a18c5 [file] [log] [blame]
/*
* Copyright 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.glance.appwidget
import android.Manifest
import android.appwidget.AppWidgetHostView
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.core.view.children
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import com.google.common.truth.Truth.assertThat
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.test.assertIs
import kotlin.test.fail
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
@SdkSuppress(minSdkVersion = 29)
class AppWidgetHostRule(
private var mPortraitSize: DpSize = DpSize(200.dp, 300.dp),
private var mLandscapeSize: DpSize = DpSize(300.dp, 200.dp),
) : TestRule {
val portraitSize: DpSize
get() = mPortraitSize
val landscapeSize: DpSize
get() = mLandscapeSize
private val mInstrumentation = InstrumentationRegistry.getInstrumentation()
private val mUiAutomation = mInstrumentation.uiAutomation
private val mActivityRule: ActivityScenarioRule<AppWidgetHostTestActivity> =
ActivityScenarioRule(AppWidgetHostTestActivity::class.java)
private val mUiDevice = UiDevice.getInstance(mInstrumentation)
// Ensure the screen starts in portrait and restore the orientation on leaving
private val mOrientationRule = TestRule { base, _ ->
object : Statement() {
override fun evaluate() {
mUiDevice.freezeRotation()
mUiDevice.setOrientationNatural()
base.evaluate()
mUiDevice.unfreezeRotation()
}
}
}
private val mInnerRules = RuleChain.outerRule(mActivityRule).around(mOrientationRule)
private var mHostStarted = false
private var mMaybeHostView: TestAppWidgetHostView? = null
private var mAppWidgetId = 0
private val mScenario: ActivityScenario<AppWidgetHostTestActivity>
get() = mActivityRule.scenario
private val mContext = ApplicationProvider.getApplicationContext<Context>()
val mHostView: TestAppWidgetHostView
get() = checkNotNull(mMaybeHostView) { "No app widget installed on the host" }
val appWidgetId: Int get() = mAppWidgetId
val device: UiDevice get() = mUiDevice
override fun apply(base: Statement, description: Description) = object : Statement() {
override fun evaluate() {
WorkManagerTestInitHelper.initializeTestWorkManager(mContext)
mInnerRules.apply(base, description).evaluate()
stopHost()
}
private fun stopHost() {
if (mHostStarted) {
mUiAutomation.dropShellPermissionIdentity()
}
WorkManager.getInstance(mContext).cancelAllWork()
}
}
/** Start the host and bind the app widget. */
fun startHost() {
mUiAutomation.adoptShellPermissionIdentity(Manifest.permission.BIND_APPWIDGET)
mHostStarted = true
mActivityRule.scenario.onActivity { activity ->
mMaybeHostView = activity.bindAppWidget(mPortraitSize, mLandscapeSize)
}
val hostView = checkNotNull(mMaybeHostView) { "Host view wasn't successfully started" }
runAndWaitForChildren {
mAppWidgetId = hostView.appWidgetId
hostView.waitForRemoteViews()
}
}
/**
* Run the [block] (usually some sort of app widget update) and wait for new RemoteViews to be
* applied.
*
* This should not be called from the main thread, i.e. in [onHostView] or [onHostActivity].
*/
suspend fun runAndWaitForUpdate(block: suspend () -> Unit) {
val hostView = checkNotNull(mMaybeHostView) { "Host view wasn't successfully started" }
hostView.resetRemoteViewsLatch()
withContext(Dispatchers.Main) { block() }
// b/267494219 these tests are currently flaking due to possible changes to the views after
// the initial update. Sleeping here is not the final fix, we need a better way to decide
// the UI has settled. In the short term this does reduce the flakiness.
Thread.sleep(5000)
// Do not wait on the main thread so that the UI handlers can run.
runAndWaitForChildren {
hostView.waitForRemoteViews()
}
}
/**
* Set TestGlanceAppWidgetReceiver to ignore broadcasts, run [block], and then reset
* TestGlanceAppWidgetReceiver.
*/
fun ignoreBroadcasts(block: () -> Unit) {
TestGlanceAppWidgetReceiver.ignoreBroadcasts = true
try {
block()
} finally {
TestGlanceAppWidgetReceiver.ignoreBroadcasts = false
}
}
fun removeAppWidget() {
mActivityRule.scenario.onActivity { activity ->
val hostView = checkNotNull(mMaybeHostView) { "No app widget to remove" }
activity.deleteAppWidget(hostView)
}
}
fun onHostActivity(block: (AppWidgetHostTestActivity) -> Unit) {
mScenario.onActivity(block)
}
fun onHostView(block: (AppWidgetHostView) -> Unit) {
onHostActivity { block(mHostView) }
}
/**
* The top-level view is always boxed into a FrameLayout.
*
* This will retrieve the actual top-level view, skipping the boxing for the root view, and
* possibly the one to get the exact size.
*/
inline fun <reified T : View> onUnboxedHostView(crossinline block: (T) -> Unit) {
// b/267494219 these tests are currently flaking due to possible changes to the views after
// the initial update. Sleeping here is not the final fix, we need a better way to decide
// the UI has settled. In the short term this does reduce the flakiness.
var found = false
for (i in 1..20) {
if (!found) {
onHostActivity {
val boxingView = assertIs<ViewGroup>(mHostView.getChildAt(0))
val childCount = boxingView.childCount
if (childCount != 0 && !boxingView.isLoading()) {
if (i > 1) Log.i(RECEIVER_TEST_TAG, "...now we have children")
block(boxingView.children.single().getTargetView())
found = true
} else {
Log.i(
RECEIVER_TEST_TAG,
"$i Boxing view is empty or is still loading, waiting..."
)
Log.i(RECEIVER_TEST_TAG, "Boxing view: $boxingView")
Thread.sleep(500)
}
}
} else {
return
}
}
fail("Waited for boxing view not to be empty, but it never got children")
}
/** Change the orientation to landscape.*/
fun setLandscapeOrientation() {
var activity: AppWidgetHostTestActivity? = null
onHostActivity {
it.resetConfigurationChangedLatch()
it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
activity = it
}
checkNotNull(activity).apply {
waitForConfigurationChange()
assertThat(lastConfiguration.orientation).isEqualTo(Configuration.ORIENTATION_LANDSCAPE)
}
}
/** Change the orientation to portrait.*/
fun setPortraitOrientation() {
var activity: AppWidgetHostTestActivity? = null
onHostActivity {
it.resetConfigurationChangedLatch()
it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
activity = it
}
checkNotNull(activity).apply {
waitForConfigurationChange()
assertThat(lastConfiguration.orientation).isEqualTo(Configuration.ORIENTATION_PORTRAIT)
}
}
/**
* Set the sizes for portrait and landscape for the host view.
*
* If specified, the options bundle for the AppWidget is updated and the code waits for the
* new RemoteViews from the provider.
*
* @param portraitSize Size of the view in portrait mode.
* @param landscapeSize Size of the view in landscape. If null, the portrait and landscape sizes
* will be set to be such that portrait is narrower than tall and the landscape wider than
* tall.
* @param updateRemoteViews If the host is already started and this is true, the provider will
* be called to get a new set of RemoteViews for the new sizes.
*/
fun setSizes(
portraitSize: DpSize,
landscapeSize: DpSize? = null,
updateRemoteViews: Boolean = true
) {
val (portrait, landscape) = if (landscapeSize != null) {
portraitSize to landscapeSize
} else {
if (portraitSize.width < portraitSize.height) {
portraitSize to DpSize(portraitSize.height, portraitSize.width)
} else {
DpSize(portraitSize.height, portraitSize.width) to portraitSize
}
}
mLandscapeSize = landscape
mPortraitSize = portrait
if (!mHostStarted) return
val hostView = mMaybeHostView
if (hostView != null) {
mScenario.onActivity {
hostView.setSizes(portrait, landscape)
}
if (updateRemoteViews) {
runAndWaitForChildren {
hostView.resetRemoteViewsLatch()
AppWidgetManager.getInstance(mContext).updateAppWidgetOptions(
mAppWidgetId,
optionsBundleOf(listOf(portrait, landscape))
)
hostView.waitForRemoteViews()
}
}
}
}
fun runAndObserveUntilDraw(
condition: String = "Expected condition to be met within 5 seconds",
run: () -> Unit = {},
test: () -> Boolean
) {
val hostView = mHostView
val latch = CountDownLatch(1)
val onDrawListener = ViewTreeObserver.OnDrawListener {
if (hostView.childCount > 0 && test()) latch.countDown()
}
mActivityRule.scenario.onActivity {
hostView.viewTreeObserver.addOnDrawListener(onDrawListener)
}
run()
try {
if (test()) return
val interval = 200L
for (timeout in 0..5000L step interval) {
val countedDown = latch.await(interval, TimeUnit.MILLISECONDS)
if (countedDown || test()) return
}
fail(condition)
} finally {
latch.countDown() // make sure it's released in all conditions
mActivityRule.scenario.onActivity {
hostView.viewTreeObserver.removeOnDrawListener(onDrawListener)
}
}
}
private fun runAndWaitForChildren(action: () -> Unit) {
runAndObserveUntilDraw("Expected new children on HostView within 5 seconds", action) {
mHostView.childCount > 0
}
}
}