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