Add width and height parameters to Glance @Preview

Relnote: Add width and height parameters to Glance @Preview
Bug: 325459978
Test: GlanceAppWidgetViewAdapterTest
Change-Id: Ibabe8a1ac71ff0a34c888956fc134b2fdda317d9
diff --git a/glance/glance-appwidget-preview/build.gradle b/glance/glance-appwidget-preview/build.gradle
index 5075396..b9d7f35 100644
--- a/glance/glance-appwidget-preview/build.gradle
+++ b/glance/glance-appwidget-preview/build.gradle
@@ -43,8 +43,10 @@
     api(project(":glance:glance-appwidget"))
 
     androidTestImplementation(libs.junit)
+    androidTestImplementation(libs.kotlinTest)
     androidTestImplementation(libs.testRunner)
     androidTestImplementation(libs.testRules)
+    androidTestImplementation(libs.truth)
 }
 
 android {
diff --git a/glance/glance-appwidget-preview/src/androidTest/kotlin/androidx/glance/appwidget/preview/GlanceAppWidgetPreviews.kt b/glance/glance-appwidget-preview/src/androidTest/kotlin/androidx/glance/appwidget/preview/GlanceAppWidgetPreviews.kt
index 1e997e2..b81e66a 100644
--- a/glance/glance-appwidget-preview/src/androidTest/kotlin/androidx/glance/appwidget/preview/GlanceAppWidgetPreviews.kt
+++ b/glance/glance-appwidget-preview/src/androidTest/kotlin/androidx/glance/appwidget/preview/GlanceAppWidgetPreviews.kt
@@ -17,9 +17,11 @@
 package androidx.glance.appwidget.preview
 
 import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.DpSize
 import androidx.compose.ui.unit.dp
 import androidx.glance.Button
 import androidx.glance.GlanceModifier
+import androidx.glance.LocalSize
 import androidx.glance.action.Action
 import androidx.glance.appwidget.appWidgetBackground
 import androidx.glance.layout.Alignment
@@ -42,7 +44,9 @@
             .padding(16.dp)
     ) {
         Text(
-            text = "First Glance widget",
+            text = "First Glance widget, LocalSize = ${LocalSize.current.let {
+                if (it == DpSize.Unspecified) "Unspecified" else "${it.width} x ${it.height}"
+            }}",
             modifier = GlanceModifier
                 .fillMaxWidth()
                 .padding(bottom = 8.dp),
diff --git a/glance/glance-appwidget-preview/src/androidTest/kotlin/androidx/glance/appwidget/preview/GlanceAppWidgetViewAdapterTest.kt b/glance/glance-appwidget-preview/src/androidTest/kotlin/androidx/glance/appwidget/preview/GlanceAppWidgetViewAdapterTest.kt
index a41f02ec..762784c 100644
--- a/glance/glance-appwidget-preview/src/androidTest/kotlin/androidx/glance/appwidget/preview/GlanceAppWidgetViewAdapterTest.kt
+++ b/glance/glance-appwidget-preview/src/androidTest/kotlin/androidx/glance/appwidget/preview/GlanceAppWidgetViewAdapterTest.kt
@@ -25,9 +25,12 @@
 import android.widget.LinearLayout
 import android.widget.RelativeLayout
 import android.widget.TextView
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
 import androidx.glance.appwidget.preview.test.R
 import androidx.test.filters.MediumTest
-import org.junit.Assert.assertNotNull
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertNotNull
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
@@ -73,9 +76,10 @@
     private fun initAndInflate(
         className: String,
         methodName: String,
+        size: DpSize,
     ) {
         activityTestRule.runOnUiThread {
-            glanceAppWidgetViewAdapter.init(className, methodName)
+            glanceAppWidgetViewAdapter.init(className, methodName, size)
             glanceAppWidgetViewAdapter.requestLayout()
         }
     }
@@ -98,27 +102,60 @@
         "Could not find the $viewTypeName View matching $composableName"
 
     @Test
