Run GlanceAppWidget compositions in SessionWorker

This change updates the glance-appwidget library to use Glance
SessionManager/SessionWorker to run compositions in WorkManager. This
behavior is off by default, but can be turned on by a flag in the source
or in tests.

AppWidgetSessionTest and SizeBoxTest unit tests are added.
GlanceAppWidgetReceiverTest is modified to run all its tests both with
the new SessionWorker and with the current method.

AppWidgetHostRule now waits for Activity.onConfigurationChange when
testing orientation changes.

Bug: 239747024
Test: gradlew :glance:glance-appwidget:{connectedAndroidTest,test}
Relnote: N/A
Change-Id: I6e176d2cd909f0ff5ced48d8f6427e110733d042
diff --git a/glance/glance-appwidget-proto/src/main/proto/layout.proto b/glance/glance-appwidget-proto/src/main/proto/layout.proto
index 08ef3e2..9685e81 100644
--- a/glance/glance-appwidget-proto/src/main/proto/layout.proto
+++ b/glance/glance-appwidget-proto/src/main/proto/layout.proto
@@ -84,4 +84,5 @@
   RADIO_BUTTON = 19;
   RADIO_ROW = 20;
   RADIO_COLUMN = 21;
+  SIZE_BOX = 22;
 }
diff --git a/glance/glance-appwidget/api/current.txt b/glance/glance-appwidget/api/current.txt
index bf3e575..6244413 100644
--- a/glance/glance-appwidget/api/current.txt
+++ b/glance/glance-appwidget/api/current.txt
@@ -13,6 +13,16 @@
   public final class AppWidgetModifiersKt {
   }
 
