App Design / App Widget

Currency Converter: Improvements

In the previous series, we looked at the overall design of a personal project that I put together. The app obtains the balance from my Transferwise Borderless account (in USD) plus the current exchange rates. It displays this in an App Widget which the user can place on the home screen. While the app works, there are occasions where the App Widget remains blank for a period of time. This was always less than 15 minutes – until the next WorkManager update was performed. While I can live with this, I happen to like good UX, so it niggled me. In this article, we’ll look at the causes of this, and how to resolve it.

There are actually two things that can cause the widget to appear blank.

The first of these is the logic to purge expired results in the repository. The rather simplistic logic that I used will always purge any expired results before attempting to get new ones from the TransferWise APIs. This displays up-to-date results. However, if the TranswerWise request fails then we have no data to display.

The second is how AppWidgetProvider works in Android. Sometimes the OS will trigger an update. In this case, the Intent which triggers the update will not contain the extras that we need to update the widget.

Purge Expired Results Logic

The existing data expiry logic looks like this:

class CurrencyRepository @Inject constructor(
    private val persistence: CurrencyPersistence,
    private val provider: CurrencyProvider,
    @Named(ApplicationModule.FROM_CURRENCY) private val from: Currency,
    @Named(ApplicationModule.TO_CURRENCY) private val to: Currency
) {

    suspend fun exchangeRates(validity: Duration): ExchangeRate {
        persistence.purgeExchangeRates(Instant.now().minus(validity))
        return persistence.loadExchangeRates() ?: provider.exchangeRates(from, to).also {
            persistence.saveExchangeRates(it)
        }
    }

    suspend fun balance(validity: Duration): Balance {
        persistence.purgeBalance(Instant.now().minus(validity))
        return persistence.loadBalance() ?: provider.balance(from).also {
            persistence.saveBalance(it)
        }
    }
}

The logic here is the same for both exchange rates and balance. First, we purge any expired results, then we try to load cached results. No results are loaded if If the cached results are outdated. That will then trigger an API call to retrieve updated results. If the API lookup fails, then we’re left with an empty cache and no data to display.

To improve this we can alter this logic somewhat. Rather than purging the expired data up-front, we can first check the expiry of the cached data. If it is still valid then we just return it. But if it has expired, then we make an API call to refresh it. If this API succeeds, then we purge the expired results and save the new data. However, if the API call fails, then we don’t purge the expired data, and still have something to display, albeit expired data:

lass CurrencyRepository @Inject constructor(
    private val persistence: CurrencyPersistence,
    private val provider: CurrencyProvider,
    @Named(ApplicationModule.FROM_CURRENCY) private val from: Currency,
    @Named(ApplicationModule.TO_CURRENCY) private val to: Currency
) {

    suspend fun exchangeRates(validity: Duration): ExchangeRate? {
        val persisted = persistence.loadExchangeRates()
        return loadAndPurge(
            value = persisted,
            validity = validity,
            purger = persistence::purgeExchangeRates,
            saver = persistence::saveExchangeRates
        ) {
            provider.exchangeRates(from, to)
        }
    }

    suspend fun balance(validity: Duration): Balance? {
        val persisted = persistence.loadBalance()
        return loadAndPurge(
            value = persisted,
            validity = validity,
            purger = persistence::purgeBalance,
            saver = persistence::saveBalance
        ) {
            provider.balance(from)
        }
    }

    private suspend fun  loadAndPurge(
        value: T?,
        validity: Duration,
        purger: suspend () -> Unit,
        saver: suspend (T) -> Unit,
        retriever: suspend () -> T?
    ): T? {
        return if (value?.timestamp?.isAfter(Instant.now().minus(validity)) == true) {
            value
        } else {
            retriever()?.also { newValue ->
                purger()
                saver(newValue)
            } ?: run {
                Timber
                    .tag("CurrencyRepository")
                    .d("Retrieve failed, so using expired, persisted value")
                value
            }
        }
    }
}

