Skip to content

6. SDK: Widget Providers

Kieron Quinn edited this page Oct 23, 2023 · 1 revision

Widget Providers

A Widget Provider works with a Smartspacer Target or Complication to allow the loading of data from another app's widget. With it, you can effectively "map" content from a widget to a Smartspace Target or Complication, providing a simple way to load data from another app such as a weather forecast.

Smartspacer handles configuring and binding the widget for you, and provides your plugin with the raw RemoteViews from the widget when it changes. It's then up to your plugin to parse data out of the widget, store it, and update a Target or Complication.

Note: Users will be asked if they wish to allow your plugin to access widgets when your Target/Complication is added. If they deny this access, the Target/Complication will not be added.

Setup

Follow the pages for creating a Target or Complication first.

The Widget Provider Class

Widget Providers at their core are Content Providers, but the Smartspacer SDK handles most of the logic required for a Provider to function.

The basic Widget Provider class is as follows:

class ExampleWidgetProvider: SmartspacerWidgetProvider() {

    override fun onWidgetChanged(smartspacerId: String, remoteViews: RemoteViews?) {
        //Handle changes to this widget
    }

    override fun getAppWidgetProviderInfo(smartspacerId: String): AppWidgetProviderInfo? {
        //Return the AppWidgetProviderInfo for the widget, or null if not available
    }

    override fun getConfig(smartspacerId: String): Config {
        //Return your configuration
    }

}

Declaring in the Manifest

Since Widget Providers are based on ContentProviders, it must be specified as one, with a specific permission. An action is not required for this provider:

<provider
    android:name=".ExampleWidgetProvider"
    android:authorities="${applicationId}.widget.example"
    android:permission="com.kieronquinn.app.smartspacer.permission.ACCESS_SMARTSPACER_WIDGETS"
    android:exported="true"/>

Note: The authority specified here is the authority you must specify in your Target or Complication's configuration, as the widgetProvider.

Configuration

The getConfig method can return some basic information for Smartspacer about your Widget. The available options are:

Config(
	width = // The required width of your Widget (optional)
	height = // The required height of your Widget (optional)
)

If unspecified, Smartspacer will use the App Widget's default sizes. The SDK also provides two methods; getColumnSpan and getRowSpan to calculate the widget's size based on launcher rows and cells.

Returning an AppWidgetProviderInfo

The getAppWidgetProviderInfo method must return the provider info for your required widget, or null if it is not available. You can query for this using AppWidgetManager.getInstalledProviders().

Note: If your required app widget is not available, consider also returning an incompatible state for the Target or Complication which requires it. That way, you can explain to the user which app is required, rather than the Target/Complication being 'available' but showing a generic error when they try to add it.

Handling Widget Changes

When your specified widget is bound and its contents change, onWidgetChanged will be called with your Smartspacer ID and the raw RemoteViews for the Widget. The Smartspacer SDK provides a number of options for getting data from the RemoteViews.

Take the example of a very basic Widget containing a TextView with the ID of example, belonging to package com.example. We can load the Views from the RemoteViews, and extract the text:

override fun onWidgetChanged(smartspacerId: String, remoteViews: RemoteViews?) {
    //Load the RemoteViews into regular Views
    val views = remoteViews?.load() ?: return
    //Find a TextView with the ID of "example", belonging to package "com.example"
    val textView = views.findViewByIdentifier<TextView>("com.example:id/example")
    if(textView != null){
        val text = textView.text.toString()
        //Do something with the text
    }else{
        Log.e("ExampleWidgetProvider", "Failed to load text from widget!")
    }
}

Note: "Identifiers" are fully qualified IDs for a given package, and prevent the need for your plugin to have to deal with the numeric resource IDs of an app, which change between versions. They are in the format package:type/name

Similarly, if you want to extract images, you can get them from an ImageView:

