Support the `contentDescription` semantic modifier for glance-appWidgets. It already existed for glance-wear-tiles

Test: Manually tested using talkback
Change-Id: Iaf1d7099300c783fa1181315402d153466154ac0
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ApplyModifiers.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ApplyModifiers.kt
index c94a102..51d1774 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ApplyModifiers.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/ApplyModifiers.kt
@@ -45,6 +45,8 @@
 import androidx.glance.layout.HeightModifier
 import androidx.glance.layout.PaddingModifier
 import androidx.glance.layout.WidthModifier
+import androidx.glance.semantics.SemanticsModifier
+import androidx.glance.semantics.SemanticsProperties
 import androidx.glance.unit.Dimension
 import androidx.glance.unit.FixedColorProvider
 import androidx.glance.unit.ResourceColorProvider
@@ -64,6 +66,7 @@
     var actionModifier: ActionModifier? = null
     var enabled: EnabledModifier? = null
     var clipToOutline: ClipToOutlineModifier? = null
+    var semanticsModifier: SemanticsModifier? = null
     modifiers.foldIn(Unit) { _, modifier ->
         when (modifier) {
             is ActionModifier -> {
@@ -100,6 +103,7 @@
             }
             is ClipToOutlineModifier -> clipToOutline = modifier
             is EnabledModifier -> enabled = modifier
+            is SemanticsModifier -> semanticsModifier = modifier
             else -> {
                 Log.w(GlanceAppWidgetTag, "Unknown modifier '$modifier', nothing done.")
             }
@@ -127,6 +131,13 @@
     enabled?.let {
         rv.setBoolean(viewDef.mainViewId, "setEnabled", it.enabled)
     }
+    semanticsModifier?.let { semantics ->
+        val contentDescription: List<String>? =
+            semantics.configuration.getOrNull(SemanticsProperties.ContentDescription)
+        if (contentDescription != null) {
+            rv.setContentDescription(viewDef.mainViewId, contentDescription.joinToString())
+        }
+    }
     rv.setViewVisibility(viewDef.mainViewId, visibility.toViewVisibility())
 }
 
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/translators/ImageTranslator.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/translators/ImageTranslator.kt
index 46c96e8..c3ddf74 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/translators/ImageTranslator.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/translators/ImageTranslator.kt
@@ -53,7 +53,6 @@
         }
     }
     val viewDef = insertView(translationContext, selector, element.modifier)
-    setContentDescription(viewDef.mainViewId, element.contentDescription)
     when (val provider = element.provider) {
         is AndroidResourceImageProvider -> setImageViewResource(
             viewDef.mainViewId,
diff --git a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/CheckBoxTranslatorTest.kt b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/CheckBoxTranslatorTest.kt
index 734a6eb..e84a322 100644
--- a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/CheckBoxTranslatorTest.kt
+++ b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/CheckBoxTranslatorTest.kt
@@ -21,6 +21,7 @@
 import android.view.ViewGroup
 import android.widget.ImageView
 import androidx.compose.ui.graphics.Color
+import androidx.glance.GlanceModifier
 import androidx.glance.appwidget.CheckBox
 import androidx.glance.appwidget.checkBoxColors
 import androidx.glance.appwidget.ImageViewSubject.Companion.assertThat
@@ -31,6 +32,8 @@
 import androidx.glance.appwidget.findViewByType
 import androidx.glance.appwidget.runAndTranslate
 import androidx.glance.color.ColorProvider
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
 import androidx.glance.unit.FixedColorProvider
 import androidx.test.core.app.ApplicationProvider
 import com.google.common.truth.Truth.assertThat
@@ -221,4 +224,22 @@
         val checkboxRoot = assertIs<ViewGroup>(context.applyRemoteViews(rv))
         assertThat(checkboxRoot.hasOnClickListeners()).isTrue()
     }
+
+    @Test
+    fun canTranslateCheckBoxWithSemanticsModifier_contentDescription() =
+        fakeCoroutineScope.runTest {
+            val rv = context.runAndTranslate {
+                CheckBox(
+                    checked = true,
+                    onCheckedChange = actionRunCallback<ActionCallback>(),
+                    text = "CheckBox",
+                    modifier = GlanceModifier.semantics {
+                        contentDescription = "Custom checkbox description"
+                    },
+                )
+            }
+
+            val checkboxRoot = assertIs<ViewGroup>(context.applyRemoteViews(rv))
+            assertThat(checkboxRoot.contentDescription).isEqualTo("Custom checkbox description")
+        }
 }