-    fun testFirstGlancePreview() {
+    fun glanceAppWidgetPreview_unspecifiedSize() {
         initAndInflate(
-            "androidx.glance.appwidget.preview.GlanceAppWidgetPreviewsKt",
-            "FirstGlancePreview")
+            className = "androidx.glance.appwidget.preview.GlanceAppWidgetPreviewsKt",
+            methodName = "FirstGlancePreview",
+            size = DpSize.Unspecified
+        )
 
         activityTestRule.runOnUiThread {
             val rootComposable = glanceAppWidgetViewAdapter.getChildAt(0) as ViewGroup
             val linearLayoutColumn = rootComposable.getChildOfType<LinearLayout>()
-            assertNotNull(viewNotFoundMsg("LinearLayout", "Column"), linearLayoutColumn)
-            val textView = linearLayoutColumn!!.getChildOfType<TextView>()
-            assertNotNull(viewNotFoundMsg("TextView", "Text"), textView)
+            assertNotNull(linearLayoutColumn, viewNotFoundMsg("LinearLayout", "Column"))
+            val textView = linearLayoutColumn.getChildOfType<TextView>()
+            assertNotNull(textView, viewNotFoundMsg("TextView", "Text"))
+            assertThat(textView.text.toString())
+                .isEqualTo("First Glance widget, LocalSize = Unspecified")
             val linearLayoutRow = linearLayoutColumn.getChildOfType<LinearLayout>()
-            assertNotNull(viewNotFoundMsg("LinearLayout", "Row"), linearLayoutRow)
+            assertNotNull(linearLayoutRow, viewNotFoundMsg("LinearLayout", "Row"))
             // Backport button are implemented using FrameLayout and depending on the API version
             // Button might be wrapped in the RelativeLayout.
-            val button1 = linearLayoutRow!!.getChildOfType<Button>()
+            val button1 = linearLayoutRow.getChildOfType<Button>()
                 ?: linearLayoutRow.getChildOfType<RelativeLayout>()!!.getChildOfType<FrameLayout>()
             val button2 = linearLayoutRow.getChildOfType<Button>(1)
                 ?: linearLayoutRow.getChildOfType<RelativeLayout>(1)!!.getChildOfType<FrameLayout>()
-            assertNotNull(viewNotFoundMsg("FrameLayout", "Button"), button1)
-            assertNotNull(viewNotFoundMsg("FrameLayout", "Button"), button2)
+            assertNotNull(button1, viewNotFoundMsg("FrameLayout", "Button"))
+            assertNotNull(button2, viewNotFoundMsg("FrameLayout", "Button"))
+        }
+    }
+
+    @Test
+    fun glanceAppWidgetPreview_withSize() {
+        initAndInflate(
+            className = "androidx.glance.appwidget.preview.GlanceAppWidgetPreviewsKt",
+            methodName = "FirstGlancePreview",
+            size = DpSize(Dp(123.0f), Dp(456.0f))
+        )
+
+        activityTestRule.runOnUiThread {
+            val rootComposable = glanceAppWidgetViewAdapter.getChildAt(0) as ViewGroup
+            val linearLayoutColumn = rootComposable.getChildOfType<LinearLayout>()
+            assertNotNull(linearLayoutColumn, viewNotFoundMsg("LinearLayout", "Column"))
+            val textView = linearLayoutColumn.getChildOfType<TextView>()
+            assertNotNull(textView, viewNotFoundMsg("TextView", "Text"))
+            assertThat(textView.text.toString())
+                .isEqualTo("First Glance widget, LocalSize = 123.0.dp x 456.0.dp")
+            val linearLayoutRow = linearLayoutColumn.getChildOfType<LinearLayout>()
+            assertNotNull(linearLayoutRow, viewNotFoundMsg("LinearLayout", "Row"))
+            // Backport button are implemented using FrameLayout and depending on the API version
+            // Button might be wrapped in the RelativeLayout.
+            val button1 = linearLayoutRow.getChildOfType<Button>()
+                ?: linearLayoutRow.getChildOfType<RelativeLayout>()!!.getChildOfType<FrameLayout>()
+            val button2 = linearLayoutRow.getChildOfType<Button>(1)
+                ?: linearLayoutRow.getChildOfType<RelativeLayout>(1)!!.getChildOfType<FrameLayout>()
+            assertNotNull(button1, viewNotFoundMsg("FrameLayout", "Button"))
+            assertNotNull(button2, viewNotFoundMsg("FrameLayout", "Button"))
         }
     }
 
diff --git a/glance/glance-appwidget-preview/src/main/java/androidx/glance/appwidget/preview/GlanceAppWidgetViewAdapter.kt b/glance/glance-appwidget-preview/src/main/java/androidx/glance/appwidget/preview/GlanceAppWidgetViewAdapter.kt
index d484f5f..a2fd62b 100644
--- a/glance/glance-appwidget-preview/src/main/java/androidx/glance/appwidget/preview/GlanceAppWidgetViewAdapter.kt
+++ b/glance/glance-appwidget-preview/src/main/java/androidx/glance/appwidget/preview/GlanceAppWidgetViewAdapter.kt
@@ -21,6 +21,7 @@
 import android.util.AttributeSet
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.currentComposer
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.DpSize
 import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
 import androidx.glance.appwidget.GlanceRemoteViews
@@ -28,6 +29,7 @@
 import kotlinx.coroutines.runBlocking
 
 private const val TOOLS_NS_URI = "http://schemas.android.com/tools"