override fun onWidgetChanged(smartspacerId: String, remoteViews: RemoteViews?) {
    //Load the RemoteViews into regular Views
    val views = remoteViews?.load() ?: return
    //Find a ImageView with the ID of "image", belonging to package "com.example"
    val imageView = views.findViewByIdentifier<ImageView>("com.example:id/image")
    if(imageView != null){
        val image = imageView.drawable.toBitmap()
        //Do something with the image
    }else{
        Log.e("ExampleWidgetProvider", "Failed to load image from widget!")
    }
}

Note: toBitmap is provided by androidx.core

Click Actions

Smartspacer's SDK contains a method to extract the PendingIntent click actions from Views:

override fun onWidgetChanged(smartspacerId: String, remoteViews: RemoteViews?) {
    //Load the RemoteViews into regular Views
    val views = remoteViews?.load() ?: return
    //Find a FrameLayout with the ID of "container", belonging to package "com.example"
    val frameLayout = views.findViewByIdentifier<FrameLayout>("com.example:id/container")
    if(frameLayout != null){
        val clickPendingIntent = frameLayout.getClickPendingIntent()
        //Do something with the click action
    }else{
        Log.e("ExampleWidgetProvider", "Failed to load click action from widget!")
    }
}

You can use this PendingIntent when a Target or Complication is clicked

Loading Lists

RemoteViews can contain AdapterViews, with Remote Adapters. Smartspacer is able to provide your plugin with a remote adapter implementation, to query items and load RemoteViews from them.

override fun onWidgetChanged(smartspacerId: String, remoteViews: RemoteViews?) {
    //Load the RemoteViews into regular Views
    val views = remoteViews?.load() ?: return
    //Find a ListView with the ID of "list", belonging to package "com.example"
    val listView = views.findViewByIdentifier<ListView>("com.example:id/list")
    if(listView != null){
        getAdapter(smartspacerId, listView.id)
    }else{
        Log.e("ExampleWidgetProvider", "Failed to load list from widget!")
    }
}

override fun onAdapterConnected(smartspacerId: String, adapter: RemoteAdapter) {
    super.onAdapterConnected(smartspacerId, adapter)
    val count = adapter.getCount()
    for(i in 0 until count) {
        val item = adapter.getViewAt(i) ?: continue
        val views = item.remoteViews.load() ?: continue
        //Do something with the item's Views
    }
}

override fun onViewDataChanged(smartspacerId: String, viewIdentifier: String?, viewId: Int?) {
    super.onViewDataChanged(smartspacerId, viewIdentifier, viewId)
	//Handle changes to a AdapterView's adapter data changing. This isn't often used by widgets.
}

Note: Some ListViews may use a Remote Collection Items. In this case, call getRemoteCollectionItems instead of getAdapter, the same onAdapterConnected callback will be made.

In addition to providing the Views, RemoteAdapter also provides the PendingIntent attached to the Adapter View (adapterViewPendingIntent), and each RemoteAdapterItem also provides a list of onClickResponses, which are equivalent to RemoteViews.RemoteResponse. When combined, you can use these to invoke Context.startIntentSender, specifying the PendingIntent's IntentSender, and the fillInIntent

Sized RemoteViews

Introduced in Android 12, Sized RemoteViews allow widgets to specify RemoteViews for different sizes of Widget. This can sometimes mean that regardless of the size you have specified for your widget, you only get back the wrong (often incorrect) RemoteViews. Smartspacer's SDK contains a method which allows you to extract the correct RemoteViews for your required size, by calling getSizedRemoteView from your Widget Provider, with your required width and height:

override fun onWidgetChanged(smartspacerId: String, remoteViews: RemoteViews?) {
    //Load the correct sized RemoteViews
    val sized = getSizedRemoteView(remoteViews ?: return, SizeF(width, height))
    val views = sized?.load() ?: return 
    //Handle the Views as before
}

Note: On Android < 12, this method will just immediately return the RemoteViews passed to it.

Jetpack Glance

Jetpack Glance provides a bit of a headache for Widget Providers, as RemoteViews produced by it do not have IDs. Unlike other Compose-esque systems, Glance does however still use Views underneath, so data can still be extracted from these widgets.

The Smartspacer SDK provides two options for handling Glance-created widgets:

ViewStructure