\ No newline at end of file
diff --git a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/ImageTranslatorTest.kt b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/ImageTranslatorTest.kt
index 9236064..52bdc36 100644
--- a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/ImageTranslatorTest.kt
+++ b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/ImageTranslatorTest.kt
@@ -25,6 +25,7 @@
 import android.net.Uri
 import android.widget.ImageView
 import androidx.core.graphics.drawable.toBitmap
+import androidx.glance.GlanceModifier
 import androidx.glance.appwidget.applyRemoteViews
 import androidx.glance.appwidget.ImageProvider
 import androidx.glance.appwidget.runAndTranslate
@@ -32,6 +33,8 @@
 import androidx.glance.layout.ContentScale
 import androidx.glance.Image
 import androidx.glance.ImageProvider
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.filters.SdkSuppress
 import com.google.common.truth.Truth.assertThat
@@ -172,4 +175,49 @@
         assertThat(imageView.getContentDescription()).isEqualTo("oval")
         assertThat(imageView.getScaleType()).isEqualTo(ImageView.ScaleType.FIT_XY)
     }
+
+    @Test
+    fun translateImage_contentDescriptionFieldAndSemanticsSet_fieldPreferred() =
+        fakeCoroutineScope.runTest {
+            val rv = context.runAndTranslate {
+                Image(
+                    provider = ImageProvider(R.drawable.oval),
+                    contentDescription = "oval",
+                    modifier = GlanceModifier.semantics { contentDescription = "round" },
+                )
+            }
+
+            val imageView = assertIs<ImageView>(context.applyRemoteViews(rv))
+            assertThat(imageView.getContentDescription()).isEqualTo("oval")
+        }
+
+    @Test
+    fun translateImage_contentDescriptionFieldNullAndSemanticsSet_setFromSemantics() =
+        fakeCoroutineScope.runTest {
+            val rv = context.runAndTranslate {
+                Image(
+                    provider = ImageProvider(R.drawable.oval),
+                    contentDescription = null,
+                    modifier = GlanceModifier.semantics { contentDescription = "round" },
+                )
+            }
+
+            val imageView = assertIs<ImageView>(context.applyRemoteViews(rv))
+            assertThat(imageView.getContentDescription()).isEqualTo("round")
+        }
+
+    @Test
+    fun translateImage_contentDescriptionFieldAndSemanticsNull() =
+        fakeCoroutineScope.runTest {
+            val rv = context.runAndTranslate {
+                Image(
+                    provider = ImageProvider(R.drawable.oval),
+                    contentDescription = null,
+                    modifier = GlanceModifier.semantics {},
+                )
+            }
+
+            val imageView = assertIs<ImageView>(context.applyRemoteViews(rv))
+            assertThat(imageView.getContentDescription()).isNull()
+        }
 }
diff --git a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/RadioButtonTranslatorTest.kt b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/RadioButtonTranslatorTest.kt
index 1f249fc..892b5ed 100644
--- a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/RadioButtonTranslatorTest.kt
+++ b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/RadioButtonTranslatorTest.kt
@@ -22,6 +22,7 @@
 import android.widget.ImageView
 import android.widget.TextView
 import androidx.compose.ui.graphics.Color
+import androidx.glance.GlanceModifier
 import androidx.glance.appwidget.ImageViewSubject.Companion.assertThat
 import androidx.glance.appwidget.RadioButton
 import androidx.glance.appwidget.radioButtonColors
@@ -33,6 +34,8 @@
 import androidx.glance.appwidget.findViewByType
 import androidx.glance.appwidget.runAndTranslate
 import androidx.glance.color.ColorProvider
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
 import androidx.glance.unit.ColorProvider
 import androidx.test.core.app.ApplicationProvider
 import com.google.common.truth.Truth.assertThat
@@ -265,6 +268,25 @@
         assertThat(radioButtonRoot.hasOnClickListeners()).isFalse()
     }
 