+  public fun interface AppWidgetProviderScope {
+    method public suspend Object? setContent(kotlin.jvm.functions.Function0<kotlin.Unit> content, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+  }
+
+  public final class AppWidgetSessionKt {
+  }
+
+  public final class AppWidgetUtilsKt {
+  }
+
   public final class ApplyModifiersKt {
   }
 
@@ -53,10 +63,13 @@
   public abstract class GlanceAppWidget {
     ctor public GlanceAppWidget(optional @LayoutRes int errorUiLayout);
     method @androidx.compose.runtime.Composable @androidx.glance.GlanceComposable public abstract void Content();
+    method public androidx.glance.session.SessionManager? getSessionManager();
     method public androidx.glance.appwidget.SizeMode getSizeMode();
     method public androidx.glance.state.GlanceStateDefinition<?>? getStateDefinition();
     method public suspend Object? onDelete(android.content.Context context, androidx.glance.GlanceId glanceId, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public suspend Object? provideGlance(androidx.glance.appwidget.AppWidgetProviderScope, android.content.Context context, androidx.glance.GlanceId glanceId, kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public final suspend Object? update(android.content.Context context, androidx.glance.GlanceId glanceId, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    property public androidx.glance.session.SessionManager? sessionManager;
     property public androidx.glance.appwidget.SizeMode sizeMode;
     property public androidx.glance.state.GlanceStateDefinition<?>? stateDefinition;
   }
@@ -133,6 +146,9 @@
   public final class RemoteViewsTranslatorKt {
   }
 
+  public final class SizeBoxKt {
+  }
+
   public sealed interface SizeMode {
   }
 
diff --git a/glance/glance-appwidget/api/public_plus_experimental_current.txt b/glance/glance-appwidget/api/public_plus_experimental_current.txt
index 6934f35..27e2045 100644
--- a/glance/glance-appwidget/api/public_plus_experimental_current.txt
+++ b/glance/glance-appwidget/api/public_plus_experimental_current.txt
@@ -13,6 +13,16 @@
   public final class AppWidgetModifiersKt {
   }
 
+  public fun interface AppWidgetProviderScope {
+    method public suspend Object? setContent(kotlin.jvm.functions.Function0<kotlin.Unit> content, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+  }
+
+  public final class AppWidgetSessionKt {
+  }
+
+  public final class AppWidgetUtilsKt {
+  }
+
   public final class ApplyModifiersKt {
   }
 
@@ -56,10 +66,13 @@
   public abstract class GlanceAppWidget {
     ctor public GlanceAppWidget(optional @LayoutRes int errorUiLayout);
     method @androidx.compose.runtime.Composable @androidx.glance.GlanceComposable public abstract void Content();
+    method public androidx.glance.session.SessionManager? getSessionManager();
     method public androidx.glance.appwidget.SizeMode getSizeMode();
     method public androidx.glance.state.GlanceStateDefinition<?>? getStateDefinition();
     method public suspend Object? onDelete(android.content.Context context, androidx.glance.GlanceId glanceId, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public suspend Object? provideGlance(androidx.glance.appwidget.AppWidgetProviderScope, android.content.Context context, androidx.glance.GlanceId glanceId, kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public final suspend Object? update(android.content.Context context, androidx.glance.GlanceId glanceId, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    property public androidx.glance.session.SessionManager? sessionManager;
     property public androidx.glance.appwidget.SizeMode sizeMode;
     property public androidx.glance.state.GlanceStateDefinition<?>? stateDefinition;
   }
@@ -147,6 +160,9 @@
   public final class RemoteViewsTranslatorKt {
   }
 
+  public final class SizeBoxKt {
+  }
+
   public sealed interface SizeMode {
   }
 
diff --git a/glance/glance-appwidget/api/restricted_current.txt b/glance/glance-appwidget/api/restricted_current.txt
index bf3e575..6244413 100644
--- a/glance/glance-appwidget/api/restricted_current.txt
+++ b/glance/glance-appwidget/api/restricted_current.txt
@@ -13,6 +13,16 @@
   public final class AppWidgetModifiersKt {
   }
 
+  public fun interface AppWidgetProviderScope {
+    method public suspend Object? setContent(kotlin.jvm.functions.Function0<kotlin.Unit> content, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+  }
+
+  public final class AppWidgetSessionKt {
+  }
+
+  public final class AppWidgetUtilsKt {
+  }
+
   public final class ApplyModifiersKt {
   }
 
@@ -53,10 +63,13 @@
   public abstract class GlanceAppWidget {
     ctor public GlanceAppWidget(optional @LayoutRes int errorUiLayout);
     method @androidx.compose.runtime.Composable @androidx.glance.GlanceComposable public abstract void Content();
+    method public androidx.glance.session.SessionManager? getSessionManager();
     method public androidx.glance.appwidget.SizeMode getSizeMode();
     method public androidx.glance.state.GlanceStateDefinition<?>? getStateDefinition();
     method public suspend Object? onDelete(android.content.Context context, androidx.glance.GlanceId glanceId, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public suspend Object? provideGlance(androidx.glance.appwidget.AppWidgetProviderScope, android.content.Context context, androidx.glance.GlanceId glanceId, kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public final suspend Object? update(android.content.Context context, androidx.glance.GlanceId glanceId, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    property public androidx.glance.session.SessionManager? sessionManager;
     property public androidx.glance.appwidget.SizeMode sizeMode;
     property public androidx.glance.state.GlanceStateDefinition<?>? stateDefinition;
   }
@@ -133,6 +146,9 @@
   public final class RemoteViewsTranslatorKt {
   }
 
+  public final class SizeBoxKt {
+  }
+
   public sealed interface SizeMode {
   }
 
diff --git a/glance/glance-appwidget/build.gradle b/glance/glance-appwidget/build.gradle
index 09baaa6..c8aa9d9 100644
--- a/glance/glance-appwidget/build.gradle
+++ b/glance/glance-appwidget/build.gradle
@@ -74,6 +74,7 @@
     androidTestImplementation(project(":test:screenshot:screenshot"))
     androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0")
     androidTestImplementation('androidx.core:core-ktx:1.7.0')
+    androidTestImplementation("androidx.work:work-testing:2.7.1")
     androidTestImplementation(libs.espressoCore)
     androidTestImplementation(libs.espressoIdlingResource)
     androidTestImplementation(libs.kotlinCoroutinesTest)
diff --git a/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/AppWidgetHostRule.kt b/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/AppWidgetHostRule.kt
index 8639a2f..f2aa39f 100644
--- a/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/AppWidgetHostRule.kt
+++ b/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/AppWidgetHostRule.kt
@@ -17,44 +17,41 @@
 package androidx.glance.appwidget
 
 import android.Manifest
-import android.app.Activity
 import android.appwidget.AppWidgetHostView
 import android.appwidget.AppWidgetManager
 import android.content.Context
 import android.content.pm.ActivityInfo
-import android.os.Build
+import android.content.res.Configuration
 import android.view.View
 import android.view.ViewGroup
 import android.view.ViewTreeObserver
 import androidx.compose.ui.unit.DpSize
 import androidx.compose.ui.unit.dp
 import androidx.core.view.children
+import androidx.glance.session.GlanceSessionManager
 import androidx.test.core.app.ActivityScenario
 import androidx.test.core.app.ApplicationProvider
-import androidx.test.espresso.Espresso.onView
-import androidx.test.espresso.UiController
-import androidx.test.espresso.ViewAction
-import androidx.test.espresso.matcher.ViewMatchers.isRoot
 import androidx.test.ext.junit.rules.ActivityScenarioRule
 import androidx.test.filters.SdkSuppress
 import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
-import androidx.test.runner.lifecycle.Stage
 import androidx.test.uiautomator.UiDevice
-import org.hamcrest.Matcher
-import org.junit.rules.RuleChain
-import org.junit.rules.TestRule
-import org.junit.runner.Description
-import org.junit.runners.model.Statement
+import androidx.work.WorkManager
+import androidx.work.testing.WorkManagerTestInitHelper
+import com.google.common.truth.Truth.assertThat
 import java.util.concurrent.CountDownLatch
 import java.util.concurrent.TimeUnit
 import kotlin.test.assertIs
 import kotlin.test.fail
+import org.junit.rules.RuleChain
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
 
 @SdkSuppress(minSdkVersion = 29)
 class AppWidgetHostRule(
     private var mPortraitSize: DpSize = DpSize(200.dp, 300.dp),
-    private var mLandscapeSize: DpSize = DpSize(300.dp, 200.dp)
+    private var mLandscapeSize: DpSize = DpSize(300.dp, 200.dp),
+    private var useSessionManager: Boolean = false,
 ) : TestRule {
 
     val portraitSize: DpSize
@@ -101,6 +98,9 @@
     override fun apply(base: Statement, description: Description) = object : Statement() {
 
         override fun evaluate() {
+            WorkManagerTestInitHelper.initializeTestWorkManager(mContext)
+            TestGlanceAppWidget.sessionManager =
+                if (useSessionManager) GlanceSessionManager else null
             mInnerRules.apply(base, description).evaluate()
             stopHost()
         }
@@ -109,6 +109,7 @@
             if (mHostStarted) {
                 mUiAutomation.dropShellPermissionIdentity()
             }
+            WorkManager.getInstance(mContext).cancelAllWork()
         }
     }
 
@@ -129,15 +130,32 @@
         }
     }
 
-    suspend fun updateAppWidget() {
+    /**
+     * Run the [block] (usually some sort of app widget update) and wait for new RemoteViews to be
+     * applied.
+     */
+    suspend fun runAndWaitForUpdate(block: suspend () -> Unit) {
         val hostView = checkNotNull(mMaybeHostView) { "Host view wasn't successfully started" }
         hostView.resetRemoteViewsLatch()
-        TestGlanceAppWidget.update(mContext, AppWidgetId(mAppWidgetId))
+        block()
         runAndWaitForChildren {
             hostView.waitForRemoteViews()
         }
     }
 
+    /**
+     * Set TestGlanceAppWidgetReceiver to ignore broadcasts, run [block], and then reset
+     * TestGlanceAppWidgetReceiver.
+     */
+    fun ignoreBroadcasts(block: () -> Unit) {
+        TestGlanceAppWidgetReceiver.ignoreBroadcasts = true
+        try {
+            block()
+        } finally {
+            TestGlanceAppWidgetReceiver.ignoreBroadcasts = false
+        }
+    }
+
     fun removeAppWidget() {
         mActivityRule.scenario.onActivity { activity ->
             val hostView = checkNotNull(mMaybeHostView) { "No app widget to remove" }
@@ -168,12 +186,30 @@
 
     /** Change the orientation to landscape.*/
     fun setLandscapeOrientation() {
-        onView(isRoot()).perform(orientationLandscape())
+        var activity: AppWidgetHostTestActivity? = null
+        onHostActivity {
+            it.resetConfigurationChangedLatch()
+            it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+            activity = it
+        }
+        checkNotNull(activity).apply {
+            waitForConfigurationChange()
+            assertThat(lastConfiguration.orientation).isEqualTo(Configuration.ORIENTATION_LANDSCAPE)
+        }
     }
 
     /** Change the orientation to portrait.*/
     fun setPortraitOrientation() {
-        onView(isRoot()).perform(orientationPortrait())
+        var activity: AppWidgetHostTestActivity? = null
+        onHostActivity {
+            it.resetConfigurationChangedLatch()
+            it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+            activity = it
+        }
+        checkNotNull(activity).apply {
+            waitForConfigurationChange()
+            assertThat(lastConfiguration.orientation).isEqualTo(Configuration.ORIENTATION_PORTRAIT)
+        }
     }
 
     /**
@@ -263,46 +299,4 @@
             mHostView.childCount > 0
         }
     }
-
-    private inner class OrientationChangeAction constructor(private val orientation: Int) :
-        ViewAction {
-        override fun getConstraints(): Matcher<View> = isRoot()
-
-        override fun getDescription() = "change orientation to $orientationName"
-
-        private val orientationName: String
-            get() =
-                if (orientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) {
-                    "landscape"
-                } else {
-                    "portrait"
-                }
-
-        override fun perform(uiController: UiController, view: View) {
-            uiController.loopMainThreadUntilIdle()
-            mActivityRule.scenario.onActivity { it.requestedOrientation = orientation }
-            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
-                // Somehow, before Android S, changing the orientation doesn't trigger the
-                // onConfigurationChange
-                uiController.loopMainThreadUntilIdle()
-                mScenario.onActivity {
-                    it.updateAllSizes(it.resources.configuration.orientation)
-                    it.reapplyRemoteViews()
-                }
-            }
-            val resumedActivities: Collection<Activity> =
-                ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED)
-            if (resumedActivities.isEmpty()) {
-                throw RuntimeException("Could not change orientation")
-            }
-        }
-    }
-
-    private fun orientationLandscape(): ViewAction {
-        return OrientationChangeAction(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
-    }
-
-    private fun orientationPortrait(): ViewAction {
-        return OrientationChangeAction(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
-    }
 }
diff --git a/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/AppWidgetHostTestActivity.kt b/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/AppWidgetHostTestActivity.kt
index 8f98489..03f13ef 100644
--- a/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/AppWidgetHostTestActivity.kt
+++ b/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/AppWidgetHostTestActivity.kt
@@ -46,6 +46,10 @@
 class AppWidgetHostTestActivity : Activity() {
     private var mHost: AppWidgetHost? = null
     private val mHostViews = mutableListOf<TestAppWidgetHostView>()
+    private var mConfigurationChanged: CountDownLatch? = null
+    private var mLastConfiguration: Configuration? = null
+    val lastConfiguration: Configuration
+        get() = synchronized(this) { mLastConfiguration!! }
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -115,16 +119,28 @@
 
     override fun onConfigurationChanged(newConfig: Configuration) {
         super.onConfigurationChanged(newConfig)
-        updateAllSizes(newConfig.orientation)
-        reapplyRemoteViews()
+        mHostViews.forEach {
+            it.updateSize(newConfig.orientation)
+            it.reapplyRemoteViews()
+        }
+        synchronized(this) {
+            mLastConfiguration = newConfig
+            mConfigurationChanged?.countDown()
+        }
     }
 
-    fun updateAllSizes(orientation: Int) {
-        mHostViews.forEach { it.updateSize(orientation) }
+    fun resetConfigurationChangedLatch() {
+       synchronized(this) {
+           mConfigurationChanged = CountDownLatch(1)
+           mLastConfiguration = null
+       }
     }
 
-    fun reapplyRemoteViews() {
-        mHostViews.forEach { it.reapplyRemoteViews() }
+    // This should not be called from the main thread, so that it does not block
+    // onConfigurationChanged from being called.
+    fun waitForConfigurationChange() {
+        val result = mConfigurationChanged?.await(5, TimeUnit.SECONDS)!!
+        require(result) { "Timeout before getting configuration" }
     }
 }
 
diff --git a/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/GlanceAppWidgetReceiverTest.kt b/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/GlanceAppWidgetReceiverTest.kt
index 20c2755..a3ca4e5 100644
--- a/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/GlanceAppWidgetReceiverTest.kt
+++ b/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/GlanceAppWidgetReceiverTest.kt
@@ -94,18 +94,28 @@
 import kotlin.test.assertIs
 import kotlin.test.assertNotNull
 import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertThrows
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
 
 @SdkSuppress(minSdkVersion = 29)
 @MediumTest
-class GlanceAppWidgetReceiverTest {
+@RunWith(Parameterized::class)
+class GlanceAppWidgetReceiverTest(useSessionManager: Boolean) {
     @get:Rule
-    val mHostRule = AppWidgetHostRule()
+    val mHostRule = AppWidgetHostRule(useSessionManager = useSessionManager)
 
     val context = InstrumentationRegistry.getInstrumentation().targetContext!!
 
+    companion object {
+        @Parameterized.Parameters(name = "useGlanceSession={0}")
+        @JvmStatic
+        fun data() = mutableListOf(true, false)
+    }
+
     @Before
     fun setUp() {
         // Reset the size mode to the default
@@ -543,64 +553,65 @@
     }
 
     @Test
-    fun updateAll() {
+    fun updateAll() = runBlocking {
         TestGlanceAppWidget.uiDefinition = {
-            Text("before")
+            Text("text")
         }
 
         mHostRule.startHost()
 
-        val didRun = AtomicBoolean(false)
-        TestGlanceAppWidget.uiDefinition = {
-            didRun.set(true)
-            Text("after")
-        }
-
-        runBlocking {
+        mHostRule.runAndWaitForUpdate {
             TestGlanceAppWidget.updateAll(context)
         }
-        assertThat(didRun.get()).isTrue()
     }
 
     @Test
-    fun updateIf() {
+    fun updateIf() = runBlocking {
+        val didRun = AtomicBoolean(false)
         TestGlanceAppWidget.uiDefinition = {
-            Text("before")
+            currentState<Preferences>()
+            didRun.set(true)
+            Text("text")
         }
 
         mHostRule.startHost()
+        assertThat(didRun.get()).isTrue()
 
-        val appWidgetManager = GlanceAppWidgetManager(context)
-        runBlocking {
-            appWidgetManager.getGlanceIds(TestGlanceAppWidget::class.java)
-                .forEach { glanceId ->
-                    updateAppWidgetState(context, glanceId) {
-                        it[testKey] = 2
-                    }
+        GlanceAppWidgetManager(context)
+            .getGlanceIds(TestGlanceAppWidget::class.java)
+            .forEach { glanceId ->
+                updateAppWidgetState(context, glanceId) {
+                    it[testKey] = 2
                 }
-        }
+            }
 
         // Make sure the app widget is updated if the test is true
-        val didRun = AtomicBoolean(false)
-        TestGlanceAppWidget.uiDefinition = {
-            didRun.set(true)
-            Text("after")
-        }
-        runBlocking {
+        didRun.set(false)
+        mHostRule.runAndWaitForUpdate {
             TestGlanceAppWidget.updateIf<Preferences>(context) { prefs ->
                 prefs[testKey] == 2
             }
         }
-
         assertThat(didRun.get()).isTrue()
 
         // Make sure it is not if the test is false
         didRun.set(false)
-        runBlocking {
-            TestGlanceAppWidget.updateIf<Preferences>(context) { prefs ->
-                prefs[testKey] == 3
+
+        // Waiting for the update should timeout since it is never triggered.
+        val exception = assertThrows(IllegalArgumentException::class.java) {
+            // AppWidgetService may send an APPWIDGET_UPDATE broadcast, which is not relevant to
+            // this and should be ignored.
+            mHostRule.ignoreBroadcasts {
+                runBlocking {
+                    mHostRule.runAndWaitForUpdate {
+                        TestGlanceAppWidget.updateIf<Preferences>(context) { prefs ->
+                            prefs[testKey] == 3
+                        }
+                    }
+                }
             }
         }
+        assertThat(exception).hasMessageThat().contains("Timeout before getting RemoteViews")
 
         assertThat(didRun.get()).isFalse()
     }
@@ -608,7 +619,7 @@
     @Test
     fun viewState() {
         TestGlanceAppWidget.uiDefinition = {
-            val value = currentState<Preferences>()[testKey] ?: -1
+            val value = currentState(testKey) ?: -1
             Text("Value = $value")
         }
 
@@ -810,7 +821,9 @@
 
         mHostRule.startHost()
         runBlocking {
-            mHostRule.updateAppWidget()
+            mHostRule.runAndWaitForUpdate {
+                TestGlanceAppWidget.update(context, AppWidgetId(mHostRule.appWidgetId))
+            }
         }
 
         // if no crash, we're good
@@ -870,7 +883,9 @@
             updateAppWidgetState(context, AppWidgetId(mHostRule.appWidgetId)) {
                 it[testBoolKey] = false
             }
-            mHostRule.updateAppWidget()
+            mHostRule.runAndWaitForUpdate {
+                TestGlanceAppWidget.update(context, AppWidgetId(mHostRule.appWidgetId))
+            }
         }
 
         mHostRule.onHostView { root ->
@@ -914,7 +929,9 @@
             updateAppWidgetState(context, AppWidgetId(mHostRule.appWidgetId)) {
                 it[testBoolKey] = false
             }
-            mHostRule.updateAppWidget()
+            mHostRule.runAndWaitForUpdate {
+                TestGlanceAppWidget.update(context, AppWidgetId(mHostRule.appWidgetId))
+            }
         }
 
         CompoundButtonActionTest.received.set(emptyList())
diff --git a/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/TestGlanceAppWidgetReceiver.kt b/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/TestGlanceAppWidgetReceiver.kt
index 9a750be5..d2832ae 100644
--- a/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/TestGlanceAppWidgetReceiver.kt
+++ b/glance/glance-appwidget/src/androidAndroidTest/kotlin/androidx/glance/appwidget/TestGlanceAppWidgetReceiver.kt
@@ -17,14 +17,29 @@
 package androidx.glance.appwidget
 
 import android.content.Context
+import android.content.Intent
+import android.util.Log
 import androidx.compose.runtime.Composable
 import androidx.glance.GlanceId
+import androidx.glance.session.SessionManager
 
 class TestGlanceAppWidgetReceiver : GlanceAppWidgetReceiver() {
     override val glanceAppWidget: GlanceAppWidget = TestGlanceAppWidget
+    companion object {
+        var ignoreBroadcasts = false
+    }
+
+    override fun onReceive(context: Context, intent: Intent) {
+        if (ignoreBroadcasts) {
+            Log.w("TestGlanceAppWidgetReceiver", "Ignored $intent")
+            return
+        }
+        super.onReceive(context, intent)
+    }
 }
 
 object TestGlanceAppWidget : GlanceAppWidget(errorUiLayout = 0) {
+    override var sessionManager: SessionManager? = null
 
     override var sizeMode: SizeMode = SizeMode.Single
 
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/AppWidgetSession.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/AppWidgetSession.kt
new file mode 100644
index 0000000..2250fbc
--- /dev/null
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/AppWidgetSession.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.appwidget
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.os.Bundle
+import android.util.Log
+import android.widget.RemoteViews
+import androidx.annotation.VisibleForTesting
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.neverEqualPolicy
+import androidx.compose.ui.unit.DpSize
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.glance.EmittableWithChildren
+import androidx.glance.GlanceComposable
+import androidx.glance.LocalContext
+import androidx.glance.LocalGlanceId
+import androidx.glance.LocalState
+import androidx.glance.session.Session
+import androidx.glance.session.SetContentFn
+import androidx.glance.state.ConfigManager
+import androidx.glance.state.GlanceState
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import java.util.concurrent.CancellationException
+
+/**
+ * A session that composes UI for a single app widget.
+ *
+ * This class represents the lifecycle of composition for an app widget. This is started by
+ * [GlanceAppWidget] in response to APPWIDGET_UPDATE broadcasts. The session is run in
+ * [androidx.glance.session.SessionWorker] on a background thread (WorkManager). While it is active,
+ * the session will continue to recompose in response to composition state changes or external
+ * events (e.g. [AppWidgetSession.updateGlance]). If a session is already running, GlanceAppWidget
+ * will trigger events on the session instead of starting a new one.
+ *
+ * @property widget the GlanceAppWidget that contains the composable for this session.
+ * @property id identifies which widget will be updated when the UI is ready.
+ * @property initialOptions options to be provided to the composition and determine sizing.
+ * @property configManager used by the session to retrieve configuration state.
+ */
+internal class AppWidgetSession(
+    private val widget: GlanceAppWidget,
+    private val id: AppWidgetId,
+    private val initialOptions: Bundle? = null,
+    private val configManager: ConfigManager = GlanceState,
+) : Session(id.toSessionKey()) {
+
+    private companion object {
+        const val TAG = "AppWidgetSession"
+        const val DEBUG = false
+    }
+
+    private val glanceState = mutableStateOf(emptyPreferences(), neverEqualPolicy())
+    private val options = mutableStateOf(Bundle(), neverEqualPolicy())
+    @VisibleForTesting
+    internal var lastRemoteViews: RemoteViews? = null
+
+    override fun createRootEmittable() = RemoteViewsRoot(MaxComposeTreeDepth)
+
+    override suspend fun provideGlance(
+        context: Context,
+        setContent: SetContentFn,
+    ) {
+        val manager = context.appWidgetManager
+        val minSize = appWidgetMinSize(
+            context.resources.displayMetrics,
+            manager,
+            id.appWidgetId
+        )
+        options.value = initialOptions ?: manager.getAppWidgetOptions(id.appWidgetId)!!
+        glanceState.value =
+            configManager.getValue(context, PreferencesGlanceStateDefinition, key)
+
+        val scope = AppWidgetProviderScope { content: @Composable @GlanceComposable () -> Unit ->
+            setContent {
+                CompositionLocalProvider(
+                    LocalContext provides context,
+                    LocalGlanceId provides id,
+                    LocalAppWidgetOptions provides options.value,
+                    LocalState provides glanceState.value,
+                ) {
+                    ForEachSize(widget.sizeMode, minSize, content)
+                }
+            }
+        }
+
+        widget.apply {
+            scope.provideGlance(context, id)
+        }
+    }
+
+    override suspend fun processEmittableTree(
+        context: Context,
+        root: EmittableWithChildren
+    ) {
+        root as RemoteViewsRoot
+        val layoutConfig = LayoutConfiguration.load(context, id.appWidgetId)
+        try {
+            normalizeCompositionTree(root)
+            val rv = translateComposition(
+                context,
+                id.appWidgetId,
+                root,
+                layoutConfig,
+                layoutConfig.addLayout(root),
+                DpSize.Unspecified
+            )
+            context.appWidgetManager.updateAppWidget(id.appWidgetId, rv)
+            lastRemoteViews = rv
+        } catch (ex: CancellationException) {
+            // Nothing to do
+        } catch (throwable: Throwable) {
+            if (widget.errorUiLayout == 0) {
+                throw throwable
+            }
+            logException(throwable)
+            val rv = RemoteViews(context.packageName, widget.errorUiLayout)
+            context.appWidgetManager.updateAppWidget(id.appWidgetId, rv)
+            lastRemoteViews = rv
+        } finally {
+            layoutConfig.save()
+        }
+    }
+
+    override suspend fun processEvent(context: Context, event: Any) {
+        when (event) {
+            is UpdateGlanceState -> {
+                if (DEBUG) Log.i(TAG, "Received UpdateGlanceState event for session($key)")
+                glanceState.value =
+                    configManager.getValue(context, PreferencesGlanceStateDefinition, key)
+            }
+            is UpdateAppWidgetOptions -> {
+                if (DEBUG) {
+                    Log.i(
+                        TAG,
+                        "Received UpdateAppWidgetOptions(${event.newOptions}) event" +
+                            "for session($key)"
+                    )
+                }
+                options.value = event.newOptions
+            }
+            else -> {
+                throw IllegalArgumentException(
+                    "Sent unrecognized event type ${event.javaClass} to AppWidgetSession"
+                )
+            }
+        }
+    }
+
+    suspend fun updateGlance() {
+        sendEvent(UpdateGlanceState)
+    }
+
+    suspend fun updateAppWidgetOptions(newOptions: Bundle) {
+        sendEvent(UpdateAppWidgetOptions(newOptions))
+    }
+
+    // Action types that this session supports.
+    @VisibleForTesting
+    internal object UpdateGlanceState
+
+    @VisibleForTesting
+    internal class UpdateAppWidgetOptions(val newOptions: Bundle)
+
+    private val Context.appWidgetManager: AppWidgetManager
+        get() = this.getSystemService(Context.APPWIDGET_SERVICE) as AppWidgetManager
+}
+
+internal fun createUniqueRemoteUiName(appWidgetId: Int) = "appWidget-$appWidgetId"
+internal fun AppWidgetId.toSessionKey() = createUniqueRemoteUiName(appWidgetId)
+
+/**
+ * Maximum depth for a composition. Although there is no hard limit, this should avoid deep
+ * recursions, which would create [RemoteViews] too large to be sent.
+ */
+private const val MaxComposeTreeDepth = 50
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/AppWidgetUtils.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/AppWidgetUtils.kt
new file mode 100644
index 0000000..e9063f8a
--- /dev/null
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/AppWidgetUtils.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.appwidget
+
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProviderInfo
+import android.os.Bundle
+import android.util.DisplayMetrics
+import android.util.Log
+import android.util.SizeF
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import kotlin.math.ceil
+import kotlin.math.min
+
+// Retrieves the minimum size of an App Widget, as configured by the App Widget provider.
+internal fun appWidgetMinSize(
+    displayMetrics: DisplayMetrics,
+    appWidgetManager: AppWidgetManager,
+    appWidgetId: Int
+): DpSize {
+    val info = appWidgetManager.getAppWidgetInfo(appWidgetId) ?: return DpSize.Zero
+    val minWidth = min(
+        info.minWidth,
+        if (info.resizeMode and AppWidgetProviderInfo.RESIZE_HORIZONTAL != 0) {
+            info.minResizeWidth
+        } else {
+            Int.MAX_VALUE
+        }
+    )
+    val minHeight = min(
+        info.minHeight,
+        if (info.resizeMode and AppWidgetProviderInfo.RESIZE_VERTICAL != 0) {
+            info.minResizeHeight
+        } else {
+            Int.MAX_VALUE
+        }
+    )
+    return DpSize(minWidth.pixelsToDp(displayMetrics), minHeight.pixelsToDp(displayMetrics))
+}
+
+// Extract the sizes from the bundle
+@Suppress("DEPRECATION")
+internal fun Bundle.extractAllSizes(minSize: () -> DpSize): List<DpSize> {
+    val sizes = getParcelableArrayList<SizeF>(AppWidgetManager.OPTION_APPWIDGET_SIZES)
+    return if (sizes.isNullOrEmpty()) {
+        estimateSizes(minSize)
+    } else {
+        sizes.map { DpSize(it.width.dp, it.height.dp) }
+    }
+}
+
+// If the list of sizes is not available, estimate it from the min/max width and height.
+// We can assume that the min width and max height correspond to the portrait mode and the max
+// width / min height to the landscape mode.
+private fun Bundle.estimateSizes(minSize: () -> DpSize): List<DpSize> {
+    val minHeight = getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0)
+    val maxHeight = getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0)
+    val minWidth = getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0)
+    val maxWidth = getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0)
+    // If the min / max widths and heights are not specified, fall back to the unique mode,
+    // giving the minimum size the app widget may have.
+    if (minHeight == 0 || maxHeight == 0 || minWidth == 0 || maxWidth == 0) {
+        return listOf(minSize())
+    }
+    return listOf(DpSize(minWidth.dp, maxHeight.dp), DpSize(maxWidth.dp, minHeight.dp))
+}
+
+// Landscape is min height / max width
+private fun Bundle.extractLandscapeSize(): DpSize? {
+    val minHeight = getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0)
+    val maxWidth = getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0)
+    return if (minHeight == 0 || maxWidth == 0) null else DpSize(maxWidth.dp, minHeight.dp)
+}
+
+// Portrait is max height / min width
+private fun Bundle.extractPortraitSize(): DpSize? {
+    val maxHeight = getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0)
+    val minWidth = getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0)
+    return if (maxHeight == 0 || minWidth == 0) null else DpSize(minWidth.dp, maxHeight.dp)
+}
+
+internal fun Bundle.extractOrientationSizes() =
+    listOfNotNull(extractLandscapeSize(), extractPortraitSize())
+
+// True if the object fits in the given size.
+private infix fun DpSize.fitsIn(other: DpSize) =
+    (ceil(other.width.value) + 1 > width.value) &&
+        (ceil(other.height.value) + 1 > height.value)
+
+internal fun DpSize.toSizeF(): SizeF = SizeF(width.value, height.value)
+
+private fun squareDistance(widgetSize: DpSize, layoutSize: DpSize): Float {
+    val dw = widgetSize.width.value - layoutSize.width.value
+    val dh = widgetSize.height.value - layoutSize.height.value
+    return dw * dw + dh * dh
+}
+
+// Find the best size that fits in the available [widgetSize] or null if no layout fits.
+internal fun findBestSize(widgetSize: DpSize, layoutSizes: Collection<DpSize>): DpSize? =
+    layoutSizes.mapNotNull { layoutSize ->
+        if (layoutSize fitsIn widgetSize) {
+            layoutSize to squareDistance(widgetSize, layoutSize)
+        } else {
+            null
+        }
+    }.minByOrNull { it.second }?.first
+
+/**
+ * @return the minimum size as configured by the App Widget provider.
+ */
+internal fun AppWidgetProviderInfo.getMinSize(displayMetrics: DisplayMetrics): DpSize {
+    val minWidth = min(
+        minWidth,
+        if (resizeMode and AppWidgetProviderInfo.RESIZE_HORIZONTAL != 0) {
+            minResizeWidth
+        } else {
+            Int.MAX_VALUE
+        }
+    )
+    val minHeight = min(
+        minHeight,
+        if (resizeMode and AppWidgetProviderInfo.RESIZE_VERTICAL != 0) {
+            minResizeHeight
+        } else {
+            Int.MAX_VALUE
+        }
+    )
+    return DpSize(minWidth.pixelsToDp(displayMetrics), minHeight.pixelsToDp(displayMetrics))
+}
+
+internal fun Collection<DpSize>.sortedBySize() =
+    sortedWith(compareBy({ it.width.value * it.height.value }, { it.width.value }))
+
+internal fun logException(throwable: Throwable) {
+    Log.e(GlanceAppWidgetTag, "Error in Glance App Widget", throwable)
+}
\ No newline at end of file
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/CompositionLocals.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/CompositionLocals.kt
index 89529bc..45be98b 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/CompositionLocals.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/CompositionLocals.kt
@@ -18,7 +18,7 @@
 
 import android.os.Bundle
 import androidx.compose.runtime.ProvidableCompositionLocal
-import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.runtime.compositionLocalOf
 
 /**
  * Option Bundle accessible when generating an App Widget.
@@ -26,4 +26,4 @@
  * See [AppWidgetManager#getAppWidgetOptions] for details
  */
 val LocalAppWidgetOptions: ProvidableCompositionLocal<Bundle> =
-    staticCompositionLocalOf { error("No default app widget options") }
+    compositionLocalOf { Bundle() }
\ No newline at end of file
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/GlanceAppWidget.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/GlanceAppWidget.kt
index 51535cd..56938e9 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/GlanceAppWidget.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/GlanceAppWidget.kt
@@ -17,13 +17,10 @@
 package androidx.glance.appwidget
 
 import android.appwidget.AppWidgetManager
-import android.appwidget.AppWidgetProviderInfo
 import android.content.Context
 import android.os.Build
 import android.os.Bundle
-import android.util.DisplayMetrics
 import android.util.Log
-import android.util.SizeF
 import android.widget.RemoteViews
 import androidx.annotation.DoNotInline
 import androidx.annotation.LayoutRes
@@ -35,7 +32,6 @@
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.Recomposer
 import androidx.compose.ui.unit.DpSize
-import androidx.compose.ui.unit.dp
 import androidx.glance.Applier
 import androidx.glance.GlanceComposable
 import androidx.glance.GlanceId
@@ -44,11 +40,10 @@
 import androidx.glance.LocalSize
 import androidx.glance.LocalState
 import androidx.glance.appwidget.state.getAppWidgetState
+import androidx.glance.session.SessionManager
 import androidx.glance.state.GlanceState
 import androidx.glance.state.GlanceStateDefinition
 import androidx.glance.state.PreferencesGlanceStateDefinition
-import kotlin.math.ceil
-import kotlin.math.min
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
@@ -59,6 +54,15 @@
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 
+fun interface AppWidgetProviderScope {
+    /**
+     * Provides [content] to the Glance host, suspending until the Glance session is
+     * shut down. If this method is called concurrently with itself, the previous
+     * call will throw [CancellationException] and the new content will replace it.
+     */
+    suspend fun setContent(content: @Composable @GlanceComposable () -> Unit)
+}
+
 /**
  * Object handling the composition and the communication with [AppWidgetManager].
  *
@@ -71,10 +75,27 @@
  */
 abstract class GlanceAppWidget(
     @LayoutRes
-    private val errorUiLayout: Int = R.layout.glance_error_layout
+    internal val errorUiLayout: Int = R.layout.glance_error_layout,
 ) {
     /**
+     * Override this function to provide the Glance Composable.
+     *
+     * This is a good place to load any data needed to render the Composable. Use
+     * [AppWidgetProviderScope.setContent] to provide the Composable once it is ready.
+     *
+     * TODO(b/239747024) make abstract once Content() is removed.
+     */
+    @Suppress("UNUSED_PARAMETER")
+    open suspend fun AppWidgetProviderScope.provideGlance(
+        @Suppress("ContextFirst") context: Context,
+        glanceId: GlanceId,
+    ) {
+        setContent { Content() }
+    }
+
+    /**
      * Definition of the UI.
+     * TODO(b/239747024) remove and update any usage to the new provideGlance API.
      */
     @Composable
     @GlanceComposable
@@ -97,6 +118,9 @@
      */
     open suspend fun onDelete(context: Context, glanceId: GlanceId) {}
 
+    // TODO(b/239747024) remove once SessionManager is the default
+    open val sessionManager: SessionManager? = null
+
     /**
      * Triggers the composition of [Content] and sends the result to the [AppWidgetManager].
      */
@@ -114,6 +138,7 @@
      */
     internal suspend fun deleted(context: Context, appWidgetId: Int) {
         val glanceId = AppWidgetId(appWidgetId)
+        sessionManager?.closeSession(glanceId.toSessionKey())
         try {
             onDelete(context, glanceId)
         } catch (cancelled: CancellationException) {
@@ -136,6 +161,16 @@
         appWidgetId: Int,
         options: Bundle? = null,
     ) {
+        sessionManager?.let {
+            val glanceId = AppWidgetId(appWidgetId)
+            if (!it.isSessionRunning(context, glanceId.toSessionKey())) {
+                it.startSession(context, AppWidgetSession(this, glanceId, options))
+            } else {
+                val session = it.getSession(glanceId.toSessionKey()) as AppWidgetSession
+                session.updateGlance()
+            }
+            return
+        }
         safeRun(context, appWidgetManager, appWidgetId) {
             val opts = options ?: appWidgetManager.getAppWidgetOptions(appWidgetId)!!
             val state = stateDefinition?.let {
@@ -157,6 +192,16 @@
         appWidgetId: Int,
         options: Bundle
     ) {
+        sessionManager?.let { manager ->
+            val glanceId = AppWidgetId(appWidgetId)
+            if (!manager.isSessionRunning(context, glanceId.toSessionKey())) {
+                manager.startSession(context, AppWidgetSession(this, glanceId, options))
+            } else {
+                val session = manager.getSession(glanceId.toSessionKey()) as AppWidgetSession
+                session.updateAppWidgetOptions(options)
+            }
+            return
+        }
         // Note, on Android S, if the mode is `Responsive`, then all the sizes are specified from
         // the start and we don't need to update the AppWidget when the size changes.
         if (sizeMode is SizeMode.Exact ||
@@ -166,17 +211,6 @@
         }
     }
 
-    // Retrieves the minimum size of an App Widget, as configured by the App Widget provider.
-    @VisibleForTesting
-    internal fun appWidgetMinSize(
-        displayMetrics: DisplayMetrics,
-        appWidgetManager: AppWidgetManager,
-        appWidgetId: Int
-    ): DpSize {
-        val info = appWidgetManager.getAppWidgetInfo(appWidgetId) ?: return DpSize.Zero
-        return info.getMinSize(displayMetrics)
-    }
-
     // Trigger the composition of the View to create the RemoteViews.
     @VisibleForTesting
     internal suspend fun compose(
@@ -486,109 +520,8 @@
     }
 }
 
-internal fun createUniqueRemoteUiName(appWidgetId: Int) = "appWidget-$appWidgetId"
-
 internal data class AppWidgetId(val appWidgetId: Int) : GlanceId
 
-// Extract the sizes from the bundle
-@Suppress("DEPRECATION")
-internal fun Bundle.extractAllSizes(minSize: () -> DpSize): List<DpSize> {
-    val sizes = getParcelableArrayList<SizeF>(AppWidgetManager.OPTION_APPWIDGET_SIZES)
-    return if (sizes.isNullOrEmpty()) {
-        estimateSizes(minSize)
-    } else {
-        sizes.map { DpSize(it.width.dp, it.height.dp) }
-    }
-}
-
-// If the list of sizes is not available, estimate it from the min/max width and height.
-// We can assume that the min width and max height correspond to the portrait mode and the max
-// width / min height to the landscape mode.
-private fun Bundle.estimateSizes(minSize: () -> DpSize): List<DpSize> {
-    val minHeight = getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0)
-    val maxHeight = getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0)
-    val minWidth = getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0)
-    val maxWidth = getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0)
-    // If the min / max widths and heights are not specified, fall back to the unique mode,
-    // giving the minimum size the app widget may have.
-    if (minHeight == 0 || maxHeight == 0 || minWidth == 0 || maxWidth == 0) {
-        return listOf(minSize())
-    }
-    return listOf(DpSize(minWidth.dp, maxHeight.dp), DpSize(maxWidth.dp, minHeight.dp))
-}
-
-// Landscape is min height / max width
-private fun Bundle.extractLandscapeSize(): DpSize? {
-    val minHeight = getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0)
-    val maxWidth = getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0)
-    return if (minHeight == 0 || maxWidth == 0) null else DpSize(maxWidth.dp, minHeight.dp)
-}
-
-// Portrait is max height / min width
-private fun Bundle.extractPortraitSize(): DpSize? {
-    val maxHeight = getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0)
-    val minWidth = getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0)
-    return if (maxHeight == 0 || minWidth == 0) null else DpSize(minWidth.dp, maxHeight.dp)
-}
-
-private fun Bundle.extractOrientationSizes() =
-    listOfNotNull(extractLandscapeSize(), extractPortraitSize())
-
-// True if the object fits in the given size.
-private infix fun DpSize.fitsIn(other: DpSize) =
-    (ceil(other.width.value) + 1 > width.value) &&
-        (ceil(other.height.value) + 1 > height.value)
-
-@VisibleForTesting
-internal fun DpSize.toSizeF(): SizeF = SizeF(width.value, height.value)
-
-private fun squareDistance(widgetSize: DpSize, layoutSize: DpSize): Float {
-    val dw = widgetSize.width.value - layoutSize.width.value
-    val dh = widgetSize.height.value - layoutSize.height.value
-    return dw * dw + dh * dh
-}
-
-// Find the best size that fits in the available [widgetSize] or null if no layout fits.
-@VisibleForTesting
-internal fun findBestSize(widgetSize: DpSize, layoutSizes: Collection<DpSize>): DpSize? =
-    layoutSizes.mapNotNull { layoutSize ->
-        if (layoutSize fitsIn widgetSize) {
-            layoutSize to squareDistance(widgetSize, layoutSize)
-        } else {
-            null
-        }
-    }.minByOrNull { it.second }?.first
-
-/**
- * @return the minimum size as configured by the App Widget provider.
- */
-internal fun AppWidgetProviderInfo.getMinSize(displayMetrics: DisplayMetrics): DpSize {
-    val minWidth = min(
-        minWidth,
-        if (resizeMode and AppWidgetProviderInfo.RESIZE_HORIZONTAL != 0) {
-            minResizeWidth
-        } else {
-            Int.MAX_VALUE
-        }
-    )
-    val minHeight = min(
-        minHeight,
-        if (resizeMode and AppWidgetProviderInfo.RESIZE_VERTICAL != 0) {
-            minResizeHeight
-        } else {
-            Int.MAX_VALUE
-        }
-    )
-    return DpSize(minWidth.pixelsToDp(displayMetrics), minHeight.pixelsToDp(displayMetrics))
-}
-
-private fun Collection<DpSize>.sortedBySize() =
-    sortedWith(compareBy({ it.width.value * it.height.value }, { it.width.value }))
-
-internal fun logException(throwable: Throwable) {
-    Log.e(GlanceAppWidgetTag, "Error in Glance App Widget", throwable)
-}
-
 /** Update all App Widgets managed by the [GlanceAppWidget] class. */
 suspend fun GlanceAppWidget.updateAll(@Suppress("ContextFirst") context: Context) {
     val manager = GlanceAppWidgetManager(context)
@@ -609,4 +542,4 @@
         val state = getAppWidgetState(context, stateDef, glanceId) as State
         if (predicate(state)) update(context, glanceId)
     }
