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.appwidget.AppWidgetHostView
20 import android.appwidget.AppWidgetProviderInfo
21 import android.content.ComponentName
22 import android.content.Context
23 import android.content.pm.ActivityInfo
24 import android.content.pm.ApplicationInfo
25 import android.content.pm.LauncherApps
26 import android.content.res.Configuration
27 import android.os.Build
28 import android.os.Parcel
29 import android.text.TextUtils
30 import android.util.DisplayMetrics
31 import android.util.TypedValue
32 import android.view.View
33 import android.view.ViewGroup
34 import android.view.ViewGroup.LayoutParams
35 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
36 import android.widget.FrameLayout
37 import android.widget.RemoteViews
38 import androidx.compose.runtime.Composable
39 import androidx.compose.runtime.Composition
40 import androidx.compose.runtime.MonotonicFrameClock
41 import androidx.compose.runtime.Recomposer
42 import androidx.compose.ui.unit.DpSize
43 import androidx.compose.ui.unit.TextUnit
44 import androidx.core.view.children
45 import androidx.glance.Applier
46 import androidx.glance.GlanceComposable
47 import androidx.glance.GlanceId
48 import androidx.glance.session.GlobalSnapshotManager
49 import androidx.test.core.app.ApplicationProvider
50 import com.google.common.truth.Truth.assertThat
51 import java.util.Locale
52 import java.util.concurrent.atomic.AtomicBoolean
53 import kotlin.reflect.KMutableProperty
54 import kotlin.reflect.full.memberProperties
55 import kotlin.reflect.jvm.isAccessible
56 import kotlin.time.Duration.Companion.seconds
57 import kotlinx.coroutines.coroutineScope
58 import kotlinx.coroutines.currentCoroutineContext
59 import kotlinx.coroutines.flow.first
60 import kotlinx.coroutines.launch
61 import kotlinx.coroutines.test.TestScope
62 import kotlinx.coroutines.test.runTest
63 import org.mockito.kotlin.doReturn
64 import org.mockito.kotlin.mock
65 import org.mockito.kotlin.spy
66 import org.robolectric.shadow.api.Shadow
67 import org.robolectric.util.ReflectionHelpers.ClassParameter
68 
69 internal suspend fun runTestingComposition(
70     content: @Composable @GlanceComposable () -> Unit,
71 ): RemoteViewsRoot =
72     runCompositionUntil(
73         stopWhen = { state: Recomposer.State, root: RemoteViewsRoot ->
74             state == Recomposer.State.Idle && !root.shouldIgnoreResult()
75         },
76         content
77     )
78 
79 internal suspend fun runCompositionUntil(
80     stopWhen: (Recomposer.State, RemoteViewsRoot) -> Boolean,
81     content: @Composable () -> Unit
82 ): RemoteViewsRoot = coroutineScope {
83     GlobalSnapshotManager.ensureStarted()
84     val root = RemoteViewsRoot(10)
85     val applier = Applier(root)
86     val recomposer = Recomposer(currentCoroutineContext())
87     val composition = Composition(applier, recomposer)
<lambda>null88     composition.setContent { content() }
89 
<lambda>null90     launch(TestFrameClock()) { recomposer.runRecomposeAndApplyChanges() }
91 
<lambda>null92     recomposer.currentState.first { stopWhen(it, root) }
93     recomposer.cancel()
94     recomposer.join()
95 
96     root
97 }
98 
99 /** Test clock that sends all frames immediately. */
100 class TestFrameClock : MonotonicFrameClock {
withFrameNanosnull101     override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R) =
102         onFrame(System.currentTimeMillis())
103 }
104 
105 /**
106  * Create the view out of a RemoteViews. You can provide a LayoutParams to set exact size of the
107  * parent AppWidgetHostView in order to test size-mapped RemoteViews.
108  */
109 internal fun Context.applyRemoteViews(
110     rv: RemoteViews,
111     params: LayoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT),
112 ): View {
113     val remoteViews =
114         with(Parcel.obtain()) {
115             try {
116                 rv.writeToParcel(this, 0)
117                 setDataPosition(0)
118                 RemoteViews(this)
119             } finally {
120                 recycle()
121             }
122         }
123 
124     val hostView =
125         TestAppWidgetHostView(createMockedContext()).apply {
126             layoutParams = params
127             if (params.height >= 0 || params.width >= 0) {
128                 layout(0, 0, params.width, params.height)
129             }
130             updateAppWidget(remoteViews)
131         }
132 
133     val view = hostView.getChildAt(0) as FrameLayout
134     assertThat(view.childCount).isEqualTo(1)
135     return view.getChildAt(0)
136 }
137 
138 // This is necessary to make AppWidgetHostView work without a real bound widget on API 24 and 25
Contextnull139 private fun Context.createMockedContext() =
140     spy(this) {
141         if (Build.VERSION.SDK_INT == 24 || Build.VERSION.SDK_INT == 25) {
142             on { getSystemService(LauncherApps::class.java) } doReturn mock()
143         }
144     }
145 
146 private class TestAppWidgetHostView(context: Context) : AppWidgetHostView(context) {
updateAppWidgetnull147     override fun updateAppWidget(remoteViews: RemoteViews?) {
148         // Set fake provider info for API versions where null AppWidgetHostView.mInfo causes NPE
149         val fakeProviderInfo = appWidgetProviderInfo {
150             provider = ComponentName("", "")
151             val prop =
152                 this::class.memberProperties.single { it.name == "providerInfo" }
153                     as KMutableProperty<*>
154             prop.setter.call(this, ActivityInfo().apply { applicationInfo = ApplicationInfo() })
155         }
156         val mInfo =
157             AppWidgetHostView::class.memberProperties.single { it.name == "mInfo" }
158                 as KMutableProperty<*>
159         mInfo.isAccessible = true
160         mInfo.setter.call(this, fakeProviderInfo)
161 
162         // Call the real implementation of AppWidgetHostView.updateAppWidget instead of
163         // ShadowAppWidgetHostView.updateAppWidget. The shadow version always uses reapply.
164         Shadow.directlyOn<Void, AppWidgetHostView>(
165             this,
166             AppWidgetHostView::class.java,
167             "updateAppWidget",
168             ClassParameter(RemoteViews::class.java, remoteViews)
169         )
170     }
171 }
172 
173 internal suspend fun Context.runAndTranslate(
174     appWidgetId: Int = 0,
175     content: @Composable () -> Unit
176 ): RemoteViews {
177     val originalRoot = runTestingComposition(content)
178 
179     // Copy makes a deep copy of the emittable tree, so will exercise the copy methods
180     // of all of the emmitables the test checks too.
181     val root = originalRoot.copy() as RemoteViewsRoot
182     normalizeCompositionTree(root)
183     return translateComposition(
184         this,
185         appWidgetId,
186         root,
187         LayoutConfiguration.create(this, appWidgetId),
188         rootViewIndex = 0,
189         layoutSize = DpSize.Zero,
190     )
191 }
192 
193 internal suspend fun Context.runAndTranslateInRtl(
194     appWidgetId: Int = 0,
195     content: @Composable () -> Unit
196 ): RemoteViews {
197     val rtlLocale =
<lambda>null198         Locale.getAvailableLocales().first {
199             TextUtils.getLayoutDirectionFromLocale(it) == View.LAYOUT_DIRECTION_RTL
200         }
201     val rtlContext =
202         createConfigurationContext(
<lambda>null203             Configuration(resources.configuration).also { it.setLayoutDirection(rtlLocale) }
204         )
205     return rtlContext.runAndTranslate(appWidgetId, content = content)
206 }
207 
appWidgetProviderInfonull208 internal fun appWidgetProviderInfo(
209     builder: AppWidgetProviderInfo.() -> Unit
210 ): AppWidgetProviderInfo = AppWidgetProviderInfo().apply(builder)
211 
212 internal fun TextUnit.toPixels(displayMetrics: DisplayMetrics) =
213     TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, displayMetrics).toInt()
214 
215 inline fun <reified T : View> View.findView(noinline pred: (T) -> Boolean) =
216     findView(pred, T::class.java)
217 
218 inline fun <reified T : View> View.findViewByType() = findView({ true }, T::class.java)
219 
findViewnull220 fun <T : View> View.findView(predicate: (T) -> Boolean, klass: Class<T>): T? {
221     try {
222         val castView = klass.cast(this)!!
223         if (predicate(castView)) {
224             return castView
225         }
226     } catch (e: ClassCastException) {
227         // Nothing to do
228     }
229     if (this !is ViewGroup) {
230         return null
231     }
232     return children.mapNotNull { it.findView(predicate, klass) }.firstOrNull()
233 }
234 
235 internal open class TestWidget(
236     override val sizeMode: SizeMode = SizeMode.Single,
237     val ui: @Composable () -> Unit,
238 ) : GlanceAppWidget(errorUiLayout = 0) {
239     override var errorUiLayout: Int = 0
240 
241     val provideGlanceCalled = AtomicBoolean(false)
242 
provideGlancenull243     override suspend fun provideGlance(context: Context, id: GlanceId) {
244         provideGlanceCalled.set(true)
245         provideContent(ui)
246     }
247 
withErrorLayoutnull248     inline fun withErrorLayout(layout: Int, block: () -> Unit) {
249         val previousErrorLayout = errorUiLayout
250         errorUiLayout = layout
251         try {
252             block()
253         } finally {
254             errorUiLayout = previousErrorLayout
255         }
256     }
257 
258     companion object {
forPreviewnull259         fun forPreview(
260             sizeMode: PreviewSizeMode = SizeMode.Single,
261             ui: @Composable (Int) -> Unit
262         ): TestWidget {
263             return object : TestWidget(SizeMode.Single, {}) {
264                 override val previewSizeMode = sizeMode
265 
266                 override suspend fun providePreview(context: Context, widgetCategory: Int) {
267                     provideContent { ui(widgetCategory) }
268                 }
269             }
270         }
271     }
272 }
273 
274 /** Count the number of children that are not gone. */
275 internal val ViewGroup.nonGoneChildCount: Int
<lambda>null276     get() = children.count { it.visibility != View.GONE }
277 
278 /** Iterate over children that are not gone. */
279 internal val ViewGroup.nonGoneChildren: Sequence<View>
<lambda>null280     get() = children.filter { it.visibility != View.GONE }
281 
configurationContextnull282 fun configurationContext(modifier: Configuration.() -> Unit): Context {
283     val configuration = Configuration()
284     modifier(configuration)
285     return ApplicationProvider.getApplicationContext<Context>()
286         .createConfigurationContext(configuration)
287 }
288 
289 /** JVM tests in a [TestScope] that can't finish under 10s. */
runMediumTestnull290 internal fun TestScope.runMediumTest(testBody: suspend TestScope.() -> Unit) =
291     this.runTest(timeout = 30.seconds, testBody = testBody)
292 
293 /** JVM tests that can't finish under 10s. */
294 internal fun runMediumTest(testBody: suspend TestScope.() -> Unit) =
295     runTest(timeout = 30.seconds, testBody = testBody)
296