Widget ListView not refreshing when onDatasetChanged method contains async network call Android
Asked Answered
I

2

8

I would like to load a list of cars from the internet into a ListView in a Widget. When I am requesting the cars in the onDatasetChanged method, it does not update the list. When I however manually click my refresh button, it does refresh it. What could be the problem? In order to provide as much information as possible, I put the complete source into a ZIP file, which can be unzipped and be opened in Android Studio. I will embed it down below too. Note: I am using RxJava for the async requests and the code is written in Kotlin, although the question is not per sé language bound.

CarsWidgetProvider

class CarsWidgetProvider : AppWidgetProvider() {
    override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
        Log.d("CAR_LOG", "Updating")
        val widgets = appWidgetIds.size

        for (i in 0 until widgets) {
            Log.d("CAR_LOG", "Updating number $i")
            val appWidgetId = appWidgetIds[i]

            // Get the layout
            val views = RemoteViews(context.packageName, R.layout.widget_layout)
            val otherIntent = Intent(context, ListViewWidgetService::class.java)
            views.setRemoteAdapter(R.id.cars_list, otherIntent)

            // Set an onclick listener for the action and the refresh button
            views.setPendingIntentTemplate(R.id.cars_list, getPendingSelfIntent(context, "action"))
            views.setOnClickPendingIntent(R.id.refresh, getPendingSelfIntent(context, "refresh"))

            // Tell the AppWidgetManager to perform an update on the current app widget
            appWidgetManager.updateAppWidget(appWidgetId, views)
        }
    }

    private fun onUpdate(context: Context) {
        val appWidgetManager = AppWidgetManager.getInstance(context)
        val thisAppWidgetComponentName = ComponentName(context.packageName, javaClass.name)
        val appWidgetIds = appWidgetManager.getAppWidgetIds(thisAppWidgetComponentName)
        appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.cars_list)
        onUpdate(context, appWidgetManager, appWidgetIds)
    }

    private fun getPendingSelfIntent(context: Context, action: String): PendingIntent {
        val intent = Intent(context, javaClass)
        intent.action = action
        return PendingIntent.getBroadcast(context, 0, intent, 0)
    }

    @SuppressLint("CheckResult")
    override fun onReceive(context: Context?, intent: Intent?) {
        super.onReceive(context, intent)
        if (context != null) {
            if ("action" == intent!!.action) {
                val car = intent.getSerializableExtra("car") as Car

                // Toggle car state
                Api.toggleCar(car)
                        .subscribeOn(Schedulers.io())
                        .observeOn(AndroidSchedulers.mainThread())
                        .subscribe {
                            Log.d("CAR_LOG", "We've got a new state: $it")
                            car.state = it
                            onUpdate(context)
                        }

            } else if ("refresh" == intent.action) {
                onUpdate(context)
            }
        }
    }
}

ListViewWidgetService

class ListViewWidgetService : RemoteViewsService() {
    override fun onGetViewFactory(intent: Intent): RemoteViewsService.RemoteViewsFactory {
        return ListViewRemoteViewsFactory(this.applicationContext, intent)
    }
}

internal class ListViewRemoteViewsFactory(private val context: Context, intent: Intent) : RemoteViewsService.RemoteViewsFactory {
    private var cars: ArrayList<Car> = ArrayList()

    override fun onCreate() {
        Log.d("CAR_LOG", "Oncreate")
        cars = ArrayList()
    }

    override fun getViewAt(position: Int): RemoteViews {
        val remoteViews = RemoteViews(context.packageName, R.layout.widget_list_item_car)

        // Get the current car
        val car = cars[position]
        Log.d("CAR_LOG", "Get view at $position")
        Log.d("CAR_LOG", "Car: $car")

        // Fill the list item with data
        remoteViews.setTextViewText(R.id.title, car.title)
        remoteViews.setTextViewText(R.id.description, car.model)
        remoteViews.setTextViewText(R.id.action, "${car.state}")
        remoteViews.setInt(R.id.action, "setBackgroundColor",
                if (car.state == 1) context.getColor(R.color.colorGreen) else context.getColor(R.color.colorRed))

        val extras = Bundle()
        extras.putSerializable("car", car)

        val fillInIntent = Intent()
        fillInIntent.putExtras(extras)

        remoteViews.setOnClickFillInIntent(R.id.activity_chooser_view_content, fillInIntent)
        return remoteViews
    }

    override fun getCount(): Int {
        Log.d("CAR_LOG", "Counting")
        return cars.size
    }

    @SuppressLint("CheckResult")
    override fun onDataSetChanged() {
        Log.d("CAR_LOG", "Data set changed")

        // Get a token
        Api.getToken()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe { token ->
                    if (token.isNotEmpty()) {
                        Log.d("CAR_LOG", "Retrieved token")
                        DataModel.token = token

                        // Get the cars
                        Api.getCars()
                                .subscribeOn(Schedulers.io())
                                .observeOn(AndroidSchedulers.mainThread())
                                .subscribe {
                                    cars = it as ArrayList<Car>

                                    Log.d("CAR_LOG", "Found cars: $cars")
                                }
                    } else {
                        Api.getCars()
                                .subscribeOn(Schedulers.io())
                                .observeOn(AndroidSchedulers.mainThread())
                                .subscribe {
                                    cars = it as ArrayList<Car>

                                    Log.d("CAR_LOG", "Found cars: $cars")
                                }
                    }
                }
    }