-}
\ No newline at end of file
+}
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/NormalizeCompositionTree.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/NormalizeCompositionTree.kt
index 50f937e..a191446 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/NormalizeCompositionTree.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/NormalizeCompositionTree.kt
@@ -52,8 +52,28 @@
     }
 }
 
+/**
+ * Ensure that [container] has only one direct child.
+ *
+ * If [container] has multiple children, wrap them in an [EmittableBox] and make that the only child
+ * of container. If [container] contains only children of type [EmittableSizeBox], then we will make
+ * sure each of the [EmittableSizeBox]es has one child by wrapping their children in an
+ * [EmittableBox].
+ */
 private fun coerceToOneChild(container: EmittableWithChildren) {
-    if (container.children.size == 1) return
+    if (container.children.isNotEmpty() && container.children.all { it is EmittableSizeBox }) {
+        for (item in container.children) {
+            item as EmittableSizeBox
+            if (item.children.size == 1) continue
+            val box = EmittableBox()
+            box.children += item.children
+            item.children.clear()
+            item.children += box
+        }
+        return
+    } else if (container.children.size == 1) {
+        return
+    }
     val box = EmittableBox()
     box.children += container.children
     container.children.clear()
@@ -117,10 +137,9 @@
  * convert the target emittable to an [EmittableText]
  */
 private fun Emittable.transformBackgroundImageAndActionRipple(): Emittable {
-    // EmittableLazyListItem is a wrapper for its immediate only child,
-    // and does not get translated to its own element. We will transform
-    // the child instead.
-    if (this is EmittableLazyListItem) return this
+    // EmittableLazyListItem and EmittableSizeBox are wrappers for their immediate only child,
+    // and do not get translated to their own element. We will transform their child instead.
+    if (this is EmittableLazyListItem || this is EmittableSizeBox) return this
 
     var target = this
 
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/RemoteViewsTranslator.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/RemoteViewsTranslator.kt
index b347e36..9f0ec001 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/RemoteViewsTranslator.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/RemoteViewsTranslator.kt
@@ -19,6 +19,7 @@
 import android.content.Context
 import android.os.Build
 import android.util.Log
+import android.util.SizeF
 import android.view.Gravity
 import android.view.View
 import android.widget.RemoteViews
@@ -87,22 +88,59 @@
     get() = forceRtl
         ?: (resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL)
 
+@RequiresApi(Build.VERSION_CODES.S)
+private object Api31Impl {
+    @DoNotInline
+    fun createRemoteViews(sizeMap: Map<SizeF, RemoteViews>): RemoteViews = RemoteViews(sizeMap)
+}
+
 internal fun translateComposition(
     translationContext: TranslationContext,
     children: List<Emittable>,
     rootViewIndex: Int
 ): RemoteViews {
-    require(children.size == 1) {
-        "The root of the tree must have exactly one child. " +
-            "The normalization of the composition tree failed."
+    if (children.all { it is EmittableSizeBox }) {
+        // If the children of root are all EmittableSizeBoxes, then we must translate each
+        // EmittableSizeBox into a distinct RemoteViews object. Then, we combine them into one
+        // multi-sized RemoteViews (a RemoteViews that contains either landscape & portrait RVs or
+        // multiple RVs mapped by size).
+        val sizeMode = (children.first() as EmittableSizeBox).sizeMode
+        val views = children.map { child ->
+            val size = (child as EmittableSizeBox).size
+            val remoteViewsInfo = createRootView(translationContext, child.modifier, rootViewIndex)
+            val rv = remoteViewsInfo.remoteViews.apply {
+                translateChild(translationContext.forRoot(root = remoteViewsInfo), child)
+            }
+            size.toSizeF() to rv
+        }
+        return when (sizeMode) {
+            is SizeMode.Single -> views.single().second
+            is SizeMode.Responsive, SizeMode.Exact -> {
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+                    Api31Impl.createRemoteViews(views.toMap())
+                } else {
+                    require(views.size == 1 || views.size == 2)
+                    combineLandscapeAndPortrait(views.map { it.second })
+                }
+            }
+        }
+    } else {
+        return children.single().let { child ->
+            val remoteViewsInfo = createRootView(translationContext, child.modifier, rootViewIndex)
+            remoteViewsInfo.remoteViews.apply {
+                translateChild(translationContext.forRoot(root = remoteViewsInfo), child)
+            }
+        }
     }
-    val child = children.first()
-    val remoteViewsInfo = createRootView(translationContext, child.modifier, rootViewIndex)
-    val rv = remoteViewsInfo.remoteViews
-    rv.translateChild(translationContext.forRoot(root = remoteViewsInfo), child)
-    return rv
 }
 
+private fun combineLandscapeAndPortrait(views: List<RemoteViews>): RemoteViews =
+    when (views.size) {
+        2 -> RemoteViews(views[0], views[1])
+        1 -> views[0]
+        else -> throw IllegalArgumentException("There must be between 1 and 2 views.")
+    }
+
 internal data class TranslationContext(
     val context: Context,
     val appWidgetId: Int,
@@ -126,6 +164,10 @@
 
     fun forRoot(root: RemoteViewsInfo): TranslationContext =
         forChild(pos = 0, parent = root.view)
+            .copy(
+                isBackgroundSpecified = AtomicBoolean(false),
+                lastViewId = AtomicInteger(0),
+            )
 
     fun resetViewId(newViewId: Int = 0) = copy(lastViewId = AtomicInteger(newViewId))
 
@@ -172,6 +214,7 @@
           translateEmittableLazyVerticalGridListItem(translationContext, element)
         }
         is EmittableRadioButton -> translateEmittableRadioButton(translationContext, element)
+        is EmittableSizeBox -> translateEmittableSizeBox(translationContext, element)
         else -> {
             throw IllegalArgumentException(
                 "Unknown element type ${element.javaClass.canonicalName}"
@@ -180,6 +223,17 @@
     }
 }
 
+internal fun RemoteViews.translateEmittableSizeBox(
+    translationContext: TranslationContext,
+    element: EmittableSizeBox
+) {
+    require(element.children.size <= 1) {
+        "Size boxes can only have at most one child ${element.children.size}. " +
+            "The normalization of the composition tree failed."
+    }
+    element.children.firstOrNull()?.let { translateChild(translationContext, it) }
+}
+
 internal fun remoteViews(translationContext: TranslationContext, @LayoutRes layoutId: Int) =
     RemoteViews(translationContext.context.packageName, layoutId)
 
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/SizeBox.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/SizeBox.kt
new file mode 100644
index 0000000..8425999
--- /dev/null
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/SizeBox.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.appwidget
+
+import android.os.Build
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.unit.DpSize
+import androidx.glance.Emittable
+import androidx.glance.EmittableWithChildren
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceNode
+import androidx.glance.LocalSize
+import androidx.glance.layout.fillMaxSize
+
+/**
+ * A marker for the translator that indicates that this [EmittableSizeBox] and its children should
+ * be translated into a distinct [android.widget.RemoteViews] object.
+ *
+ * EmittableSizeBox is only functional when it is a direct child of the root [RemoteViewsRoot].
+ * Multiple EmittableSizeBox children will each be translated into a distinct RemoteViews, then
+ * combined into one multi-sized RemoteViews.
+ */
+internal class EmittableSizeBox : EmittableWithChildren() {
+    override var modifier: GlanceModifier
+        get() = children.singleOrNull()?.modifier
+            ?: GlanceModifier.fillMaxSize()
+        set(_) {
+            throw IllegalAccessError("You cannot set the modifier of an EmittableSizeBox")
+        }
+    var size: DpSize = DpSize.Unspecified
+    var sizeMode: SizeMode = SizeMode.Single
+
+    override fun copy(): Emittable = EmittableSizeBox().also {
+        it.size = size
+        it.sizeMode = sizeMode
+        it.children.addAll(children.map { it.copy() })
+    }
+
+    override fun toString(): String = "EmittableSizeBox(" +
+        "size=$size, " +
+        "sizeMode=$sizeMode, " +
+        "children=[\n${childrenToString()}\n]" +
+        ")"
+}
+
+/**
+ * This composable emits a marker that lets the translator know that this [SizeBox] and its children
+ * should be translated into a distinct RemoteViews that is then combined with its siblings to form
+ * a multi-sized RemoteViews.
+ *
+ * This should not be used directly. The correct SizeBoxes can be generated with [ForEachSize].
+ */
+@Composable
+internal fun SizeBox(
+    size: DpSize,
+    sizeMode: SizeMode,
+    content: @Composable () -> Unit
+) {
+    CompositionLocalProvider(LocalSize provides size) {
+        GlanceNode(
+            factory = ::EmittableSizeBox,
+            update = {
+                this.set(size) { this.size = it }
+                this.set(sizeMode) { this.sizeMode = it }
+            },
+            content = content
+        )
+    }
+}
+
+/**
+ * For each size indicated by [sizeMode], run [content] with a [SizeBox] set to the corresponding
+ * size.
+ */
+@Composable
+internal fun ForEachSize(
+    sizeMode: SizeMode,
+    minSize: DpSize,
+    content: @Composable () -> Unit
+) {
+    val sizes = when (sizeMode) {
+        is SizeMode.Single -> listOf(minSize)
+        is SizeMode.Exact -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            LocalAppWidgetOptions.current.extractAllSizes { minSize }
+        } else {
+            LocalAppWidgetOptions.current.extractOrientationSizes()
+                .ifEmpty { listOf(minSize) }
+        }
+        is SizeMode.Responsive -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            sizeMode.sizes
+        } else {
+            val smallestSize = sizeMode.sizes.sortedBySize()[0]
+            LocalAppWidgetOptions.current.extractOrientationSizes()
+                .mapNotNull { findBestSize(it, sizeMode.sizes) }
+                .ifEmpty { listOf(smallestSize, smallestSize) }
+        }
+    }
+    sizes.map { size ->
+        SizeBox(size, sizeMode, content)
+    }
+}
diff --git a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/WidgetLayout.kt b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/WidgetLayout.kt
index 60e39f3..91255d2 100644
--- a/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/WidgetLayout.kt
+++ b/glance/glance-appwidget/src/androidMain/kotlin/androidx/glance/appwidget/WidgetLayout.kt
@@ -338,6 +338,7 @@
         is EmittableLazyVerticalGridListItem -> LayoutProto.LayoutType.LIST_ITEM
         is RemoteViewsRoot -> LayoutProto.LayoutType.REMOTE_VIEWS_ROOT
         is EmittableRadioButton -> LayoutProto.LayoutType.RADIO_BUTTON