All of this logic is in the loadAndPurge() method.

This behaviour may not be correct for all use-cases. But it displays the latest data that was retrieved from TransferWise. so it feels right here. It might be nice to display an indication to the user that this may be stale data, but that’s a task for another day!

Intents Without Extras

The other cause for an empty widget is if the OS triggers a widget update. The current AppWidgetProvider does not cope with this:

@AndroidEntryPoint
class CurrencyAppWidgetProvider : AppWidgetProvider() {

    @Inject
    lateinit var updateScheduler: UpdateScheduler

    private var balance: String? = null
    private var converted: String? = null

    override fun onEnabled(context: Context?) {
        super.onEnabled(context)
        Timber.d("OnEnabled")
        updateScheduler.startUpdates()
    }

    override fun onReceive(context: Context, intent: Intent) {
        balance = intent.getStringExtra("BALANCE")
        converted = intent.getStringExtra("CONVERTED")
        super.onReceive(context, intent)
    }

    override fun onUpdate(context: Context, widgetManager: AppWidgetManager, widgetIds: IntArray) {
        widgetIds.forEach { id ->
            Timber.d("onUpdate")
            val views = RemoteViews(context.packageName, R.layout.currency_widget).apply {
                balance?.also {
                    setTextViewText(R.id.balance, it)
                }
                converted?.also {
                    setTextViewText(R.id.converted, it)
                }
            }
            widgetManager.updateAppWidget(id, views)
        }
    }

    override fun onDisabled(context: Context?) {
        super.onDisabled(context)
        Timber.d("OnDisabled")
        updateScheduler.stopUpdates()
    }
}

The onReceive() method extracts values from the Intent extras. An Intent from the OS update will not include these so they will both be set to null. The logic in onUpdate() will only set values for the widget TextViews for non-null values.

One solution would be to make a repository call here instead of replying upon values from extras. However I wanted to avoid making repository calls here. The reason for this is that this is a BroadcastReceiver which will have a very short lifespan. Once the onUpdate() method returns the BroadcastReceiver() may be destroyed. The onUpdate() method is running on the main thread, so we don’t want to call any blocking operations directly. The repository calls are all suspend functions and using coroutines here could cause problems. The onUpdate() method would return before any coroutine blocks finished executing, so the coroutine context may get terminated before the retrieval from the repository has completed.

This is all getting rather complex. However, there is a much simpler solution. We already have WorkManager set up do do a background retrieval, so we can add some logic to leverage this:

override fun onReceive(context: Context, intent: Intent) {
    balance = intent.getStringExtra("BALANCE")
    converted = intent.getStringExtra("CONVERTED")
    super.onReceive(context, intent)
    if (intent.action == AppWidgetManager.ACTION_APPWIDGET_UPDATE &&
        (balance == null || converted == null)
    ) {
        updateScheduler.enqueueOneTimeWorker()
    }
}

This still displays a blank widget. However, it also triggers a one-time worker to load data from the repository. Once this completes, another widget update will be performed. This one’s Intent will contain the required extras. This will return cached values in most cases. So this should be pretty fast.

One thing worth noting here is that updateScheduler can only be accessed after super.onReceive() has been called. This is because Hilt does not perform injection until this point so attempting to access updateScheduler before this will result in runtime errors that a lateinit value has not been initialised.

Conclusion

Both of these changes are necessary. If we only implemented the second change, then there’s an edge-case where we could purge the expired data in the one-time worker job. This would trigger another App Widget update with null extra values, so we’d get into a loop. Having a more forgiving cache purging strategy minimises the chance of this.

I haven’t documented a number of supporting changes required for this. But for those interested, just view the commit on the source repo.

Once again, many thanks to Sebastiano Poggi for useful discussions around background processing and how best to force a data refresh. Seb raised a lot of good points when we were discussing things and the result is a much better implementation. Thanks Seb!

The source code for this article is available here.

© 2021, Mark Allison. All rights reserved.

Copyright © 2021 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.