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.app.Activity
20 import android.content.Context
21 import android.graphics.Typeface
22 import android.graphics.drawable.BitmapDrawable
23 import android.graphics.drawable.GradientDrawable
24 import android.os.Build
25 import android.os.FileObserver
26 import android.text.SpannedString
27 import android.text.style.StyleSpan
28 import android.text.style.TextAppearanceSpan
29 import android.text.style.UnderlineSpan
30 import android.util.Log
31 import android.view.View
32 import android.view.ViewGroup
33 import android.widget.Button
34 import android.widget.CompoundButton
35 import android.widget.FrameLayout
36 import android.widget.ImageView
37 import android.widget.ImageView.ScaleType
38 import android.widget.LinearLayout
39 import android.widget.RadioButton
40 import android.widget.TextView
41 import androidx.compose.runtime.Composable
42 import androidx.compose.runtime.DisposableEffect
43 import androidx.compose.runtime.SideEffect
44 import androidx.compose.runtime.getValue
45 import androidx.compose.runtime.mutableStateOf
46 import androidx.compose.runtime.remember
47 import androidx.compose.runtime.setValue
48 import androidx.compose.ui.graphics.Color
49 import androidx.compose.ui.unit.Dp
50 import androidx.compose.ui.unit.DpSize
51 import androidx.compose.ui.unit.dp
52 import androidx.datastore.dataStoreFile
53 import androidx.datastore.preferences.core.Preferences
54 import androidx.datastore.preferences.core.booleanPreferencesKey
55 import androidx.datastore.preferences.core.intPreferencesKey
56 import androidx.glance.Button
57 import androidx.glance.ButtonDefaults
58 import androidx.glance.GlanceId
59 import androidx.glance.GlanceModifier
60 import androidx.glance.Image
61 import androidx.glance.ImageProvider
62 import androidx.glance.LocalContext
63 import androidx.glance.LocalSize
64 import androidx.glance.action.ActionParameters
65 import androidx.glance.action.actionParametersOf
66 import androidx.glance.action.actionStartActivity
67 import androidx.glance.action.clickable
68 import androidx.glance.action.toParametersKey
69 import androidx.glance.appwidget.R.layout.glance_error_layout
70 import androidx.glance.appwidget.action.ActionCallback
71 import androidx.glance.appwidget.action.ToggleableStateKey
72 import androidx.glance.appwidget.action.actionRunCallback
73 import androidx.glance.appwidget.state.getAppWidgetState
74 import androidx.glance.appwidget.state.updateAppWidgetState
75 import androidx.glance.appwidget.test.R
76 import androidx.glance.background
77 import androidx.glance.color.ColorProvider
78 import androidx.glance.currentState
79 import androidx.glance.layout.Alignment
80 import androidx.glance.layout.Box
81 import androidx.glance.layout.Column
82 import androidx.glance.layout.ContentScale
83 import androidx.glance.layout.Row
84 import androidx.glance.layout.fillMaxHeight
85 import androidx.glance.layout.fillMaxSize
86 import androidx.glance.layout.fillMaxWidth
87 import androidx.glance.layout.height
88 import androidx.glance.layout.padding
89 import androidx.glance.layout.width
90 import androidx.glance.layout.wrapContentHeight
91 import androidx.glance.state.PreferencesGlanceStateDefinition
92 import androidx.glance.text.FontStyle
93 import androidx.glance.text.FontWeight
94 import androidx.glance.text.Text
95 import androidx.glance.text.TextDecoration
96 import androidx.glance.text.TextStyle
97 import androidx.glance.unit.ColorProvider
98 import androidx.test.filters.FlakyTest
99 import androidx.test.filters.MediumTest
100 import androidx.test.filters.SdkSuppress
101 import androidx.test.platform.app.InstrumentationRegistry
102 import com.google.common.truth.Truth.assertThat
103 import com.google.common.truth.Truth.assertWithMessage
104 import java.util.concurrent.CountDownLatch
105 import java.util.concurrent.TimeUnit
106 import java.util.concurrent.atomic.AtomicBoolean
107 import java.util.concurrent.atomic.AtomicReference
108 import kotlin.test.assertIs
109 import kotlin.test.assertNotNull
110 import kotlin.time.Duration.Companion.seconds
111 import kotlinx.coroutines.Job
112 import kotlinx.coroutines.coroutineScope
113 import kotlinx.coroutines.delay
114 import kotlinx.coroutines.flow.MutableStateFlow
115 import kotlinx.coroutines.flow.collectIndexed
116 import kotlinx.coroutines.flow.filterNotNull
117 import kotlinx.coroutines.flow.first
118 import kotlinx.coroutines.flow.take
119 import kotlinx.coroutines.flow.update
120 import kotlinx.coroutines.launch
121 import kotlinx.coroutines.runBlocking
122 import org.junit.After
123 import org.junit.Before
124 import org.junit.Rule
125 import org.junit.Test
126 import org.junit.rules.TestWatcher
127 import org.junit.runner.Description
128 
129 /** Enable verbose logging for test failure investigation. Enable for b/267494219 */
130 const val VERBOSE_LOG = true
131 
132 const val RECEIVER_TEST_TAG = "GAWRT" // shorten to avoid long tag lint
133 
134 @SdkSuppress(minSdkVersion = 29)
135 @MediumTest
136 class GlanceAppWidgetReceiverTest {
137     @get:Rule val mHostRule = AppWidgetHostRule()
138 
139     @get:Rule val mViewDumpRule = ViewHierarchyFailureWatcher()
140 
141     val context = InstrumentationRegistry.getInstrumentation().targetContext!!
142 
143     @Before
144     fun setUp() {
145         // Reset the size mode to the default
146         TestGlanceAppWidget.sizeMode = SizeMode.Single
147     }
148 
149     @After
150     fun cleanUp() {
151         TestGlanceAppWidget.resetOnDeleteBlock()
152         CompoundButtonActionTest.reset()
153     }
154 
155     @Test
156     fun createSimpleAppWidget() {
157         TestGlanceAppWidget.uiDefinition = {
158             val density = LocalContext.current.resources.displayMetrics.density
159             val size = LocalSize.current
160             assertThat(size.width.value).isWithin(1 / density).of(40f)
161             assertThat(size.height.value).isWithin(1 / density).of(40f)
162             Text(
163                 "text content",
164                 style =
165                     TextStyle(
166                         textDecoration = TextDecoration.Underline,
167                         fontWeight = FontWeight.Medium,
168                         fontStyle = FontStyle.Italic,
169                     )
170             )
171         }
172 
173         mHostRule.startHost()
174 
175         mHostRule.onUnboxedHostView<TextView> { textView ->
176             assertThat(textView.text.toString()).isEqualTo("text content")
177             val content = textView.text as SpannedString
178             content.checkHasSingleTypedSpan<UnderlineSpan> {}
179             content.checkHasSingleTypedSpan<StyleSpan> {
180                 assertThat(it.style).isEqualTo(Typeface.ITALIC)
181             }
182             content.checkHasSingleTypedSpan<TextAppearanceSpan> {
183                 assertThat(it.textFontWeight).isEqualTo(500)
184             }
185         }
186     }
187 
188     @Test
189     fun createExactAppWidget() {
190         TestGlanceAppWidget.sizeMode = SizeMode.Exact
191         TestGlanceAppWidget.uiDefinition = {
192             val size = LocalSize.current
193             Text("size = ${size.width} x ${size.height}")
194         }
195 
196         mHostRule.startHost()
197 
198         mHostRule.onUnboxedHostView<TextView> { textView ->
199             assertThat(textView.text.toString()).isEqualTo("size = 200.0.dp x 300.0.dp")
200         }
201 
202         mHostRule.setLandscapeOrientation()
203         mHostRule.onUnboxedHostView<TextView> { textView ->
204             assertThat(textView.text.toString()).isEqualTo("size = 300.0.dp x 200.0.dp")
205         }
206     }
207 
208     @FlakyTest(bugId = 249803914)
209     @Test
210     fun createResponsiveAppWidget() {
211         TestGlanceAppWidget.sizeMode =
212             SizeMode.Responsive(setOf(DpSize(100.dp, 150.dp), DpSize(250.dp, 150.dp)))
213 
214         TestGlanceAppWidget.uiDefinition = {
215             val size = LocalSize.current
216             Text("size = ${size.width} x ${size.height}")
217         }
218 
219         mHostRule.startHost()
220 
221         mHostRule.onUnboxedHostView<TextView> { textView ->
222             assertThat(textView.text.toString()).isEqualTo("size = 100.0.dp x 150.0.dp")
223         }
224 
225         mHostRule.setLandscapeOrientation()
226         mHostRule.onUnboxedHostView<TextView> { textView ->
227             assertThat(textView.text.toString()).isEqualTo("size = 250.0.dp x 150.0.dp")
228         }
229 
230         mHostRule.setSizes(
231             DpSize(50.dp, 100.dp),
232             DpSize(100.dp, 50.dp),
233             updateRemoteViews = Build.VERSION.SDK_INT < Build.VERSION_CODES.S,
234         )
235 
236         mHostRule.setPortraitOrientation()
237         mHostRule.onUnboxedHostView<TextView> { textView ->
238             assertThat(textView.text.toString()).isEqualTo("size = 100.0.dp x 150.0.dp")
239         }
240 
241         mHostRule.setLandscapeOrientation()
242         mHostRule.onUnboxedHostView<TextView> { textView ->
243             assertThat(textView.text.toString()).isEqualTo("size = 100.0.dp x 150.0.dp")
244         }
245     }
246 
247     @Test
248     fun createTextWithFillMaxDimensions() {
249         TestGlanceAppWidget.uiDefinition = {
250             Text("expanded text", modifier = GlanceModifier.fillMaxWidth().fillMaxHeight())
251         }
252 
253         mHostRule.startHost()
254 
255         mHostRule.onUnboxedHostView<TextView> { textView ->
256             assertViewSize(textView, mHostRule.portraitSize)
257         }
258     }
259 
260     @Test
261     fun createTextViewWithExactDimensions() {
262         TestGlanceAppWidget.uiDefinition = {
263             Text("expanded text", modifier = GlanceModifier.width(150.dp).height(100.dp))
264         }
265 
266         mHostRule.startHost()
267 
268         mHostRule.onUnboxedHostView<TextView> { textView ->
269             assertViewSize(textView, DpSize(150.dp, 100.dp))
270         }
271     }
272 
273     @Test
274     fun createTextViewWithMixedDimensions() {
275         TestGlanceAppWidget.uiDefinition = {
276             Text("expanded text", modifier = GlanceModifier.fillMaxWidth().height(110.dp))
277         }
278 
279         mHostRule.startHost()
280 
281         mHostRule.onUnboxedHostView<TextView> { textView ->
282             assertViewSize(textView, DpSize(mHostRule.portraitSize.width, 110.dp))
283         }
284     }
285 
286     @Test
287     fun createBoxWithExactDimensions() {
288         TestGlanceAppWidget.uiDefinition = {
289             Box(modifier = GlanceModifier.width(150.dp).height(180.dp)) { Text("Inside") }
290         }
291 
292         mHostRule.startHost()
293 
294         mHostRule.onUnboxedHostView<FrameLayout> { box ->
295             assertThat(box.notGoneChildCount).isEqualTo(1)
296             assertViewSize(box, DpSize(150.dp, 180.dp))
297         }
298     }
299 
300     @Test
301     fun createBoxWithMixedDimensions() {
302         TestGlanceAppWidget.uiDefinition = {
303             Box(modifier = GlanceModifier.width(150.dp).wrapContentHeight()) { Text("Inside") }
304         }
305 
306         mHostRule.startHost()
307 
308         mHostRule.onUnboxedHostView<FrameLayout> { box ->
309             val text = assertNotNull(box.findChild<TextView> { it.text.toString() == "Inside" })
310             assertThat(box.height).isEqualTo(text.height)
311             assertViewDimension(box, box.width, 150.dp)
312         }
313     }
314 
315     @Test
316     fun createColumnWithMixedDimensions() {
317         TestGlanceAppWidget.uiDefinition = {
318             Column(modifier = GlanceModifier.width(150.dp).fillMaxHeight()) {
319                 Text("Inside 1")
320                 Text("Inside 2")
321                 Text("Inside 3")
322             }
323         }
324 
325         mHostRule.startHost()
326 
327         mHostRule.onHostView { hostView ->
328             assertThat(hostView.childCount).isEqualTo(1)
329             val child =
330                 assertNotNull(
331                     hostView.findChild<LinearLayout> { it.orientation == LinearLayout.VERTICAL }
332                 )
333             assertViewSize(child, DpSize(150.dp, mHostRule.portraitSize.height))
334         }
335     }
336 
337     @Test
338     fun createRowWithMixedDimensions() {
339         TestGlanceAppWidget.uiDefinition = {
340             Row(modifier = GlanceModifier.fillMaxWidth().height(200.dp)) {
341                 Text("Inside 1")
342                 Text("Inside 2")
343                 Text("Inside 3")
344             }
345         }
346 
347         mHostRule.startHost()
348 
349         mHostRule.onHostView { hostView ->
350             assertThat(hostView.childCount).isEqualTo(1)
351             val child =
352                 assertNotNull(
353                     hostView.findChild<LinearLayout> { it.orientation == LinearLayout.HORIZONTAL }
354                 )
355             assertViewSize(child, DpSize(mHostRule.portraitSize.width, 200.dp))
356         }
357     }
358 
359     @Test
360     fun createRowWithTwoTexts() {
361         TestGlanceAppWidget.uiDefinition = {
362             Row(modifier = GlanceModifier.fillMaxWidth().fillMaxHeight()) {
363                 Text("Inside 1", modifier = GlanceModifier.defaultWeight().height(100.dp))
364                 Text("Inside 2", modifier = GlanceModifier.defaultWeight().fillMaxHeight())
365             }
366         }
367 
368         mHostRule.startHost()
369 
370         mHostRule.onUnboxedHostView<LinearLayout> { row ->
371             assertThat(row.orientation).isEqualTo(LinearLayout.HORIZONTAL)
372             assertThat(row.notGoneChildCount).isEqualTo(2)
373             val children = row.notGoneChildren.toList()
374             val child1 = children[0].getTargetView<TextView>()
375             val child2 = assertIs<TextView>(children[1])
376 
377             assertViewSize(child1, DpSize(mHostRule.portraitSize.width / 2, 100.dp))
378             assertViewSize(
379                 child2,
380                 DpSize(mHostRule.portraitSize.width / 2, mHostRule.portraitSize.height),
381             )
382         }
383     }
384 
385     @Test
386     fun createColumnWithTwoTexts() {
387         TestGlanceAppWidget.uiDefinition = {
388             Column(modifier = GlanceModifier.fillMaxWidth().fillMaxHeight()) {
389                 Text("Inside 1", modifier = GlanceModifier.fillMaxWidth().defaultWeight())
390                 Text("Inside 2", modifier = GlanceModifier.width(100.dp).defaultWeight())
391             }
392         }
393 
394         mHostRule.startHost()
395 
396         mHostRule.onUnboxedHostView<LinearLayout> { column ->
397             assertThat(column.orientation).isEqualTo(LinearLayout.VERTICAL)
398             assertThat(column.notGoneChildCount).isEqualTo(2)
399             val children = column.notGoneChildren.toList()
400             val child1 = assertIs<TextView>(children[0])
401             val child2 = children[1].getTargetView<TextView>()
402             assertViewSize(
403                 child1,
404                 DpSize(mHostRule.portraitSize.width, mHostRule.portraitSize.height / 2),
405             )
406             assertViewSize(child2, DpSize(100.dp, mHostRule.portraitSize.height / 2))
407         }
408     }
409 
410     @Test
411     fun columnWithWeightedItemRespectsSizeOfOtherItem() {
412         // When one item has a default weight, it should scale to respect the size of the other item
413         // in this case the other item fills the column, so the weighted item should take up no
414         // space, that is, has height 0.
415         TestGlanceAppWidget.uiDefinition = {
416             Column(modifier = GlanceModifier.fillMaxWidth().fillMaxHeight()) {
417                 Text("Inside 1", modifier = GlanceModifier.fillMaxWidth().defaultWeight())
418                 Text("Inside 2", modifier = GlanceModifier.width(100.dp).fillMaxHeight())
419             }
420         }
421 
422         mHostRule.startHost()
423 
424         mHostRule.onUnboxedHostView<LinearLayout> { column ->
425             assertThat(column.orientation).isEqualTo(LinearLayout.VERTICAL)
426             assertThat(column.notGoneChildCount).isEqualTo(2)
427             val children = column.notGoneChildren.toList()
428             val child1 = assertIs<TextView>(children[0])
429             val child2 = children[1].getTargetView<TextView>()
430             assertViewSize(
431                 child1,
432                 DpSize(mHostRule.portraitSize.width, 0.dp),
433             )
434             assertViewSize(child2, DpSize(100.dp, mHostRule.portraitSize.height))
435         }
436     }
437 
438     @Test
439     @SdkSuppress(minSdkVersion = 31)
440     fun createButton() {
441         TestGlanceAppWidget.uiDefinition = {
442             Button(
443                 text = "Button",
444                 onClick = actionStartActivity<Activity>(),
445                 colors =
446                     ButtonDefaults.buttonColors(
447                         backgroundColor = ColorProvider(Color.Transparent),
448                         contentColor = ColorProvider(Color.DarkGray)
449                     ),
450                 enabled = false
451             )
452         }
453 
454         mHostRule.startHost()
455 
456         mHostRule.onUnboxedHostView<Button> { button ->
457             checkNotNull(button.text.toString() == "Button") { "Couldn't find 'Button'" }
458 
459             assertThat(button.isEnabled).isFalse()
460             assertThat(button.hasOnClickListeners()).isFalse()
461         }
462     }
463 
464     @Test
465     @SdkSuppress(minSdkVersion = 29, maxSdkVersion = 30)
466     fun createButtonBackport() {
467         TestGlanceAppWidget.uiDefinition = {
468             Button(
469                 text = "Button",
470                 onClick = actionStartActivity<Activity>(),
471                 colors =
472                     ButtonDefaults.buttonColors(
473                         backgroundColor = ColorProvider(Color.Transparent),
474                         contentColor = ColorProvider(Color.DarkGray)
475                     ),
476                 enabled = false
477             )
478         }
479 
480         mHostRule.startHost()
481 
482         mHostRule.onUnboxedHostView<FrameLayout> { button ->
483             checkNotNull(button.findChild<TextView> { it.text.toString() == "Button" }) {
484                 "Couldn't find TextView 'Button'"
485             }
486 
487             assertThat(button.isEnabled).isFalse()
488             assertThat(button.hasOnClickListeners()).isFalse()
489         }
490     }
491 
492     @Test
493     fun createImage() {
494         TestGlanceAppWidget.uiDefinition = {
495             Image(provider = ImageProvider(R.drawable.oval), contentDescription = "oval")
496         }
497 
498         mHostRule.startHost()
499 
500         mHostRule.onUnboxedHostView<ImageView> { image ->
501             assertThat(image.contentDescription).isEqualTo("oval")
502             val gradientDrawable = assertIs<GradientDrawable>(image.drawable)
503             assertThat(gradientDrawable.shape).isEqualTo(GradientDrawable.OVAL)
504         }
505     }
506 
507     @Test
508     fun drawableBackground() {
509         TestGlanceAppWidget.uiDefinition = {
510             Text(
511                 "Some useful text",
512                 modifier =
513                     GlanceModifier.fillMaxWidth()
514                         .height(220.dp)
515                         .background(ImageProvider(R.drawable.oval))
516             )
517         }
518 
519         mHostRule.startHost()
520 
521         mHostRule.onUnboxedHostView<FrameLayout> { box ->
522             assertThat(box.notGoneChildCount).isEqualTo(2)
523             val (boxedImage, boxedText) = box.notGoneChildren.toList()
524             val image = boxedImage.getTargetView<ImageView>()
525             val text = boxedText.getTargetView<TextView>()
526             assertThat(image.drawable).isNotNull()
527             assertThat(image.scaleType).isEqualTo(ScaleType.FIT_XY)
528             assertThat(text.background).isNull()
529         }
530     }
531 
532     @Test
533     fun drawableFitBackground() {
534         TestGlanceAppWidget.uiDefinition = {
535             Text(
536                 "Some useful text",
537                 modifier =
538                     GlanceModifier.fillMaxWidth()
539                         .height(220.dp)
540                         .background(ImageProvider(R.drawable.oval), contentScale = ContentScale.Fit)
541             )
542         }
543 
544         mHostRule.startHost()
545 
546         mHostRule.onUnboxedHostView<FrameLayout> { box ->
547             assertThat(box.notGoneChildCount).isEqualTo(2)
548             val (boxedImage, boxedText) = box.notGoneChildren.toList()
549             val image = boxedImage.getTargetView<ImageView>()
550             val text = boxedText.getTargetView<TextView>()
551             assertThat(image.drawable).isNotNull()
552             assertThat(image.scaleType).isEqualTo(ScaleType.FIT_CENTER)
553             assertThat(text.background).isNull()
554         }
555     }
556 
557     @Test
558     fun drawableCropBackground() {
559         TestGlanceAppWidget.uiDefinition = {
560             Text(
561                 "Some useful text",
562                 modifier =
563                     GlanceModifier.fillMaxWidth()
564                         .height(220.dp)
565                         .background(
566                             ImageProvider(R.drawable.oval),
567                             contentScale = ContentScale.Crop
568                         )
569             )
570         }
571 
572         mHostRule.startHost()
573 
574         mHostRule.onUnboxedHostView<FrameLayout> { box ->
575             assertThat(box.notGoneChildCount).isEqualTo(2)
576             val (boxedImage, boxedText) = box.notGoneChildren.toList()
577             val image = boxedImage.getTargetView<ImageView>()
578             val text = boxedText.getTargetView<TextView>()
579             assertThat(image.drawable).isNotNull()
580             assertThat(image.scaleType).isEqualTo(ScaleType.CENTER_CROP)
581             assertThat(text.background).isNull()
582         }
583     }
584 
585     @Test
586     fun bitmapBackground() {
587         TestGlanceAppWidget.uiDefinition = {
588             val context = LocalContext.current
589             val bitmap =
590                 (context.resources.getDrawable(R.drawable.compose, null) as BitmapDrawable).bitmap
591             Text(
592                 "Some useful text",
593                 modifier = GlanceModifier.fillMaxSize().background(ImageProvider(bitmap))
594             )
595         }
596 
597         mHostRule.startHost()
598 
599         mHostRule.onUnboxedHostView<FrameLayout> { box ->
600             assertThat(box.notGoneChildCount).isEqualTo(2)
601             val (boxedImage, boxedText) = box.notGoneChildren.toList()
602             val image = boxedImage.getTargetView<ImageView>()
603             val text = boxedText.getTargetView<TextView>()
604             assertIs<BitmapDrawable>(image.drawable)
605             assertThat(text.background).isNull()
606         }
607     }
608 
609     @Test
610     fun removeAppWidget() {
611         TestGlanceAppWidget.uiDefinition = { Text("something") }
612 
613         mHostRule.startHost()
614 
615         val appWidgetManager = GlanceAppWidgetManager(context)
616         val glanceId = runBlocking {
617             appWidgetManager.getGlanceIds(TestGlanceAppWidget::class.java).single()
618         }
619 
620         runBlocking { updateAppWidgetState(context, glanceId) { it[testKey] = 3 } }
621 
622         val fileKey = createUniqueRemoteUiName((glanceId as AppWidgetId).appWidgetId)
623         val preferencesFile = PreferencesGlanceStateDefinition.getLocation(context, fileKey)
624 
625         assertThat(preferencesFile.exists()).isTrue()
626         val fileIsDeleted = CountDownLatch(1)
627         val fileDeletionObserver =
628             object : FileObserver(preferencesFile, DELETE_SELF) {
629                 override fun onEvent(event: Int, path: String?) {
630                     if (event == DELETE_SELF) {
631                         fileIsDeleted.countDown()
632                     }
633                 }
634             }
635         fileDeletionObserver.startWatching()
636         mHostRule.removeAppWidget()
637         try {
638             assertWithMessage("View state file is deleted")
639                 .that(fileIsDeleted.await(5, TimeUnit.SECONDS))
640                 .isTrue()
641         } finally {
642             fileDeletionObserver.stopWatching()
643         }
644     }
645 
646     @Test
647     fun layoutConfigurationCanBeDeleted() {
648         TestGlanceAppWidget.uiDefinition = { Text("something") }
649 
650         mHostRule.startHost()
651 
652         val appWidgetManager = GlanceAppWidgetManager(context)
653         val glanceId = runBlocking {
654             appWidgetManager.getGlanceIds(TestGlanceAppWidget::class.java).first()
655         }
656 
657         val appWidgetId = (glanceId as AppWidgetId).appWidgetId
658         val file = context.dataStoreFile(layoutDatastoreKey(appWidgetId))
659         assertThat(file.exists())
660 
661         val isDeleted = LayoutConfiguration.delete(context, glanceId)
662         assertThat(isDeleted).isTrue()
663     }
664 
665     @Test
666     fun updateAll() =
667         runBlocking<Unit> {
668             TestGlanceAppWidget.uiDefinition = { Text("text") }
669 
670             mHostRule.startHost()
671 
672             mHostRule.runAndWaitForUpdate { TestGlanceAppWidget.updateAll(context) }
673         }
674 
675     @Test
676     fun updateIf() =
677         runBlocking<Unit> {
678             val didRun = AtomicBoolean(false)
679             TestGlanceAppWidget.uiDefinition = {
680                 currentState<Preferences>()
681                 didRun.set(true)
682                 Text("text")
683             }
684 
685             mHostRule.startHost()
686             assertThat(didRun.get()).isTrue()
687 
688             GlanceAppWidgetManager(context).getGlanceIds(TestGlanceAppWidget::class.java).forEach {
689                 glanceId ->
690                 updateAppWidgetState(context, glanceId) { it[testKey] = 2 }
691             }
692 
693             // Make sure the app widget is updated if the test is true
694             didRun.set(false)
695             mHostRule.runAndWaitForUpdate {
696                 TestGlanceAppWidget.updateIf<Preferences>(context) { prefs -> prefs[testKey] == 2 }
697             }
698             assertThat(didRun.get()).isTrue()
699 
700             // Make sure it is not if the test is false
701             didRun.set(false)
702 
703             // Waiting for the update should timeout since it is never triggered.
704             val updateResult = runCatching {
705                 // AppWidgetService may send an APPWIDGET_UPDATE broadcast, which is not relevant to
706                 // this and should be ignored.
707                 mHostRule.ignoreBroadcasts {
708                     runBlocking {
709                         mHostRule.runAndWaitForUpdate {
710                             TestGlanceAppWidget.updateIf<Preferences>(context) { prefs ->
711                                 prefs[testKey] == 3
712                             }
713                         }
714                     }
715                 }
716             }
717             assertThat(updateResult.exceptionOrNull()).apply {
718                 isInstanceOf(IllegalArgumentException::class.java)
719                 hasMessageThat().contains("Timeout before getting RemoteViews")
720             }
721 
722             assertThat(didRun.get()).isFalse()
723         }
724 
725     @Test
726     fun viewState() {
727         TestGlanceAppWidget.uiDefinition = {
728             val value = currentState(testKey) ?: -1
729             Text("Value = $value")
730         }
731 
732         mHostRule.startHost()
733 
734         val appWidgetId = AtomicReference<GlanceId>()
735         mHostRule.onHostView { view -> appWidgetId.set(AppWidgetId(view.appWidgetId)) }
736 
737         runBlocking {
738             updateAppWidgetState(context, appWidgetId.get()) { it[testKey] = 2 }
739 
740             val prefs =
741                 TestGlanceAppWidget.getAppWidgetState<Preferences>(context, appWidgetId.get())
742             assertThat(prefs[testKey]).isEqualTo(2)
743         }
744     }
745 
746     @Test
747     fun actionCallback() {
748         TestGlanceAppWidget.uiDefinition = {
749             Column {
750                 Text(
751                     "text1",
752                     modifier =
753                         GlanceModifier.clickable(
754                             actionRunCallback<CallbackTest>(
755                                 actionParametersOf(CallbackTest.key to 1)
756                             )
757                         )
758                 )
759                 Text(
760                     "text2",
761                     modifier =
762                         GlanceModifier.clickable(
763                             actionRunCallback<CallbackTest>(
764                                 actionParametersOf(CallbackTest.key to 2)
765                             )
766                         )
767                 )
768             }
769         }
770 
771         mHostRule.startHost()
772 
773         CallbackTest.received.set(emptyList())
774         CallbackTest.latch = CountDownLatch(2)
775         mHostRule.onUnboxedHostView<ViewGroup> { root ->
776             checkNotNull(
777                     root.findChild<TextView> { it.text.toString() == "text1" }?.parent as? View
778                 )
779                 .performClick()
780             checkNotNull(
781                     root.findChild<TextView> { it.text.toString() == "text2" }?.parent as? View
782                 )
783                 .performClick()
784         }
785         assertThat(CallbackTest.latch.await(5, TimeUnit.SECONDS)).isTrue()
786         assertThat(CallbackTest.received.get()).containsExactly(1, 2)
787     }
788 
789     @Test
790     fun multipleActionCallback() {
791         TestGlanceAppWidget.uiDefinition = {
792             Text(
793                 "text1",
794                 modifier =
795                     GlanceModifier.clickable(
796                             actionRunCallback<CallbackTest>(
797                                 actionParametersOf(CallbackTest.key to 1)
798                             )
799                         )
800                         .clickable(
801                             actionRunCallback<CallbackTest>(
802                                 actionParametersOf(CallbackTest.key to 2)
803                             )
804                         )
805             )
806         }
807 
808         mHostRule.startHost()
809 
810         CallbackTest.received.set(emptyList())
811         CallbackTest.latch = CountDownLatch(1)
812         mHostRule.onUnboxedHostView<ViewGroup> { root ->
813             checkNotNull(
814                     root.findChild<TextView> { it.text.toString() == "text1" }?.parent as? View
815                 )
816                 .performClick()
817         }
818         assertThat(CallbackTest.latch.await(5, TimeUnit.SECONDS)).isTrue()
819         assertThat(CallbackTest.received.get()).containsExactly(2)
820     }
821 
822     @Test
823     fun wrapAroundFillMaxSize() {
824         TestGlanceAppWidget.uiDefinition = {
825             val wrapperModifier =
826                 GlanceModifier.background(ColorProvider(Color.LightGray))
827                     .fillMaxSize()
828                     .padding(8.dp)
829             Column(modifier = wrapperModifier) {
830                 val boxModifier = GlanceModifier.defaultWeight().fillMaxWidth()
831                 BoxRowBox(modifier = boxModifier, text = "Text 1")
832                 BoxRowBox(modifier = boxModifier, text = "Text 2")
833             }
834         }
835 
836         mHostRule.startHost()
837 
838         mHostRule.onUnboxedHostView<LinearLayout> { column ->
839             val displayMetrics = column.context.resources.displayMetrics
840             val targetHeight = (column.height.pixelsToDp(displayMetrics) - 16.dp) / 2
841             val targetWidth = column.width.pixelsToDp(displayMetrics) - 16.dp
842 
843             val text1 = checkNotNull(column.findChild<TextView> { it.text.toString() == "Text 1" })
844             val row1 = text1.getParentView<FrameLayout>().getParentView<LinearLayout>()
845             assertThat(row1.orientation).isEqualTo(LinearLayout.HORIZONTAL)
846             assertViewSize(row1, DpSize(targetWidth, targetHeight))
847 
848             val text2 = checkNotNull(column.findChild<TextView> { it.text.toString() == "Text 2" })
849             val row2 = text2.getParentView<FrameLayout>().getParentView<LinearLayout>()
850             assertThat(row2.orientation).isEqualTo(LinearLayout.HORIZONTAL)
851             assertThat(row2.height).isGreaterThan(20.dp.toPixels(context))
852             assertViewSize(row2, DpSize(targetWidth, targetHeight))
853         }
854     }
855 
856     @Test
857     fun compoundButtonAction() =
858         runBlocking<Unit> {
859             val checkbox = "checkbox"
860             val switch = "switch"
861             val checkBoxClicked = MutableStateFlow(false)
862             val switchClicked = MutableStateFlow(false)
863 
864             TestGlanceAppWidget.uiDefinition = {
865                 Column {
866                     CheckBox(
867                         checked = false,
868                         onCheckedChange = { assert(checkBoxClicked.tryEmit(true)) },
869                         text = checkbox
870                     )
871                     Switch(
872                         checked = true,
873                         onCheckedChange = { assert(switchClicked.tryEmit(true)) },
874                         text = switch
875                     )
876                 }
877             }
878 
879             mHostRule.startHost()
880 
881             mHostRule.onUnboxedHostView<ViewGroup> { root ->
882                 checkNotNull(root.findChild<TextView> { it.text.toString() == checkbox })
883                     .performCompoundButtonClick()
884                 checkNotNull(root.findChild<TextView> { it.text.toString() == switch })
885                     .performCompoundButtonClick()
886             }
887             checkBoxClicked.first { it }
888             switchClicked.first { it }
889         }
890 
891     @Test
892     fun canCreateCheckableColorProvider() {
893         TestGlanceAppWidget.uiDefinition = {
894             Switch(
895                 checked = true,
896                 onCheckedChange = null,
897                 text = "Hello Checked Switch (day: Blue/Green, night: Red/Yellow)",
898                 style =
899                     TextStyle(
900                         color = ColorProvider(day = Color.Black, night = Color.White),
901                         fontWeight = FontWeight.Bold,
902                         fontStyle = FontStyle.Normal,
903                     ),
904                 colors =
905                     SwitchDefaults.colors(
906                         checkedThumbColor = ColorProvider(day = Color.Blue, night = Color.Red),
907                         checkedTrackColor = ColorProvider(day = Color.Green, night = Color.Yellow),
908                         uncheckedThumbColor = ColorProvider(Color.Magenta),
909                         uncheckedTrackColor = ColorProvider(Color.Magenta),
910                     )
911             )
912         }
913 
914         mHostRule.startHost()
915         runBlocking {
916             mHostRule.runAndWaitForUpdate {
917                 TestGlanceAppWidget.update(context, AppWidgetId(mHostRule.appWidgetId))
918             }
919         }
920 
921         // if no crash, we're good
922     }
923 
924     @Test
925     fun radioActionCallback() {
926         TestGlanceAppWidget.uiDefinition = {
927             RadioButton(
928                 checked = true,
929                 onClick =
930                     actionRunCallback<CallbackTest>(actionParametersOf(CallbackTest.key to 2)),
931                 text = "text1"
932             )
933         }
934 
935         mHostRule.startHost()
936 
937         CallbackTest.received.set(emptyList())
938         CallbackTest.latch = CountDownLatch(1)
939         mHostRule.onUnboxedHostView<View> { root ->
940             checkNotNull(root.findChild<TextView> { it.text.toString() == "text1" })
941                 .performCompoundButtonClick()
942         }
943         assertThat(CallbackTest.latch.await(5, TimeUnit.SECONDS)).isTrue()
944         assertThat(CallbackTest.received.get()).containsExactly(2)
945     }
946 
947     @Test
948     @SdkSuppress(minSdkVersion = 31)
949     fun lambdaActionCallback() =
950         runBlocking<Unit> {
951             TestGlanceAppWidget.uiDefinition = {
952                 val text = remember { mutableStateOf("initial") }
953                 Button(text = text.value, onClick = { text.value = "clicked" })
954             }
955 
956             mHostRule.startHost()
957             var button: View? = null
958             mHostRule.onUnboxedHostView<Button> { buttonView ->
959                 assertThat(buttonView.text.toString()).isEqualTo("initial")
960                 button = buttonView
961             }
962             mHostRule.runAndWaitForUpdate { button!!.performClick() }
963 
964             mHostRule.onUnboxedHostView<Button> { buttonView ->
965                 assertThat(buttonView.text.toString()).isEqualTo("clicked")
966             }
967         }
968 
969     @Test
970     @SdkSuppress(minSdkVersion = 29, maxSdkVersion = 30)
971     fun lambdaActionCallback_backportButton() =
972         runBlocking<Unit> {
973             TestGlanceAppWidget.uiDefinition = {
974                 val text = remember { mutableStateOf("initial") }
975                 Button(text = text.value, onClick = { text.value = "clicked" })
976             }
977 
978             mHostRule.startHost()
979             var button: View? = null
980             mHostRule.onUnboxedHostView<ViewGroup> { root ->
981                 val text =
982                     checkNotNull(root.findChild<TextView> { it.text.toString() == "initial" })
983                 button = text.parent as View
984             }
985             mHostRule.runAndWaitForUpdate { button!!.performClick() }
986 
987             mHostRule.onUnboxedHostView<ViewGroup> { root ->
988                 checkNotNull(root.findChild<TextView> { it.text.toString() == "clicked" })
989             }
990         }
991 
992     @Test
993     fun unsetActionCallback() =
994         runBlocking<Unit> {
995             var enabled by mutableStateOf(true)
996             TestGlanceAppWidget.uiDefinition = {
997                 Text(
998                     "text1",
999                     modifier = if (enabled) GlanceModifier.clickable {} else GlanceModifier
1000                 )
1001             }
1002 
1003             mHostRule.startHost()
1004             mHostRule.onUnboxedHostView<View> { root ->
1005                 val view =
1006                     checkNotNull(
1007                         root.findChild<TextView> { it.text.toString() == "text1" }?.parent as? View
1008                     )
1009                 assertThat(view.hasOnClickListeners()).isTrue()
1010             }
1011 
1012             mHostRule.runAndWaitForUpdate { enabled = false }
1013 
1014             mHostRule.onUnboxedHostView<TextView> { root ->
1015                 val view =
1016                     checkNotNull(
1017                         root.findChild<TextView> { it.text.toString() == "text1" }?.parent as? View
1018                     )
1019                 assertThat(view.hasOnClickListeners()).isFalse()
1020             }
1021         }
1022 
1023     @Test
1024     fun unsetCompoundButtonActionCallback() =
1025         runBlocking<Unit> {
1026             TestGlanceAppWidget.uiDefinition = {
1027                 val enabled = currentState<Preferences>()[testBoolKey] ?: true
1028                 CheckBox(
1029                     checked = false,
1030                     onCheckedChange =
1031                         if (enabled) {
1032                             actionRunCallback<CompoundButtonActionTest>(
1033                                 actionParametersOf(CompoundButtonActionTest.key to "checkbox")
1034                             )
1035                         } else null,
1036                     text = "checkbox"
1037                 )
1038             }
1039 
1040             mHostRule.startHost()
1041             CompoundButtonActionTest.reset()
1042             mHostRule.onUnboxedHostView<ViewGroup> { root ->
1043                 checkNotNull(root.findChild<TextView> { it.text.toString() == "checkbox" })
1044                     .performCompoundButtonClick()
1045             }
1046             assertThat(CompoundButtonActionTest.nextValue()).containsExactly("checkbox" to true)
1047 
1048             updateAppWidgetState(context, AppWidgetId(mHostRule.appWidgetId)) {
1049                 it[testBoolKey] = false
1050             }
1051             mHostRule.runAndWaitForUpdate {
1052                 TestGlanceAppWidget.update(context, AppWidgetId(mHostRule.appWidgetId))
1053             }
1054 
1055             CompoundButtonActionTest.reset()
1056             mHostRule.onUnboxedHostView<ViewGroup> { root ->
1057                 checkNotNull(root.findChild<TextView> { it.text.toString() == "checkbox" })
1058                     .performCompoundButtonClick()
1059             }
1060             delay(5.seconds)
1061             assertThat(CompoundButtonActionTest.currentValue).isNull()
1062         }
1063 
1064     @SdkSuppress(minSdkVersion = 31)
1065     @Test
1066     fun compoundButtonsOnlyHaveOneAction() {
1067         TestGlanceAppWidget.uiDefinition = {
1068             Column {
1069                 CheckBox(
1070                     checked = false,
1071                     onCheckedChange =
1072                         actionRunCallback<CompoundButtonActionTest>(actionParametersOf()),
1073                     text = "checkbox",
1074                     modifier =
1075                         GlanceModifier.clickable(
1076                             actionRunCallback<CompoundButtonActionTest>(actionParametersOf())
1077                         ),
1078                 )
1079             }
1080         }
1081 
1082         mHostRule.startHost()
1083 
1084         mHostRule.onUnboxedHostView<ViewGroup> { root ->
1085             val checkbox =
1086                 checkNotNull(root.findChild<CompoundButton> { it.text.toString() == "checkbox" })
1087             assertThat(checkbox.hasOnClickListeners()).isFalse()
1088         }
1089     }
1090 
1091     @Test
1092     fun elementsWithActionsHaveRipples() {
1093         TestGlanceAppWidget.uiDefinition = {
1094             Text(
1095                 text = "text1",
1096                 modifier = GlanceModifier.clickable(actionRunCallback<CallbackTest>()),
1097             )
1098         }
1099 
1100         mHostRule.startHost()
1101 
1102         mHostRule.onUnboxedHostView<FrameLayout> { box ->
1103             assertThat(box.notGoneChildCount).isEqualTo(2)
1104             val (boxedText, boxedImage) = box.notGoneChildren.toList()
1105             val text = boxedText.getTargetView<TextView>()
1106             val image = boxedImage.getTargetView<ImageView>()
1107             assertThat(text.background).isNull()
1108             assertThat(image.drawable).isNotNull()
1109             assertThat(image.isClickable()).isFalse()
1110         }
1111     }
1112 
1113     @Test
1114     fun elementsWithNoActionsDontHaveRipples() {
1115         TestGlanceAppWidget.uiDefinition = { Text("text1") }
1116 
1117         mHostRule.startHost()
1118 
1119         mHostRule.onUnboxedHostView<View> { view -> assertIs<TextView>(view) }
1120     }
1121 
1122     @SdkSuppress(minSdkVersion = 31)
1123     @Test
1124     fun compoundButtonsDoNotHaveRipples() {
1125         TestGlanceAppWidget.uiDefinition = {
1126             RadioButton(
1127                 checked = true,
1128                 onClick = actionRunCallback<CallbackTest>(),
1129                 text = "text1",
1130             )
1131         }
1132 
1133         mHostRule.startHost()
1134 
1135         mHostRule.onUnboxedHostView<View> { view -> assertIs<RadioButton>(view) }
1136     }
1137 
1138     @Test
1139     fun cancellingContentCoroutineCausesContentToLeaveComposition() =
1140         runBlocking<Unit> {
1141             val currentEffectState = MutableStateFlow(EffectState.Initial)
1142             var contentJob: Job? = null
1143             TestGlanceAppWidget.onProvideGlance = {
1144                 coroutineScope {
1145                     contentJob = launch {
1146                         provideContent {
1147                             DisposableEffect(true) {
1148                                 currentEffectState.tryEmit(EffectState.Started)
1149                                 onDispose { currentEffectState.tryEmit(EffectState.Disposed) }
1150                             }
1151                         }
1152                     }
1153                 }
1154             }
1155             launch { mHostRule.startHost() }
1156             currentEffectState.take(3).collectIndexed { index, state ->
1157                 when (index) {
1158                     0 -> assertThat(state).isEqualTo(EffectState.Initial)
1159                     1 -> {
1160                         assertThat(state).isEqualTo(EffectState.Started)
1161                         assertNotNull(contentJob).cancel()
1162                     }
1163                     2 -> assertThat(state).isEqualTo(EffectState.Disposed)
1164                 }
1165             }
1166         }
1167 
1168     @Test
1169     fun rootViewIdIsNotReservedId() =
1170         runBlocking<Unit> {
1171             TestGlanceAppWidget.uiDefinition = { Column {} }
1172 
1173             mHostRule.startHost()
1174             mHostRule.onUnboxedHostView<View> { root -> assertThat(root.id).isNotIn(0..1) }
1175         }
1176 
1177     @Test
1178     fun initialCompositionErrorUiLayout() = runBlocking {
1179         TestGlanceAppWidget.withErrorLayout(glance_error_layout) {
1180             TestGlanceAppWidget.uiDefinition = { throw Throwable("error") }
1181 
1182             mHostRule.startHost()
1183             mHostRule.onHostView { hostView ->
1184                 val layoutId =
1185                     assertNotNull((hostView as TestAppWidgetHostView).mRemoteViews?.layoutId)
1186                 assertThat(layoutId).isEqualTo(glance_error_layout)
1187             }
1188         }
1189     }
1190 
1191     @Test
1192     fun recompositionErrorUiLayout() = runBlocking {
1193         TestGlanceAppWidget.withErrorLayout(glance_error_layout) {
1194             val runError = mutableStateOf(false)
1195             TestGlanceAppWidget.uiDefinition = {
1196                 if (runError.value) throw Throwable("error") else Text("Hello World")
1197             }
1198 
1199             mHostRule.startHost()
1200             mHostRule.onUnboxedHostView<TextView> {
1201                 assertThat(it.text.toString()).isEqualTo("Hello World")
1202             }
1203             mHostRule.runAndWaitForUpdate { runError.value = true }
1204             mHostRule.onHostView { hostView ->
1205                 val layoutId =
1206                     assertNotNull((hostView as TestAppWidgetHostView).mRemoteViews?.layoutId)
1207                 assertThat(layoutId).isEqualTo(glance_error_layout)
1208             }
1209         }
1210     }
1211 
1212     @Test
1213     fun sideEffectErrorUiLayout() = runBlocking {
1214         TestGlanceAppWidget.withErrorLayout(glance_error_layout) {
1215             TestGlanceAppWidget.uiDefinition = { SideEffect { throw Throwable("error") } }
1216 
1217             mHostRule.startHost()
1218             mHostRule.onHostView { hostView ->
1219                 val layoutId =
1220                     assertNotNull((hostView as TestAppWidgetHostView).mRemoteViews?.layoutId)
1221                 assertThat(layoutId).isEqualTo(glance_error_layout)
1222             }
1223         }
1224     }
1225 
1226     @Test
1227     fun provideGlanceErrorUiLayout() = runBlocking {
1228         // This also tests LaunchedEffect error handling, since provideGlance is run in a
1229         // LaunchedEffect through collectAsState.
1230         TestGlanceAppWidget.withErrorLayout(glance_error_layout) {
1231             TestGlanceAppWidget.onProvideGlance = { throw Throwable("error") }
1232 
1233             mHostRule.startHost()
1234             mHostRule.onHostView { hostView ->
1235                 val layoutId =
1236                     assertNotNull((hostView as TestAppWidgetHostView).mRemoteViews?.layoutId)
1237                 assertThat(layoutId).isEqualTo(glance_error_layout)
1238             }
1239         }
1240     }
1241 
1242     @Test
1243     fun errorInBroadcastReceiverDoesNotCrashProcess() = runBlocking {
1244         // The following line causes the GlanceAppWidget to throw an error in `update`, which runs
1245         // in a child job of the BroadcastReceiver's goAsync scope.
1246         TestGlanceAppWidget.withErrorOnSessionCreation {
1247             // Waiting for RemoteViews should timeout since the update will fail. The process should
1248             // not crash.
1249             val result = runCatching { mHostRule.startHost() }
1250             assertThat(result.exceptionOrNull()).apply {
1251                 isInstanceOf(IllegalArgumentException::class.java)
1252                 hasMessageThat().contains("Timeout before getting RemoteViews")
1253             }
1254         }
1255     }
1256 
1257     // Check there is a single span of the given type and that it passes the [check].
1258     private inline fun <reified T> SpannedString.checkHasSingleTypedSpan(check: (T) -> Unit) {
1259         val spans = getSpans(0, length, T::class.java)
1260         assertThat(spans).hasLength(1)
1261         check(spans[0])
1262     }
1263 
1264     private fun assertViewSize(view: View, expectedSize: DpSize) {
1265         val density = view.context.resources.displayMetrics.density
1266         assertWithMessage("${view.accessibilityClassName} width")
1267             .that(view.width / density)
1268             .isWithin(1.1f / density)
1269             .of(expectedSize.width.value)
1270         assertWithMessage("${view.accessibilityClassName} height")
1271             .that(view.height / density)
1272             .isWithin(1.1f / density)
1273             .of(expectedSize.height.value)
1274     }
1275 
1276     private fun assertViewDimension(view: View, sizePx: Int, expectedSize: Dp) {
1277         val density = view.context.resources.displayMetrics.density
1278         assertThat(sizePx / density).isWithin(1.1f / density).of(expectedSize.value)
1279     }
1280 
1281     enum class EffectState {
1282         Initial,
1283         Started,
1284         Disposed
1285     }
1286 
1287     inner class ViewHierarchyFailureWatcher : TestWatcher() {
1288         override fun starting(description: Description) {
1289             super.starting(description)
1290             if (VERBOSE_LOG) {
1291                 Log.d(RECEIVER_TEST_TAG, "")
1292                 Log.d(RECEIVER_TEST_TAG, "")
1293                 Log.d(RECEIVER_TEST_TAG, "Starting: ${description.methodName}")
1294                 Log.d(RECEIVER_TEST_TAG, "---------------------")
1295             }
1296         }
1297 
1298         override fun failed(e: Throwable?, description: Description?) {
1299             Log.e(RECEIVER_TEST_TAG, "$description failed")
1300             Log.e(RECEIVER_TEST_TAG, "Host view hierarchy at failure:")
1301             logViewHierarchy(RECEIVER_TEST_TAG, mHostRule.mHostView, "")
1302         }
1303     }
1304 }
1305 
1306 private val testKey = intPreferencesKey("testKey")
1307 private val testBoolKey = booleanPreferencesKey("testKey")
1308 
1309 internal class CallbackTest : ActionCallback {
onActionnull1310     override suspend fun onAction(
1311         context: Context,
1312         glanceId: GlanceId,
1313         parameters: ActionParameters
1314     ) {
1315         val value = checkNotNull(parameters[key])
1316         received.update { it + value }
1317         latch.countDown()
1318     }
1319 
1320     companion object {
1321         lateinit var latch: CountDownLatch
1322         val received = AtomicReference<List<Int>>(emptyList())
1323         val key = testKey.toParametersKey()
1324     }
1325 }
1326 
1327 @Composable
BoxRowBoxnull1328 private fun BoxRowBox(modifier: GlanceModifier, text: String) {
1329     Box(modifier) {
1330         val rowModifier =
1331             GlanceModifier.background(ColorProvider(Color.Gray)).fillMaxWidth().padding(8.dp)
1332         Row(modifier = rowModifier) {
1333             val boxModifier =
1334                 GlanceModifier.background(ColorProvider(Color.DarkGray))
1335                     .width(64.dp)
1336                     .fillMaxHeight()
1337             Box(modifier = boxModifier, contentAlignment = Alignment.Center) { Text(text) }
1338         }
1339     }
1340 }
1341 
1342 internal class CompoundButtonActionTest : ActionCallback {
onActionnull1343     override suspend fun onAction(
1344         context: Context,
1345         glanceId: GlanceId,
1346         parameters: ActionParameters
1347     ) {
1348         val target = checkNotNull(parameters[key])
1349         val value = checkNotNull(parameters[ToggleableStateKey])
1350         received.update { (it ?: emptyList()) + (target to value) }
1351     }
1352 
1353     companion object {
1354         private val received = MutableStateFlow<List<Pair<String, Boolean>>?>(null)
1355         val key = ActionParameters.Key<String>("eventTarget")
1356 
resetnull1357         fun reset() {
1358             received.value = null
1359         }
1360 
1361         val currentValue
1362             get() = received.value
1363 
nextValuenull1364         suspend fun nextValue() = received.filterNotNull().first()
1365     }
1366 }
1367