+        is EmittableSizeBox -> LayoutProto.LayoutType.SIZE_BOX
         else ->
             throw IllegalArgumentException("Unknown element type ${this.javaClass.canonicalName}")
     }
diff --git a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/AppWidgetSessionTest.kt b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/AppWidgetSessionTest.kt
new file mode 100644
index 0000000..616ba95
--- /dev/null
+++ b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/AppWidgetSessionTest.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.appwidget
+
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProviderInfo
+import android.content.Context
+import android.widget.TextView
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.glance.Emittable
+import androidx.glance.GlanceModifier
+import androidx.glance.state.GlanceStateDefinition
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import androidx.glance.state.ConfigManager
+import androidx.glance.text.EmittableText
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertIs
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class AppWidgetSessionTest {
+
+    private val id = AppWidgetId(123)
+    private val widget = SampleGlanceAppWidget {}
+    private val context = ApplicationProvider.getApplicationContext<Context>()
+    private val defaultOptions =
+        optionsBundleOf(listOf(DpSize(100.dp, 50.dp), DpSize(50.dp, 100.dp)))
+    private val testState = TestGlanceState()
+    private val session = AppWidgetSession(widget, id, defaultOptions, testState)
+
+    @Before
+    fun setUp() {
+        val appWidgetManager = Shadows.shadowOf(
+            context.getSystemService(Context.APPWIDGET_SERVICE) as AppWidgetManager
+        )
+        appWidgetManager.addBoundWidget(id.appWidgetId, AppWidgetProviderInfo())
+    }
+
+    @Test
+    fun createRootEmittable() = runTest {
+        assertIs<RemoteViewsRoot>(session.createRootEmittable())
+    }
+
+    @Test
+    fun provideGlanceCallsSetContent() = runTest {
+        var wasCalled = false
+        session.provideGlance(context) {
+            wasCalled = true
+        }
+        assertThat(wasCalled).isTrue()
+    }
+
+    @Test
+    fun processEmittableTree() = runTest {
+        val root = RemoteViewsRoot(maxDepth = 1).apply {
+            children += EmittableText().apply {
+                text = "hello"
+            }
+        }
+
+        session.processEmittableTree(context, root)
+        context.applyRemoteViews(session.lastRemoteViews!!).let {
+            val text = assertIs<TextView>(it)
+            assertThat(text.text).isEqualTo("hello")
+        }
+    }
+
+    @Test
+    fun processEmittableTree_catchesException() = runTest {
+        val root = RemoteViewsRoot(maxDepth = 1).apply {
+            children += object : Emittable {
+                override var modifier: GlanceModifier = GlanceModifier
+                override fun copy() = this
+            }
+        }
+
+        session.processEmittableTree(context, root)
+        assertThat(session.lastRemoteViews!!.layoutId).isEqualTo(widget.errorUiLayout)
+    }
+
+    @Test
+    fun processEvent_unknownAction() = runTest {
+        assertThrows(IllegalArgumentException::class.java) {
+            runBlocking { session.processEvent(context, Any()) }
+        }
+    }
+
+    @Test
+    fun processEvent_updateGlance() = runTest {
+        session.processEvent(context, AppWidgetSession.UpdateGlanceState)
+        assertThat(testState.getValueCalls).containsExactly(id.toSessionKey())
+    }
+
+    @Test
+    fun updateGlance() = runTest {
+        session.updateGlance()
+        session.receiveEvents(context) {
+            [email protected] { session.close() }
+        }
+        assertThat(testState.getValueCalls).containsExactly(id.toSessionKey())
+    }
+
+    private class SampleGlanceAppWidget(
+        val ui: @Composable () -> Unit,
+    ) : GlanceAppWidget() {
+        @Composable
+        override fun Content() {
+            ui()
+        }
+    }
+
+    private class TestGlanceState : ConfigManager {
+
+        val getValueCalls = mutableListOf<String>()
+        @Suppress("UNCHECKED_CAST")
+        override suspend fun <T> getValue(
+            context: Context,
+            definition: GlanceStateDefinition<T>,
+            fileKey: String
+        ): T {
+            assertIs<PreferencesGlanceStateDefinition>(definition)
+            getValueCalls.add(fileKey)
+            return definition.getDataStore(context, fileKey).also {
+                definition.getLocation(context, fileKey).delete()
+            }.data.first() as T
+        }
+
+        override suspend fun <T> updateValue(
+            context: Context,
+            definition: GlanceStateDefinition<T>,
+            fileKey: String,
+            updateBlock: suspend (T) -> T
+        ): T {
+            TODO("Not yet implemented")
+        }
+
+        override suspend fun deleteStore(
+            context: Context,
+            definition: GlanceStateDefinition<*>,
+            fileKey: String
+        ) {
+            TODO("Not yet implemented")
+        }
+    }
+}
diff --git a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/GlanceAppWidgetTest.kt b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/GlanceAppWidgetTest.kt
index 1aa10a4..ca542cd 100644
--- a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/GlanceAppWidgetTest.kt
+++ b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/GlanceAppWidgetTest.kt
@@ -316,7 +316,6 @@
 
     @Test
     fun appWidgetMinSize_noResizing() {
-        val composer = SampleGlanceAppWidget { }
         val appWidgetManager = mock<AppWidgetManager> {
             on { getAppWidgetInfo(1) }.thenReturn(
                 appWidgetProviderInfo {
@@ -329,13 +328,12 @@
             )
         }
 
-        assertThat(composer.appWidgetMinSize(displayMetrics, appWidgetManager, 1))
+        assertThat(appWidgetMinSize(displayMetrics, appWidgetManager, 1))
             .isEqualTo(DpSize(50.dp, 50.dp))
     }
 
     @Test
     fun appWidgetMinSize_horizontalResizing() {
-        val composer = SampleGlanceAppWidget { }
         val appWidgetManager = mock<AppWidgetManager> {
             on { getAppWidgetInfo(1) }.thenReturn(
                 appWidgetProviderInfo {
@@ -348,13 +346,12 @@
             )
         }
 
-        assertThat(composer.appWidgetMinSize(displayMetrics, appWidgetManager, 1))
+        assertThat(appWidgetMinSize(displayMetrics, appWidgetManager, 1))
             .isEqualTo(DpSize(40.dp, 50.dp))
     }
 
     @Test
     fun appWidgetMinSize_verticalResizing() {
-        val composer = SampleGlanceAppWidget { }
         val appWidgetManager = mock<AppWidgetManager> {
             on { getAppWidgetInfo(1) }.thenReturn(
                 appWidgetProviderInfo {
@@ -367,13 +364,12 @@
             )
         }
 
-        assertThat(composer.appWidgetMinSize(displayMetrics, appWidgetManager, 1))
+        assertThat(appWidgetMinSize(displayMetrics, appWidgetManager, 1))
             .isEqualTo(DpSize(50.dp, 30.dp))
     }
 
     @Test
     fun appWidgetMinSize_bigMinResize() {
-        val composer = SampleGlanceAppWidget { }
         val appWidgetManager = mock<AppWidgetManager> {
             on { getAppWidgetInfo(1) }.thenReturn(
                 appWidgetProviderInfo {
@@ -386,7 +382,7 @@
             )
         }
 
-        assertThat(composer.appWidgetMinSize(displayMetrics, appWidgetManager, 1))
+        assertThat(appWidgetMinSize(displayMetrics, appWidgetManager, 1))
             .isEqualTo(DpSize(50.dp, 50.dp))
     }
 
@@ -452,30 +448,6 @@
         )
     }
 
-    private fun optionsBundleOf(sizes: List<DpSize>): Bundle {
-        require(sizes.isNotEmpty()) { "There must be at least one size" }
-        val (minSize, maxSize) = sizes.fold(sizes[0] to sizes[0]) { acc, s ->
-            DpSize(min(acc.first.width, s.width), min(acc.first.height, s.height)) to
-                DpSize(max(acc.second.width, s.width), max(acc.second.height, s.height))
-        }
-        return Bundle().apply {
-            putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, minSize.width.value.toInt())
-            putInt(
-                AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT,
-                minSize.height.value.toInt()
-            )
-            putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, maxSize.width.value.toInt())
-            putInt(
-                AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT,
-                maxSize.height.value.toInt()
-            )
-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-                val sizeList = sizes.map { it.toSizeF() }.toArrayList()
-                putParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, sizeList)
-            }
-        }
-    }
-
     private fun createPortraitContext() =
         makeOrientationContext(Configuration.ORIENTATION_PORTRAIT)
 
