Android Jetpack Glance 1.0.0 : problems updating widget
Asked Answered
S

2

12

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 :

  • It always works when first adding the widget to my dashboard. Data is always fresh there.
  • It always works the first time the update method is called from the application itself (I'm using updateAll). The widget is updated and shows up-to-date data,
  • Then it does not work if I call the update method too soon after the last call. In this case the provideGlance method is not triggered at all.

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 !

  • I use version 1.0.0 of the androidx.glance:glance-appwidget lib, released a few days ago,
  • I did not forget to add the <receiver> tag in my AndroidManifest.xml, as well as the required android.appwidget.provider xml file in my res/xml folder. I think I've correctly done everything that is mentioned on the Glance setup page given I have no problem adding the widget on my home screen in the first place,
  • I use SQLiteOpenHelper under the hood to access my data, with a few helper classes of my creation on top of it, not using Room or any other ORM lib (I want to keep my application simple for now).
  • Here is what my provideGlance method looks like :
    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)

  • I also don't use Hilt or any other DI lib.
Symposium answered 12/9, 2023 at 10:40 Comment(0)
S
24

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 :

  • When a Glance session is running, everytime any of the GlanceAppWidget.update methods are called from within the app, Glance will recompose your widget content using a fresh copy of the Glance state
  • This Glance state must be defined beforehand, by providing a GlanceStateDefinition instance to your class extending GlanceAppWidget. This instance is responsible for providing the type (class) of your state, as well as the DataStore that Glance will use internally to get an updated version of the Glance state

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.

Symposium answered 13/9, 2023 at 9:13 Comment(5)
Do you have a working copy of this widget that you like to share?Lumpfish
Holy cow. I can't thank you enough. Been trying to figure out using the "oficial" example and none seems to work. This is really helpfulWanderjahr
You saved my day... This isn't documented anywherePhotomural
Can you please share the code of it or github link?Resile
I am having this issue as well, but even providing a state definition doesn't seem to help, I am not sure exactly how it is supposed to work over the default value.Byrne
A
0

I too was stuck with the same problem following the official guide, which is not clear enough about the update process. However, the documentation of GlanceAppWidget::provideGlance() explains it well. Following it, the following code works fine:

class PortfolioStatusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
    val portfolioUuidFlow =
        WidgetConfigurationDao(context).getPortfolioUuid(GlanceAppWidgetManager(context).getAppWidgetId(id))
    provideContent {
        val portfolioUuid by portfolioUuidFlow.collectAsState(null)
        //status stores data to be displayed by the widget
        var status: WidgetStatus by remember { mutableStateOf(WidgetStatus.Invalid) }
        LaunchedEffect(portfolioUuid, DbState.version) {
            status = getPortfolioStatus(context, portfolioUuid ?: "")
        }
        GlanceTheme {
            Box(modifier = GlanceModifier.padding(4.dp)) {
                //Use status here as needed. 
                //Calls to PortfolioStatusWidget.update() and PortfolioStatusWidget().updateAll() now always update the widget.
            }
        }
    }
}

}

Analgesia answered 29/5 at 12:56 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.