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