@@ -498,3 +470,27 @@
         }
     }
 }
+
+internal fun optionsBundleOf(sizes: List<DpSize>): Bundle {
+    require(sizes.isNotEmpty()) { "There must be at least one size" }
+    val (minSize, maxSize) = sizes.fold(sizes[0] to sizes[0]) { acc, s ->
+        DpSize(min(acc.first.width, s.width), min(acc.first.height, s.height)) to
+            DpSize(max(acc.second.width, s.width), max(acc.second.height, s.height))
+    }
+    return Bundle().apply {
+        putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, minSize.width.value.toInt())
+        putInt(
+            AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT,
+            minSize.height.value.toInt()
+        )
+        putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, maxSize.width.value.toInt())
+        putInt(
+            AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT,
+            maxSize.height.value.toInt()
+        )
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            val sizeList = sizes.map { it.toSizeF() }.toArrayList()
+            putParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, sizeList)
+        }
+    }
+}
diff --git a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/SizeBoxTest.kt b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/SizeBoxTest.kt
new file mode 100644
index 0000000..ba438eb
--- /dev/null
+++ b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/SizeBoxTest.kt
@@ -0,0 +1,270 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.appwidget
+
+import android.os.Bundle
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.glance.LocalSize
+import androidx.glance.text.EmittableText
+import androidx.glance.text.Text
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertIs
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class SizeBoxTest {
+    private val minSize = DpSize(50.dp, 100.dp)
+
+    @Test
+    fun sizeModeSingle() = runTest {
+        val root = runTestingComposition {
+            ForEachSize(SizeMode.Single, minSize) {
+                val size = LocalSize.current
+                Text("${size.width} x ${size.height}")
+            }
+        }
+        val sizeBox = assertIs<EmittableSizeBox>(root.children.single())
+        assertThat(sizeBox.size).isEqualTo(minSize)
+        assertThat(sizeBox.sizeMode).isEqualTo(SizeMode.Single)
+        val text = assertIs<EmittableText>(sizeBox.children.single())
+        assertThat(text.text).isEqualTo("50.0.dp x 100.0.dp")
+    }
+
+    @Config(sdk = [30])
+    @Test
+    fun sizeModeExactPreS() = runTest {
+        val options = optionsBundleOf(
+            listOf(
+                DpSize(100.dp, 50.dp),
+                DpSize(50.dp, 100.dp),
+                DpSize(75.dp, 75.dp),
+            )
+        )
+        val root = runTestingComposition {
+            CompositionLocalProvider(LocalAppWidgetOptions provides options) {
+                ForEachSize(SizeMode.Exact, minSize) {
+                    val size = LocalSize.current
+                    Text("${size.width} x ${size.height}")
+                }
+            }
+        }
+        // On Pre-S, since AppWidgetManager.OPTION_APPWIDGET_SIZES isn't available, we use
+        // AppWidgetManager.OPTION_APPWIDGET_{MIN,MAX}_{HEIGHT,WIDTH} to find the landscape and
+        // portrait sizes.
+        assertThat(root.children).hasSize(2)
+        val sizeBox1 = assertIs<EmittableSizeBox>(root.children[0])
+        assertThat(sizeBox1.size).isEqualTo(DpSize(100.dp, 50.dp))
+        assertThat(sizeBox1.sizeMode).isEqualTo(SizeMode.Exact)
+        val text1 = assertIs<EmittableText>(sizeBox1.children.single())
+        assertThat(text1.text).isEqualTo("100.0.dp x 50.0.dp")
+
+        val sizeBox2 = assertIs<EmittableSizeBox>(root.children[1])
+        assertThat(sizeBox2.size).isEqualTo(DpSize(50.dp, 100.dp))
+        assertThat(sizeBox2.sizeMode).isEqualTo(SizeMode.Exact)
+        val text2 = assertIs<EmittableText>(sizeBox2.children.single())
+        assertThat(text2.text).isEqualTo("50.0.dp x 100.0.dp")
+    }
+
+    @Config(sdk = [31])
+    @Test
+    fun sizeModeExactS() = runTest {
+        val options = optionsBundleOf(
+            listOf(
+                DpSize(100.dp, 50.dp),
+                DpSize(50.dp, 100.dp),
+                DpSize(75.dp, 75.dp),
+            )
+        )
+        val root = runTestingComposition {
+            CompositionLocalProvider(LocalAppWidgetOptions provides options) {
+                ForEachSize(SizeMode.Exact, minSize) {
+                    val size = LocalSize.current
+                    Text("${size.width} x ${size.height}")
+                }
+            }
+        }
+        // On S+, AppWidgetManager.OPTION_APPWIDGET_SIZES is available so we create a SizeBox for
+        // each size.
+        assertThat(root.children).hasSize(3)
+        val sizeBox1 = assertIs<EmittableSizeBox>(root.children[0])
+        assertThat(sizeBox1.size).isEqualTo(DpSize(100.dp, 50.dp))
+        assertThat(sizeBox1.sizeMode).isEqualTo(SizeMode.Exact)
+        val text1 = assertIs<EmittableText>(sizeBox1.children.single())
+        assertThat(text1.text).isEqualTo("100.0.dp x 50.0.dp")
+
+        val sizeBox2 = assertIs<EmittableSizeBox>(root.children[1])
+        assertThat(sizeBox2.size).isEqualTo(DpSize(50.dp, 100.dp))
+        assertThat(sizeBox2.sizeMode).isEqualTo(SizeMode.Exact)
+        val text2 = assertIs<EmittableText>(sizeBox2.children.single())
+        assertThat(text2.text).isEqualTo("50.0.dp x 100.0.dp")
+
+        val sizeBox3 = assertIs<EmittableSizeBox>(root.children[2])
+        assertThat(sizeBox3.size).isEqualTo(DpSize(75.dp, 75.dp))
+        assertThat(sizeBox3.sizeMode).isEqualTo(SizeMode.Exact)
+        val text3 = assertIs<EmittableText>(sizeBox3.children.single())
+        assertThat(text3.text).isEqualTo("75.0.dp x 75.0.dp")
+    }
+
+    @Test
+    fun sizeModeExactEmptySizes() = runTest {
+        val options = Bundle()
+        val root = runTestingComposition {
+            CompositionLocalProvider(LocalAppWidgetOptions provides options) {
+                ForEachSize(SizeMode.Exact, minSize) {
+                    val size = LocalSize.current
+                    Text("${size.width} x ${size.height}")
+                }
+            }
+        }
+        // When no sizes are available, a single SizeBox for minSize should be created
+        assertThat(root.children).hasSize(1)
+        val sizeBox1 = assertIs<EmittableSizeBox>(root.children[0])
+        assertThat(sizeBox1.size).isEqualTo(minSize)
+        assertThat(sizeBox1.sizeMode).isEqualTo(SizeMode.Exact)
+        val text1 = assertIs<EmittableText>(sizeBox1.children.single())
+        assertThat(text1.text).isEqualTo("50.0.dp x 100.0.dp")
+    }
+
+    @Config(sdk = [30])
+    @Test
+    fun sizeModeResponsivePreS() = runTest {
+        val options = optionsBundleOf(
+            listOf(
+                DpSize(100.dp, 50.dp),
+                DpSize(50.dp, 100.dp),
+                DpSize(75.dp, 75.dp),
+            )
+        )
+        val sizeMode = SizeMode.Responsive(
+            setOf(
+                DpSize(99.dp, 49.dp),
+                DpSize(49.dp, 99.dp),
+                DpSize(75.dp, 75.dp),
+            )
+        )
+        val root = runTestingComposition {
+            CompositionLocalProvider(LocalAppWidgetOptions provides options) {
+                ForEachSize(sizeMode, minSize) {
+                    val size = LocalSize.current
+                    Text("${size.width} x ${size.height}")
+                }
+            }
+        }
+        // On Pre-S, we extract orientation sizes from
+        // AppWidgetManager.OPTION_APPWIDGET_{MIN,MAX}_{HEIGHT,WIDTH} to find the landscape and
+        // portrait sizes, then find which responsive size fits best for each.
+        assertThat(root.children).hasSize(2)
+        val sizeBox1 = assertIs<EmittableSizeBox>(root.children[0])
+        assertThat(sizeBox1.size).isEqualTo(DpSize(99.dp, 49.dp))
+        assertThat(sizeBox1.sizeMode).isEqualTo(sizeMode)
+        val text1 = assertIs<EmittableText>(sizeBox1.children.single())
+        assertThat(text1.text).isEqualTo("99.0.dp x 49.0.dp")
+
+        val sizeBox2 = assertIs<EmittableSizeBox>(root.children[1])
+        assertThat(sizeBox2.size).isEqualTo(DpSize(49.dp, 99.dp))
+        assertThat(sizeBox2.sizeMode).isEqualTo(sizeMode)
+        val text2 = assertIs<EmittableText>(sizeBox2.children.single())
+        assertThat(text2.text).isEqualTo("49.0.dp x 99.0.dp")
+    }
+
+    @Config(sdk = [30])
+    @Test
+    fun sizeModeResponsiveUseSmallestSize() = runTest {
+        val options = optionsBundleOf(
+            listOf(
+                DpSize(100.dp, 50.dp),
+                DpSize(50.dp, 100.dp),
+            )
+        )
+        val sizeMode = SizeMode.Responsive(
+            setOf(
+                DpSize(200.dp, 200.dp),
+                DpSize(300.dp, 300.dp),
+                DpSize(75.dp, 75.dp),
+            )
+        )
+        val root = runTestingComposition {
+            CompositionLocalProvider(LocalAppWidgetOptions provides options) {
+                ForEachSize(sizeMode, minSize) {
+                    val size = LocalSize.current
+                    Text("${size.width} x ${size.height}")
+                }
+            }
+        }
+        // On Pre-S, we extract orientation sizes from
+        // AppWidgetManager.OPTION_APPWIDGET_{MIN,MAX}_{HEIGHT,WIDTH} to find the landscape and
+        // portrait sizes, then find which responsive size fits best for each. If none fits, then we
+        // use the smallest size for both landscape and portrait.
+        assertThat(root.children).hasSize(2)
+        val sizeBox1 = assertIs<EmittableSizeBox>(root.children[0])
+        assertThat(sizeBox1.size).isEqualTo(DpSize(75.dp, 75.dp))
+        assertThat(sizeBox1.sizeMode).isEqualTo(sizeMode)
+        val text1 = assertIs<EmittableText>(sizeBox1.children.single())
+        assertThat(text1.text).isEqualTo("75.0.dp x 75.0.dp")
+
+        val sizeBox2 = assertIs<EmittableSizeBox>(root.children[1])
+        assertThat(sizeBox2.size).isEqualTo(DpSize(75.dp, 75.dp))
+        assertThat(sizeBox2.sizeMode).isEqualTo(sizeMode)
+        val text2 = assertIs<EmittableText>(sizeBox2.children.single())
+        assertThat(text2.text).isEqualTo("75.0.dp x 75.0.dp")
+    }
+
+    @Config(sdk = [31])
+    @Test
+    fun sizeModeResponsiveS() = runTest {
+        val sizeMode = SizeMode.Responsive(
+            setOf(
+                DpSize(100.dp, 50.dp),
+                DpSize(50.dp, 100.dp),
+                DpSize(75.dp, 75.dp),
+            )
+        )
+        val root = runTestingComposition {
+            ForEachSize(sizeMode, minSize) {
+                val size = LocalSize.current
+                Text("${size.width} x ${size.height}")
+            }
+        }
+        // On S, we create a SizeBox for each given size.
+        assertThat(root.children).hasSize(3)
+        val sizeBox1 = assertIs<EmittableSizeBox>(root.children[0])
+        assertThat(sizeBox1.size).isEqualTo(DpSize(100.dp, 50.dp))
+        assertThat(sizeBox1.sizeMode).isEqualTo(sizeMode)
+        val text1 = assertIs<EmittableText>(sizeBox1.children.single())
+        assertThat(text1.text).isEqualTo("100.0.dp x 50.0.dp")
+
+        val sizeBox2 = assertIs<EmittableSizeBox>(root.children[1])
+        assertThat(sizeBox2.size).isEqualTo(DpSize(50.dp, 100.dp))
+        assertThat(sizeBox2.sizeMode).isEqualTo(sizeMode)
+        val text2 = assertIs<EmittableText>(sizeBox2.children.single())
+        assertThat(text2.text).isEqualTo("50.0.dp x 100.0.dp")
+
+        val sizeBox3 = assertIs<EmittableSizeBox>(root.children[2])
+        assertThat(sizeBox3.size).isEqualTo(DpSize(75.dp, 75.dp))
+        assertThat(sizeBox3.sizeMode).isEqualTo(sizeMode)
+        val text3 = assertIs<EmittableText>(sizeBox3.children.single())
+        assertThat(text3.text).isEqualTo("75.0.dp x 75.0.dp")
+    }
+}
\ No newline at end of file
diff --git a/glance/glance/src/androidMain/kotlin/androidx/glance/CompositionLocals.kt b/glance/glance/src/androidMain/kotlin/androidx/glance/CompositionLocals.kt
index 39a8849..f563295 100644
--- a/glance/glance/src/androidMain/kotlin/androidx/glance/CompositionLocals.kt
+++ b/glance/glance/src/androidMain/kotlin/androidx/glance/CompositionLocals.kt
@@ -19,7 +19,6 @@
 import android.content.Context
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.ProvidableCompositionLocal
-import androidx.compose.runtime.State
 import androidx.compose.runtime.compositionLocalOf
 import androidx.compose.runtime.staticCompositionLocalOf
 import androidx.compose.ui.unit.DpSize