+    @Test
+    fun canTranslateRadioButtonWithSemanticsModifier_contentDescription() =
+        fakeCoroutineScope.runTest {
+            val rv = context.runAndTranslate {
+                RadioButton(
+                    checked = true,
+                    onClick = actionRunCallback<ActionCallback>(),
+                    text = "RadioButton",
+                    modifier = GlanceModifier.semantics {
+                        contentDescription = "Custom radio button description"
+                    },
+                )
+            }
+
+            val radioButtonRoot = assertIs<ViewGroup>(context.applyRemoteViews(rv))
+            assertThat(radioButtonRoot.contentDescription)
+                .isEqualTo("Custom radio button description")
+        }
+
     private val ViewGroup.radioImageView: ImageView?
         get() = findView {
             shadowOf(it.drawable).createdFromResId ==
diff --git a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/SwitchTranslatorTest.kt b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/SwitchTranslatorTest.kt
index 3b70ee7..9dce352 100644
--- a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/SwitchTranslatorTest.kt
+++ b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/SwitchTranslatorTest.kt
@@ -21,6 +21,7 @@
 import android.view.ViewGroup
 import android.widget.ImageView
 import androidx.compose.ui.graphics.Color
+import androidx.glance.GlanceModifier
 import androidx.glance.appwidget.ImageViewSubject.Companion.assertThat
 import androidx.glance.appwidget.Switch
 import androidx.glance.appwidget.action.ActionCallback
@@ -31,6 +32,8 @@
 import androidx.glance.appwidget.runAndTranslate
 import androidx.glance.appwidget.switchColors
 import androidx.glance.color.ColorProvider
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
 import androidx.glance.unit.ColorProvider
 import androidx.test.core.app.ApplicationProvider
 import com.google.common.truth.Truth.assertThat
@@ -272,6 +275,23 @@
         assertThat(switchRoot.hasOnClickListeners()).isTrue()
     }
 
+    @Test
+    fun canTranslateSwitchWithSemanticsModifier_contentDescription() = fakeCoroutineScope.runTest {
+        val rv = context.runAndTranslate {
+            Switch(
+                checked = true,
+                onCheckedChange = actionRunCallback<ActionCallback>(),
+                text = "Switch",
+                modifier = GlanceModifier.semantics {
+                    contentDescription = "Custom switch description"
+                },
+            )
+        }
+
+        val switchRoot = assertIs<ViewGroup>(context.applyRemoteViews(rv))
+        assertThat(switchRoot.contentDescription).isEqualTo("Custom switch description")
+    }
+
     private val ViewGroup.thumbImageView: ImageView?
         get() = findView {
             shadowOf(it.drawable).createdFromResId ==
diff --git a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/TextTranslatorTest.kt b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/TextTranslatorTest.kt
index 0d26478..b877f04 100644
--- a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/TextTranslatorTest.kt
+++ b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/translators/TextTranslatorTest.kt
@@ -45,6 +45,8 @@
 import androidx.glance.color.ColorProvider
 import androidx.glance.layout.Column
 import androidx.glance.layout.fillMaxWidth
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
 import androidx.glance.text.FontStyle
 import androidx.glance.text.FontWeight
 import androidx.glance.text.Text
@@ -371,6 +373,23 @@
         assertThat(view.maxLines).isEqualTo(5)
     }
 
