androidandroid-widgetglance-appwidgetglance

Android Jetpack Glance 1.0.0 : problems updating widget


I am implementing my first Jetpack Glance powered app widget for a simple todo list app I'm working on. I'm following the guide at https://developer.android.com/jetpack/compose/glance and everything is fine until I get to the point where I need to update my widget to match the data updates that occured from within my application.

From my understanding of Manage and update GlanceAppWidget, one can trigger the widget recomposition by calling either of update, updateIf or updateAll methods on a widget instance from the application code itself. Specifically, calls to those functions should trigger the GlanceAppWidget.provideGlance(context: Context, id: GlanceId) method, which is responsible for fetching any required data and providing the widget content, as described on this snippet :

class MyAppWidget : GlanceAppWidget() {

    override suspend fun provideGlance(context: Context, id: GlanceId) {

        // In this method, load data needed to render the AppWidget.
        // Use `withContext` to switch to another thread for long running
        // operations.

        provideContent {
            // create your AppWidget here
            Text("Hello World")
        }
    }
}

But in my case, it is not always working. Here is what I observed after a few tries :

I then looked into the GlanceAppWidget source code and noticed it relies on an AppWidgetSession class :

    /**
     * Internal version of [update], to be used by the broadcast receiver directly.
     */
    internal suspend fun update(
        context: Context,
        appWidgetId: Int,
        options: Bundle? = null,
    ) {
        Tracing.beginGlanceAppWidgetUpdate()
        val glanceId = AppWidgetId(appWidgetId)
        if (!sessionManager.isSessionRunning(context, glanceId.toSessionKey())) {
            sessionManager.startSession(context, AppWidgetSession(this, glanceId, options))
        } else {
            val session = sessionManager.getSession(glanceId.toSessionKey()) as AppWidgetSession
            session.updateGlance()
        }
    }

If a session is running, it will be used to trigger the glance widget update. Otherwise it will be started and used for the same purpose.

I noticed that my problem occurs if and only if only a session is running, which would explain why it doesn't occur if I give it enough time between updates call : there are no more running sessions (whatever it means exactly) and a new one needs to be created.

I tried digging further in Glance internals to understand why it does not work when using a running session, with no success so far. The only thing I noticed and thought is weird is that at some point the AppWidgetSession internaly uses a class called GlanceStateDefinition, that I didn't see mentionned on the official Android Glance guide but that a few other guides on the web use to implement a Glance widget (Though using alpha or beta versions of Jetpack Glance libs).

Does anyone has a clue on why it behaves like this ? Here is some more information, please let me know if you need something else. Thanks a lot !

    override suspend fun provideGlance(context: Context, id: GlanceId) {

        val todoDbHelper = TodoDbHelper(context)
        val dbHelper = DbHelper(todoDbHelper.readableDatabase)
        val todoDao = TodoDao(dbHelper)
        val todos = todoDao.findAll()

        provideContent {
            TodoAppWidgetContent(todos)
        }
    }

The todoDao.findAll() returns a plain List (it relies on a helper function that runs on Dispatchers.IO so that the main thread is not blocked)


Solution

  • I spent a few more hours searching and found my answer :

    It turns out I was wrong assuming that the provideGlance method, or even the provideContent should be triggered again when calling any of the aforementioned update methods. You can fetch some initialization data in there but you cannot rely on it to keep your widget updated, it is only called when no Glance session is currently running (When first adding the widget / when time has passed since adding it). Instead you can (/should) rely on the state of your Glance Widget.

    I think this concept of Glance state is very poorly explained in the guide, to say the least, so I'll give it a short try hoping to help people having the same problem as I did :

    A DataStore is an interface that provides two abstracts methods for getting and updating data (More info here : DataStore). There are 2 implementations provided, Preferences DataStore and Proto DataStore. The first one is intended to replace SharedPreferences as a mean to store key-value pairs, and the second can be used to store typed objects.

    Most of the Glance tutorials I found on the web make use of the Preferences DataStore in their examples, but for my purpose I chose to implement my own version of a DataStore as a readonly proxy to my Dao object, as follows :

    class TodoDataStore(private val context: Context): DataStore<List<TodoListData>> {
        override val data: Flow<List<TodoListData>>
            get() {
                val todoDbHelper = TodoDbHelper(context)
                val dbHelper = DbHelper(todoDbHelper.readableDatabase)
                val todoDao = TodoDao(dbHelper)
                return flow { emit(todoDao.findAll()) }
            }
    
        override suspend fun updateData(transform: suspend (t: List<TodoListData>) -> List<TodoListData>): List<TodoListData> {
            throw NotImplementedError("Not implemented in Todo Data Store")
        }
    }
    

    The state definition in my class extending GlanceAppWidget looks like this :

        override val stateDefinition: GlanceStateDefinition<List<TodoListData>>
            get() = object: GlanceStateDefinition<List<TodoListData>> {
                override suspend fun getDataStore(
                    context: Context,
                    fileKey: String
                ): DataStore<List<TodoListData>> {
                    return TodoDataStore(context)
                }
    
                override fun getLocation(context: Context, fileKey: String): File {
                    throw NotImplementedError("Not implemented for Todo App Widget State Definition")
                }
            }
    

    Meaning I can now rely on the state of my Glance Widget instead of using my Dao class directly, by using the currentState() method :

        override suspend fun provideGlance(context: Context, id: GlanceId) {
            provideContent {
                TodoAppWidgetContent(currentState())
            }
        }
    

    It works like a charm now !

    I intend to fill an issue regarding the lack of documentation regarding the Glance state and its relation to the concept of Datastore in the Glance guide.