@@ -59,12 +58,7 @@
  * @return the current store of the provided type [T]
  */
 @Composable
-inline fun <reified T> currentState(): T = LocalState.current.let {
-    when (it) {
-        is State<*> -> it.value as T
-        else -> it as T
-    }
-}
+inline fun <reified T> currentState(): T = LocalState.current as T
 
 /**
  * Retrieves the current [Preferences] value of the provided [Preferences.Key] from the current
diff --git a/glance/glance/src/androidMain/kotlin/androidx/glance/state/GlanceStateDefinition.kt b/glance/glance/src/androidMain/kotlin/androidx/glance/state/GlanceStateDefinition.kt
index c1f265b..9b7d161 100644
--- a/glance/glance/src/androidMain/kotlin/androidx/glance/state/GlanceStateDefinition.kt
+++ b/glance/glance/src/androidMain/kotlin/androidx/glance/state/GlanceStateDefinition.kt
@@ -54,14 +54,13 @@
 }
 
 /**
- * Data store for data specific to the glanceable view. Stored data should include information
- * relevant to the representation of views, but not surface specific view data. For example, the
- * month displayed on a calendar rather than actual calendar entries.
+ * Interface for an object that manages configuration for glanceables using the given
+ * GlanceStateDefinition.
  *
  * @suppress
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-object GlanceState {
+interface ConfigManager {
     /**
      * Returns the stored data associated with the given UI key string.
      *
@@ -73,7 +72,7 @@
         context: Context,
         definition: GlanceStateDefinition<T>,
         fileKey: String
-    ): T = getDataStore(context, definition, fileKey).data.first()
+    ): T
 
     /**
      * Updates the underlying data by applying the provided update block.
@@ -87,7 +86,7 @@
         definition: GlanceStateDefinition<T>,
         fileKey: String,
         updateBlock: suspend (T) -> T
-    ): T = getDataStore(context, definition, fileKey).updateData(updateBlock)
+    ): T
 
     /**
      * Delete the file underlying the [DataStore] and remove local references to the [DataStore].
@@ -96,6 +95,35 @@
         context: Context,
         definition: GlanceStateDefinition<*>,
         fileKey: String
+    )
+}
+
+/**
+ * Data store for data specific to the glanceable view. Stored data should include information
+ * relevant to the representation of views, but not surface specific view data. For example, the
+ * month displayed on a calendar rather than actual calendar entries.
+ *
+ * @suppress
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+object GlanceState : ConfigManager {
+    override suspend fun <T> getValue(
+        context: Context,
+        definition: GlanceStateDefinition<T>,
+        fileKey: String
+    ): T = getDataStore(context, definition, fileKey).data.first()
+
+    override suspend fun <T> updateValue(
+        context: Context,
+        definition: GlanceStateDefinition<T>,
+        fileKey: String,
+        updateBlock: suspend (T) -> T
+    ): T = getDataStore(context, definition, fileKey).updateData(updateBlock)
+
+    override suspend fun deleteStore(
+        context: Context,
+        definition: GlanceStateDefinition<*>,
+        fileKey: String
     ) {
         mutex.withLock {
             dataStores.remove(fileKey)