1 /* <lambda>null2 * Copyright 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.glance.appwidget 18 19 import android.Manifest 20 import android.appwidget.AppWidgetHostView 21 import android.appwidget.AppWidgetManager 22 import android.content.Context 23 import android.content.pm.ActivityInfo 24 import android.content.res.Configuration 25 import android.util.Log 26 import android.view.View 27 import android.view.ViewGroup 28 import android.view.ViewTreeObserver 29 import androidx.compose.ui.unit.DpSize 30 import androidx.compose.ui.unit.dp 31 import androidx.core.view.children 32 import androidx.test.core.app.ActivityScenario 33 import androidx.test.core.app.ApplicationProvider 34 import androidx.test.ext.junit.rules.ActivityScenarioRule 35 import androidx.test.filters.SdkSuppress 36 import androidx.test.platform.app.InstrumentationRegistry 37 import androidx.test.uiautomator.UiDevice 38 import androidx.work.WorkManager 39 import androidx.work.testing.WorkManagerTestInitHelper 40 import com.google.common.truth.Truth 41 import com.google.common.truth.Truth.assertThat 42 import java.lang.ref.WeakReference 43 import java.util.concurrent.CountDownLatch 44 import java.util.concurrent.TimeUnit 45 import kotlin.test.assertIs 46 import kotlin.test.fail 47 import kotlinx.coroutines.Dispatchers 48 import kotlinx.coroutines.channels.Channel 49 import kotlinx.coroutines.withContext 50 import kotlinx.coroutines.withTimeoutOrNull 51 import org.junit.rules.RuleChain 52 import org.junit.rules.TestRule 53 import org.junit.runner.Description 54 import org.junit.runners.model.Statement 55 56 @SdkSuppress(minSdkVersion = 29) 57 class AppWidgetHostRule( 58 private var mPortraitSize: DpSize = DpSize(200.dp, 300.dp), 59 private var mLandscapeSize: DpSize = DpSize(300.dp, 200.dp), 60 ) : TestRule { 61 62 val portraitSize: DpSize 63 get() = mPortraitSize 64 65 val landscapeSize: DpSize 66 get() = mLandscapeSize 67 68 private val mInstrumentation = InstrumentationRegistry.getInstrumentation() 69 private val mUiAutomation = mInstrumentation.uiAutomation 70 71 private val mActivityRule: ActivityScenarioRule<AppWidgetHostTestActivity> = 72 ActivityScenarioRule(AppWidgetHostTestActivity::class.java) 73 74 private val mUiDevice = UiDevice.getInstance(mInstrumentation) 75 76 // Ensure the screen starts in portrait and restore the orientation on leaving 77 private val mOrientationRule = TestRule { base, _ -> 78 object : Statement() { 79 override fun evaluate() { 80 mUiDevice.freezeRotation() 81 mUiDevice.setOrientationNatural() 82 base.evaluate() 83 mUiDevice.unfreezeRotation() 84 } 85 } 86 } 87 88 private val mInnerRules = RuleChain.outerRule(mActivityRule).around(mOrientationRule) 89 90 private lateinit var mMaybeHostView: WeakReference<TestAppWidgetHostView?> 91 92 private var mHostStarted = false 93 private var mAppWidgetId = 0 94 private val mScenario: ActivityScenario<AppWidgetHostTestActivity> 95 get() = mActivityRule.scenario 96 97 private val mContext = ApplicationProvider.getApplicationContext<Context>() 98 99 val mHostView: TestAppWidgetHostView 100 get() = checkNotNull(mMaybeHostView.get()) { "No app widget installed on the host" } 101 102 val appWidgetId: Int 103 get() = mAppWidgetId 104 105 val device: UiDevice 106 get() = mUiDevice 107 108 override fun apply(base: Statement, description: Description) = 109 object : Statement() { 110 111 override fun evaluate() { 112 WorkManagerTestInitHelper.initializeTestWorkManager(mContext) 113 waitForBroadcastIdle() 114 115 mInnerRules.apply(base, description).evaluate() 116 stopHost() 117 } 118 119 private fun stopHost() { 120 if (mHostStarted) { 121 mUiAutomation.dropShellPermissionIdentity() 122 } 123 WorkManager.getInstance(mContext).cancelAllWork() 124 } 125 } 126 127 /** Start the host and bind the app widget. */ 128 fun startHost() { 129 mUiAutomation.adoptShellPermissionIdentity(Manifest.permission.BIND_APPWIDGET) 130 mHostStarted = true 131 132 mActivityRule.scenario.onActivity { activity -> 133 mMaybeHostView = WeakReference(activity.bindAppWidget(mPortraitSize, mLandscapeSize)) 134 } 135 136 runAndWaitForChildren { 137 mAppWidgetId = mHostView.appWidgetId 138 mHostView.waitForRemoteViews() 139 } 140 } 141 142 /** 143 * Run the [block] (usually some sort of app widget update) and wait for new RemoteViews to be 144 * applied. 145 * 146 * This should not be called from the main thread, i.e. in [onHostView] or [onHostActivity]. 147 */ 148 suspend fun runAndWaitForUpdate(block: suspend () -> Unit) { 149 mHostView.resetRemoteViewsLatch() 150 withContext(Dispatchers.Main) { block() } 151 152 // b/267494219 these tests are currently flaking due to possible changes to the views after 153 // the initial update. Sleeping here is not the final fix, we need a better way to decide 154 // the UI has settled. In the short term this does reduce the flakiness. 155 Thread.sleep(5000) 156 157 // Do not wait on the main thread so that the UI handlers can run. 158 runAndWaitForChildren { mHostView.waitForRemoteViews() } 159 } 160 161 /** 162 * Set TestGlanceAppWidgetReceiver to ignore broadcasts, run [block], and then reset 163 * TestGlanceAppWidgetReceiver. 164 */ 165 fun ignoreBroadcasts(block: () -> Unit) { 166 TestGlanceAppWidgetReceiver.ignoreBroadcasts = true 167 try { 168 block() 169 } finally { 170 TestGlanceAppWidgetReceiver.ignoreBroadcasts = false 171 } 172 } 173 174 fun removeAppWidget() { 175 mActivityRule.scenario.onActivity { activity -> activity.deleteAppWidget(mHostView) } 176 } 177 178 fun onHostActivity(block: (AppWidgetHostTestActivity) -> Unit) { 179 mScenario.onActivity(block) 180 } 181 182 fun onHostView(block: (AppWidgetHostView) -> Unit) { 183 onHostActivity { block(mHostView) } 184 } 185 186 /** 187 * The top-level view is always boxed into a FrameLayout. 188 * 189 * This will retrieve the actual top-level view, skipping the boxing for the root view, and 190 * possibly the one to get the exact size. 191 */ 192 inline fun <reified T : View> onUnboxedHostView(crossinline block: (T) -> Unit) { 193 194 // b/267494219 these tests are currently flaking due to possible changes to the views after 195 // the initial update. Sleeping here is not the final fix, we need a better way to decide 196 // the UI has settled. In the short term this does reduce the flakiness. 197 var found = false 198 for (i in 1..20) { 199 if (!found) { 200 onHostActivity { 201 val boxingView = assertIs<ViewGroup>(mHostView.getChildAt(0)) 202 val childCount = boxingView.childCount 203 if (childCount != 0 && !boxingView.isLoading()) { 204 if (i > 1) Log.i(RECEIVER_TEST_TAG, "...now we have children") 205 block(boxingView.children.single().getTargetView()) 206 found = true 207 } else { 208 Log.i( 209 RECEIVER_TEST_TAG, 210 "$i Boxing view is empty or is still loading, waiting..." 211 ) 212 Log.i(RECEIVER_TEST_TAG, "Boxing view: $boxingView") 213 Thread.sleep(500) 214 } 215 } 216 } else { 217 return 218 } 219 } 220 fail("Waited for boxing view not to be empty, but it never got children") 221 } 222 223 /** Change the orientation to landscape. */ 224 fun setLandscapeOrientation() { 225 var activity: AppWidgetHostTestActivity? = null 226 onHostActivity { 227 it.resetConfigurationChangedLatch() 228 it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE 229 activity = it 230 } 231 checkNotNull(activity).apply { 232 waitForConfigurationChange() 233 assertThat(lastConfiguration.orientation).isEqualTo(Configuration.ORIENTATION_LANDSCAPE) 234 } 235 } 236 237 /** Change the orientation to portrait. */ 238 fun setPortraitOrientation() { 239 var activity: AppWidgetHostTestActivity? = null 240 onHostActivity { 241 it.resetConfigurationChangedLatch() 242 it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT 243 activity = it 244 } 245 checkNotNull(activity).apply { 246 waitForConfigurationChange() 247 assertThat(lastConfiguration.orientation).isEqualTo(Configuration.ORIENTATION_PORTRAIT) 248 } 249 } 250 251 /** 252 * Set the sizes for portrait and landscape for the host view. 253 * 254 * If specified, the options bundle for the AppWidget is updated and the code waits for the new 255 * RemoteViews from the provider. 256 * 257 * @param portraitSize Size of the view in portrait mode. 258 * @param landscapeSize Size of the view in landscape. If null, the portrait and landscape sizes 259 * will be set to be such that portrait is narrower than tall and the landscape wider than 260 * tall. 261 * @param updateRemoteViews If the host is already started and this is true, the provider will 262 * be called to get a new set of RemoteViews for the new sizes. 263 */ 264 fun setSizes( 265 portraitSize: DpSize, 266 landscapeSize: DpSize? = null, 267 updateRemoteViews: Boolean = true 268 ) { 269 val (portrait, landscape) = 270 if (landscapeSize != null) { 271 portraitSize to landscapeSize 272 } else { 273 if (portraitSize.width < portraitSize.height) { 274 portraitSize to DpSize(portraitSize.height, portraitSize.width) 275 } else { 276 DpSize(portraitSize.height, portraitSize.width) to portraitSize 277 } 278 } 279 mLandscapeSize = landscape 280 mPortraitSize = portrait 281 if (!mHostStarted) return 282 283 val hostView = mMaybeHostView.get() 284 if (hostView != null) { 285 mScenario.onActivity { hostView.setSizes(portrait, landscape) } 286 287 if (updateRemoteViews) { 288 runAndWaitForChildren { 289 hostView.resetRemoteViewsLatch() 290 AppWidgetManager.getInstance(mContext) 291 .updateAppWidgetOptions( 292 mAppWidgetId, 293 optionsBundleOf(listOf(portrait, landscape)) 294 ) 295 hostView.waitForRemoteViews() 296 } 297 } 298 } 299 } 300 301 fun runAndObserveUntilDraw( 302 condition: String = "Expected condition to be met within 5 seconds", 303 run: () -> Unit = {}, 304 test: () -> Boolean 305 ) { 306 val hostView = mHostView 307 val latch = CountDownLatch(1) 308 val onDrawListener = 309 ViewTreeObserver.OnDrawListener { 310 if (hostView.childCount > 0 && test()) latch.countDown() 311 } 312 mActivityRule.scenario.onActivity { 313 hostView.viewTreeObserver.addOnDrawListener(onDrawListener) 314 } 315 316 run() 317 318 try { 319 if (test()) return 320 val interval = 200L 321 for (timeout in 0..5000L step interval) { 322 val countedDown = latch.await(interval, TimeUnit.MILLISECONDS) 323 if (countedDown || test()) return 324 } 325 fail(condition) 326 } finally { 327 latch.countDown() // make sure it's released in all conditions 328 mActivityRule.scenario.onActivity { 329 hostView.viewTreeObserver.removeOnDrawListener(onDrawListener) 330 } 331 } 332 } 333 334 private fun runAndWaitForChildren(action: () -> Unit) { 335 runAndObserveUntilDraw("Expected new children on HostView within 5 seconds", action) { 336 mHostView.childCount > 0 337 } 338 } 339 340 /** 341 * Waits for given condition to be true and throws [AssertionError] with given message if not 342 * satisfied within the default timeout. 343 */ 344 suspend fun waitAndTestForCondition( 345 errorMessage: String, 346 timeoutMs: Long = 600, 347 condition: (TestAppWidgetHostView) -> Boolean 348 ) { 349 val resume = Channel<Unit>(Channel.CONFLATED) 350 fun test() = condition(mHostView) 351 val onDrawListener = ViewTreeObserver.OnDrawListener { if (test()) resume.trySend(Unit) } 352 353 onHostActivity { 354 // If test is already true, do not wait for the next draw to resume 355 if (test()) resume.trySend(Unit) 356 mHostView.viewTreeObserver.addOnDrawListener(onDrawListener) 357 } 358 try { 359 val status = 360 withTimeoutOrNull<Boolean?>(timeoutMs) { 361 resume.receive() 362 true 363 } 364 365 Truth.assertWithMessage(errorMessage).that(status).isEqualTo(true) 366 } finally { 367 onHostActivity { mHostView.viewTreeObserver.removeOnDrawListener(onDrawListener) } 368 } 369 } 370 } 371