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