The ViewStructure SDK allows you to use Kotlin DSL to specify a format of the layout, assign Views your IDs based on their position, and then use these IDs to get the Views and extract data from them.

Consider the structure of the root of the Google Finance Widget:

private val STRUCTURE_ROOT: ViewGroup.() -> Unit = {
    frameLayout {
        frameLayout {
            linearLayout {
                index = 1
                listView {
                    id = IDENTIFIER_LIST
                }
            }
        }
    }
}

This corresponds to a View layout of:

<FrameLayout>
	<FrameLayout>
		<other view/>
		<LinearLayout>
			<ListView/>
		</LinearLayout>
	</FrameLayout>
</FrameLayout>

As you can see, Glance can be quite messy with its generated View layouts.

This ViewStructure can be used as follows:

override fun onWidgetChanged(smartspacerId: String, remoteViews: RemoteViews?) {
    val views = remoteViews?.load() ?: return
	//Load the ViewStructure, this will return null if the layout does not match
    val structure = mapWidgetViewStructure(views, STRUCTURE_ROOT) ?: return
	//Get the raw View resource ID of the ListView, which we can use to get the adapter
	val listViewId = structure.getViewIdFromStructureId(IDENTIFIER_LIST) ?: return
	//Load the adapter or collection items for the list. Glance uses RemoteCollectionItems on >= S, and a regular Adapter below that.
    if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        getRemoteCollectionItems(smartspacerId, listViewId)
    }else{
        getAdapter(smartspacerId, listViewId)
    }
	//onAdapterConnected will be called once the adapter is ready
}

The ViewStructure has automatically loaded the Views, mapped the ListView to have the ID of IDENTIFIER_LIST, and then returned an object we can get Views or their raw View resource IDs from.

You can use this in two ways, to access a View directly call findViewByStructureId, passing in the original loaded views, and the identifier given to the View in the DSL.

structure.findViewByStructureId<ListView>(views, IDENTIFIER_LIST)

Alternatively, you can access the View's raw resource ID by calling getViewIdFromStructureId as above:

structure.getViewIdFromStructureId(IDENTIFIER_LIST)

You can then use this ID to load an adapter.

Index-based Loading

For simpler widgets, or widgets whose layout is too dynamic to use a ViewStructure, Smartspacer also provides a method which will return a flat list of all Views of a given type which are children of a ViewGroup, depth first. If the widget you are loading data from only has one ListView for example, you could call

val listView = views.findViewsByType(ListView::class.java).firstOrNull()

Which would return the ListView, or null. Similarly, you can use the standard Kotlin list filtering to load the nth item, or the last ImageView with a content description. Since the list order should stay the same between calls (assuming the widget isn't also changing layout entirely), this is often sufficient to load data from awkward widgets.

Troubleshooting

Smartspacer's SDK provides a method to dump a ViewGroup tree to the logcat:

views.dumpToLog("Views")

This would dump a space-indented tree to the log with the tag "Views". Each View is labelled with its resource identifier, raw ID, click pending intent (if set), whether it is clickable, ImageViews with their drawable and content description, and TextViews with their text. You can use this tree to write the DSL for a ViewStructure, or get the right identifiers to use when loading View data.

Alternatively, for a visual representation, the old DDMS monitor tool included with the Android SDK can be used on a widget added to any launcher's home screen, and displays View identifiers when they are selected. Note that the monitor tool does not work with Java versions newer than 1.8, so if the version in your $PATH is newer than that, you need to edit the monitor.ini file (or equivalent) to point to JDK 8 with the -vm flag.

How do I load x piece of data?

Most of the time, you can load data from widgets' text, icons and content descriptions. However, occsasionally developers will also include data in the fillInIntent in the adapter for a list-based adapter. Sometimes, this can contain far more information than the widget actually shows, and you can extract it from the intent's extras. For an example of doing this, check out the Aftership Plugin's source.

Alternatively, sometimes developers will leave information in the tags of a View. Usually the only way to find this is either to painstakingly check every single View's tags, or to decompile the app in question to see what it's doing when creating the widget.