    override fun getViewTypeCount(): Int {
        Log.d("CAR_LOG", "Get view type count")
        return 1
    }

    override fun getItemId(position: Int): Long {
        Log.d("CAR_LOG", "Get item ID")
        return position.toLong()
    }

    override fun onDestroy() {
        Log.d("CAR_LOG", "On Destroy")
        cars.clear()
    }

    override fun hasStableIds(): Boolean {
        Log.d("CAR_LOG", "Has stable IDs")
        return true
    }

    override fun getLoadingView(): RemoteViews? {
        Log.d("CAR_LOG", "Get loading view")
        return null
    }
}

DataModel

object DataModel {
    var token: String = ""
    var currentCar: Car = Car()
    var cars: ArrayList<Car> = ArrayList()

    init {
        Log.d("CAR_LOG", "Creating data model")
    }

    override fun toString(): String {
        return "Token : $token \n currentCar: $currentCar \n Cars: $cars"
    }
}

Car

class Car : Serializable {
    var id : String = ""
    var title: String = ""
    var model: String = ""
    var state: Int = 0
}


EDIT

After I received some answers, I started figuring out a temporary fix, which is calling .get() on an AsyncTask, which makes it synchronous

@SuppressLint("CheckResult")
override fun onDataSetChanged() {
    cars = GetCars().execute().get() as ArrayList<Car>
}

class GetCars : AsyncTask<String, Void, List<Car>>() {
    override fun doInBackground(vararg params: String?): List<Device> {
        return Api.getCars().blockingFirst();
    }
}
Isobath answered 29/7, 2018 at 13:51 Comment(1)
It is highly recommended that you should not makes api calls in AppWidgetProvider. AppWidgetProvider is simply a BroadcastReceiver, and if the api takes few seconds to finish, it might be terminated before finishes. developer.android.com/guide/topics/appwidgets/…Quinones
A
9

As said @ferini your rx chain is an asynchronous call, so what actually is going on when you call appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.cars_list) : enter image description here

As you can see, after notifyAppWidgetViewDataChanged() onDataSetChange() will be called, but your call is asynchronous, that's why RemoteViewFactory going further to FetchMetaData (check image). So, on first launch of the widget, you initialise cars as ArrayList(), that's why your ListView is empty. When you send refresh action, your cars not empty, cuz your asynchronous call that you start in onDataSetChange(), already fetch data and populate your cars, that's why on refresh action you populate ListView by last asynchronous call.

As you can see, onDataSetChanged() is intended for populate some data from database or something like that. You have some options how to update your widget:

  1. Save in database and fetch in onDataSetChange() by calling notifyAppWidgetViewDataChanged() in IntentService. Process full update and make new intent for listview adapter with parcelable data and setRemoteAdapter() on RemoteView, so in onCreate RemoteViewsService, pass intent to RemoteViewsFactory where you can get from intent your arraylist of cars and set it to RemoteViewsFactory, in this case you don't need onDataSetChange() callback at all.

  2. Just make all synchronous (blocking) calls in onDataSetChange() since it's not a UI thread.

You can easily check this by making blocking rx call in onDataSetChanged() and your widget will work as you want.

override fun onDataSetChanged() {
    Log.d("CAR_LOG", "Data set changed")
    cars = Api.getCars().blockingFirst() as ArrayList<Car>
}

UPDATE: Actually if you will log current thread in RemoteViewFactory callbacks, u will see that only onCreate() is on main thread, other callbacks working on thread of binder thread pool. So it's ok to make synchronous calls in onDataSetChanged() since it's not on UI thread, so making blocking calls it's ok, u will not get ANR

Abstriction answered 1/8, 2018 at 8:56 Comment(4)
As the data changes rapidly, the Database solution is not an option. I do not quite understand what you mean with the second, are you telling me to completely refresh the service with a fresh intent? Using blockingFirst() indeed solves the problem, but wouldn't it block the UI too, which will maybe cause an ANR in the future? It also will give NetworkOnMainThread exceptions ofcourse, so..Isobath
Of cuz I don't recommend to use blockingFirst() :D - that was only to understand why your list doesn't updated as you needed. Best solution (I'm using it in production as well) is to update from IntentService and pass your parceble data to RemoteFactory.Abstriction
Will look into that. For now I am using this, although calling .get() should of course never be done! (See edit)Isobath
@jason i was wrong, so my answer have some updates. it will not give NetworkOnMainThread exceptions, and where is no ANR if you're going to make blocking calls, cuz invoking of all methods (except onCreate) of RemoteViewFactory will be done by binder thread pool. Actually like your question, hope now it's clear :DAbstriction
M
3

The problem seems to be in the async request. The views are refreshed after onDataSetChanged() completes. However, since the request in asynchronous, the results come after that.

As per https://developer.android.com/guide/topics/appwidgets/#fresh:

"Note that you can perform processing-intensive operations synchronously within the onDataSetChanged() callback. You are guaranteed that this call will be completed before the metadata or view data is fetched from the RemoteViewsFactory."

Try to make your request synchronous.

Mediatory answered 1/8, 2018 at 8:27 Comment(2)
To me it seems like making the request synchronous will block the UI and eventually can result in ANR?Isobath
I have not tried it, but since the docs say it is ok to do intensive work there, I would guess that onDataSetChanged() does not run on main/UI thread.Mediatory

© 2022 - 2024 — McMap. All rights reserved.