+    @Test
+    fun canTranslateTextWithSemanticsModifier_contentDescription() = fakeCoroutineScope.runTest {
+        val rv = context.runAndTranslate {
+            Text(
+                text = "Max line is set",
+                maxLines = 5,
+                modifier = GlanceModifier.semantics {
+                    contentDescription = "Custom text description"
+                },
+            )
+        }
+        val view = context.applyRemoteViews(rv)
+
+        assertIs<TextView>(view)
+        assertThat(view.contentDescription).isEqualTo("Custom text description")
+    }
+
     // Check there is a single span, that it's of the correct type and passes the [check].
     private inline fun <reified T> SpannedString.checkSingleSpan(check: (T) -> Unit) {
         val spans = getSpans(0, length, Any::class.java)
diff --git a/glance/glance-wear-tiles/src/androidMain/kotlin/androidx/glance/wear/tiles/WearCompositionTranslator.kt b/glance/glance-wear-tiles/src/androidMain/kotlin/androidx/glance/wear/tiles/WearCompositionTranslator.kt
index 7e0ee8d..22b630f 100644
--- a/glance/glance-wear-tiles/src/androidMain/kotlin/androidx/glance/wear/tiles/WearCompositionTranslator.kt
+++ b/glance/glance-wear-tiles/src/androidMain/kotlin/androidx/glance/wear/tiles/WearCompositionTranslator.kt
@@ -561,7 +561,7 @@
     val imageBuilder = LayoutElementBuilders.Image.Builder()
         .setWidth(element.modifier.getWidth(context).toImageDimension())
         .setHeight(element.modifier.getHeight(context).toImageDimension())
-        .setModifiers(translateModifiers(context, element.modifier, element.contentDescription))
+        .setModifiers(translateModifiers(context, element.modifier))
         .setResourceId(mappedResId)
         .setContentScaleMode(
             when (element.contentScale) {
@@ -701,7 +701,6 @@
 private fun translateModifiers(
     context: Context,
     modifier: GlanceModifier,
-    contentDescription: String? = null
 ): ModifiersBuilders.Modifiers =
     modifier.foldIn(ModifiersBuilders.Modifiers.Builder()) { builder, element ->
         when (element) {
@@ -727,14 +726,6 @@
                 ?.let {
                     builder.setPadding(it.toProto())
                 }
-
-            contentDescription?.let { contentDescription ->
-                builder.setSemantics(
-                    ModifiersBuilders.Semantics.Builder()
-                        .setContentDescription(contentDescription)
-                        .build()
-                )
-            }
         }
         .build()
 
diff --git a/glance/glance/src/androidMain/kotlin/androidx/glance/Image.kt b/glance/glance/src/androidMain/kotlin/androidx/glance/Image.kt
index 8b402b4..7869e12 100644
--- a/glance/glance/src/androidMain/kotlin/androidx/glance/Image.kt
+++ b/glance/glance/src/androidMain/kotlin/androidx/glance/Image.kt
@@ -24,6 +24,8 @@
 import androidx.annotation.RestrictTo
 import androidx.compose.runtime.Composable
 import androidx.glance.layout.ContentScale
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
 
 /**
  * Interface representing an Image source which can be used with a Glance [Image] element.
@@ -78,20 +80,17 @@
     override var modifier: GlanceModifier = GlanceModifier
 
     var provider: ImageProvider? = null
-    var contentDescription: String? = null
     var contentScale: ContentScale = ContentScale.Fit
 
     override fun copy(): Emittable = EmittableImage().also {
         it.modifier = modifier
         it.provider = provider
-        it.contentDescription = contentDescription
         it.contentScale = contentScale
     }
 
     override fun toString(): String = "EmittableImage(" +
         "modifier=$modifier, " +
         "provider=$provider, " +
-        "contentDescription=$contentDescription, " +
         "contentScale=$contentScale" +
         ")"
 }
@@ -117,12 +116,19 @@
     modifier: GlanceModifier = GlanceModifier,
     contentScale: ContentScale = ContentScale.Fit
 ) {
+    val finalModifier = if (contentDescription != null) {
+        modifier.semantics {
+            this.contentDescription = contentDescription
+        }
+    } else {
+        modifier
+    }
+
     GlanceNode(
         factory = ::EmittableImage,
         update = {
             this.set(provider) { this.provider = it }
-            this.set(contentDescription) { this.contentDescription = it }
-            this.set(modifier) { this.modifier = it }
+            this.set(finalModifier) { this.modifier = it }
             this.set(contentScale) { this.contentScale = it }
         }
     )
diff --git a/glance/glance/src/test/kotlin/androidx/glance/ImageTest.kt b/glance/glance/src/test/kotlin/androidx/glance/ImageTest.kt
index 0a67b2e..38c08f1 100644
--- a/glance/glance/src/test/kotlin/androidx/glance/ImageTest.kt
+++ b/glance/glance/src/test/kotlin/androidx/glance/ImageTest.kt
@@ -21,6 +21,8 @@
 import androidx.glance.layout.PaddingModifier
 import androidx.glance.layout.padding
 import androidx.glance.layout.runTestingComposition
+import androidx.glance.semantics.SemanticsModifier
+import androidx.glance.semantics.SemanticsProperties
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.test.TestScope
@@ -28,6 +30,7 @@
 import org.junit.Before
 import org.junit.Test
 import kotlin.test.assertIs
+import kotlin.test.assertNotNull
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class ImageTest {
@@ -56,7 +59,9 @@
 
         val imgSource = assertIs<AndroidResourceImageProvider>(img.provider)
         assertThat(imgSource.resId).isEqualTo(5)
-        assertThat(img.contentDescription).isEqualTo("Hello World")
+        val semanticsModifier = assertNotNull(img.modifier.findModifier<SemanticsModifier>())
+        assertThat(semanticsModifier.configuration[SemanticsProperties.ContentDescription])
+            .containsExactly("Hello World")
         assertThat(img.contentScale).isEqualTo(ContentScale.FillBounds)
         assertThat(img.modifier.findModifier<PaddingModifier>()).isNotNull()
     }