+private const val ANDROID_NS_URI = "http://schemas.android.com/apk/res/android"
 
 /**
  * View adapter that renders a glance `@Composable`. The `@Composable` is found by reading the
@@ -51,6 +53,7 @@
     internal fun init(
         className: String,
         methodName: String,
+        size: DpSize,
     ) {
         val content = @Composable {
             val composer = currentComposer
@@ -63,7 +66,7 @@
         val remoteViews = runBlocking {
             GlanceRemoteViews().compose(
                 context = context,
-                size = DpSize.Unspecified,
+                size = size,
                 content = content).remoteViews
         }
         val view = remoteViews.apply(context, this)
@@ -75,6 +78,13 @@
         val className = composableName.substringBeforeLast('.')
         val methodName = composableName.substringAfterLast('.')
 
-        init(className, methodName)
+        val width = attrs.getAttributeValue(ANDROID_NS_URI, "layout_width")
+                ?.removeSuffix("dp")?.toFloatOrNull()
+        val height = attrs.getAttributeValue(ANDROID_NS_URI, "layout_height")
+                ?.removeSuffix("dp")?.toFloatOrNull()
+        var size = DpSize.Unspecified
+        if (width != null && height != null) size = DpSize(Dp(width), Dp(height))
+
+        init(className, methodName, size)
     }
 }
diff --git a/glance/glance-preview/api/current.txt b/glance/glance-preview/api/current.txt
index fc59a6d..acbb7f5 100644
--- a/glance/glance-preview/api/current.txt
+++ b/glance/glance-preview/api/current.txt
@@ -4,12 +4,16 @@
   @SuppressCompatibility @kotlin.RequiresOptIn(message="This API is experimental and is likely to change in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalGlancePreviewApi {
   }
 
-  @SuppressCompatibility @androidx.glance.preview.ExperimentalGlancePreviewApi @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public @interface Preview {
+  @SuppressCompatibility @androidx.glance.preview.ExperimentalGlancePreviewApi @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public @interface Preview {
+    method public abstract int heightDp() default -1;
     method public abstract String surface();
+    method public abstract int widthDp() default -1;
+    property public abstract int heightDp;
     property public abstract String surface;
+    property public abstract int widthDp;
   }
 
-  @SuppressCompatibility @androidx.glance.preview.ExperimentalGlancePreviewApi @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public static @interface Preview.Container {
+  @SuppressCompatibility @androidx.glance.preview.ExperimentalGlancePreviewApi @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public static @interface Preview.Container {
     method public abstract androidx.glance.preview.Preview[] value();
   }
 
diff --git a/glance/glance-preview/api/restricted_current.txt b/glance/glance-preview/api/restricted_current.txt
index fc59a6d..acbb7f5 100644
--- a/glance/glance-preview/api/restricted_current.txt
+++ b/glance/glance-preview/api/restricted_current.txt
@@ -4,12 +4,16 @@
   @SuppressCompatibility @kotlin.RequiresOptIn(message="This API is experimental and is likely to change in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalGlancePreviewApi {
   }
 
-  @SuppressCompatibility @androidx.glance.preview.ExperimentalGlancePreviewApi @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public @interface Preview {
+  @SuppressCompatibility @androidx.glance.preview.ExperimentalGlancePreviewApi @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public @interface Preview {
+    method public abstract int heightDp() default -1;
     method public abstract String surface();
+    method public abstract int widthDp() default -1;
+    property public abstract int heightDp;
     property public abstract String surface;
+    property public abstract int widthDp;
   }
 
-  @SuppressCompatibility @androidx.glance.preview.ExperimentalGlancePreviewApi @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public static @interface Preview.Container {
+  @SuppressCompatibility @androidx.glance.preview.ExperimentalGlancePreviewApi @kotlin.annotation.Repeatable @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public static @interface Preview.Container {
     method public abstract androidx.glance.preview.Preview[] value();
   }
 
diff --git a/glance/glance-preview/src/main/java/androidx/glance/preview/Preview.kt b/glance/glance-preview/src/main/java/androidx/glance/preview/Preview.kt
index fb20c35..beb418ae 100644
--- a/glance/glance-preview/src/main/java/androidx/glance/preview/Preview.kt
+++ b/glance/glance-preview/src/main/java/androidx/glance/preview/Preview.kt
@@ -17,13 +17,29 @@
 package androidx.glance.preview
 
 /**
- * The annotation that marks glance components (functions) that should have visual preview.
+ * The annotation to be used on a Glance composable for displaying visual previews in Android
+ * Studio.
+ *
+ * The [widthDp] and [heightDp] parameters correspond to the size of the widget, i.e. the size
+ * available in the LocalSize composition local. When [widthDp] and [heightDp] aren't specified,
+ * the visual preview wraps its content. In this case, LocalSize should not be read within the
+ * composable.
+ *
+ * @param surface indicates which of the possible glance [Surfaces] to use in this preview.
+ * @param widthDp width in DP that will be used when rendering the annotated Glance @[Composable]
+ * and that will be set as the widget's LocalSize width.
+ * @param heightDp height in DP that will be used when rendering the annotated Glance @[Composable]
+ * and that will be set as the widget's LocalSize height.
  */
-@Retention(AnnotationRetention.SOURCE)
+@Retention(AnnotationRetention.BINARY)
 @Target(
     AnnotationTarget.ANNOTATION_CLASS,
     AnnotationTarget.FUNCTION
 )
 @ExperimentalGlancePreviewApi
 @Repeatable
-annotation class Preview(@Surface val surface: String)
+annotation class Preview(
+    @Surface val surface: String,
+    val widthDp: Int = -1,
+    val heightDp: Int = -1
+)