From drawables, from bitmaps, from a url — from anywhere!

This the next in my series of blog posts all about widgets. Check out Widgets with Glance: Blending in and Widgets with Glance: Standing out for some Widget UI tricks and tips. And for advanced state handling see Widgets with Glance: Beyond String States.
If you are getting into widgets, pretty soon you are going to want to display images, whether that be a simple icon or a lovely graphic to brighten up your user’s home screen. Depending on where your image is stored (within your app as a drawable or from the internet) there are a few methods and some are more straightforward than others.
Let’s start simple with drawables…
Displaying drawables
Displaying a local icon or image is easy from an app drawable resource, all you need is to create an ImageProvider
, pass in the drawable resource ID and then use it in a Image
Glance composable:
Image( | |
provider = ImageProvider(R.drawable.ic_launcher_foreground_mono), | |
contentDescription = "My image", | |
colorFilter = ColorFilter.tint( | |
GlanceTheme.colors.primary | |
), | |
contentScale = ContentScale.Fit, | |
modifier = GlanceModifier | |
) |
In the above I have applied a ColorFilter
to tint the image and a contentDescription
for accessibility. Any sizing or padding can be set via the GlanceModifier
as usual and contentScale
will control how the image fits within the Image
boundaries (more on this later).
Just a simple happy drawable displaying on a widget. The drawable is emoji by Maxim Kulikov from The Noun Project
Now, this is pretty simple, what if we want the widget to display a pretty photo? If it is stored as a drawable resource then this is exactly the same:
Image( | |
provider = ImageProvider(R.drawable.mountain), | |
contentDescription = null, | |
contentScale = ContentScale.Crop, | |
modifier = GlanceModifier.fillMaxSize() | |
) |
Looking pretty. Image from Lorem Picsum
Here I have expanded the image to fit the space and used ContentScale.Crop
so it maintains the original aspect ratio and displays edge to edge in my widget.
Glance ContentScale options
Let’s go back to looking at ContentScale
. As usual, with Jetpack Glance we have fewer options than we have with regular Jetpack Compose. The ContentScale
options are:
ContentScale.Crop
— Scale uniformly (maintaining aspect ratio) so that both dimensions (width and height) will be equal to or larger than the corresponding dimension of the image composable.ContentScale.Fit
— Scale uniformly (maintaining aspect ratio) so that both dimensions (width and height) will be equal to or less than the corresponding dimension of the image composableContentScale.FillBounds
— Scale horizontal and vertically non-uniformly to fill the image composable bounds.
Displaying an image from a url
Now, we get to what you are probably reading this article for. How to display an image from the internet in a widget. Unfortunately we can’t just directly use your favourite image loading library like Coil, Glide or Picasso as they don’t have Glance composables that we can put in Glance widget UI code (for various reasons). Instead, we need to load the image first into a bitmap, then use ImageProvider(bitmap)
to load this in our Image
Glance composable.
For this, we have two options depending on the use case:
- Load the image as a bitmap using a background thread
- Use a coroutine worker to download the image as a file
Image Bitmap with a background thread
This is the simplest option and is useful for widgets where the image is independent from the rest of the widget display (see below for when you wouldn’t want to use this option).
The first step is making use of Coil ImageRequest
to download the image as a bitmap
.
val context = LocalContext.current | |
var loadedBitmap by remember(imageUrl) { mutableStateOf<Bitmap?>(null) } | |
LaunchedEffect(imageUrl) { | |
withContext(Dispatchers.IO) { | |
val request = ImageRequest.Builder(context).data(imageUrl).apply { | |
memoryCachePolicy(CachePolicy.DISABLED) | |
diskCachePolicy(CachePolicy.DISABLED) | |
}.build() | |
// Request the image to be loaded and return null if an error has occurred | |
loadedBitmap = when (val result = context.imageLoader.execute(request)) { | |
is ErrorResult -> null | |
is SuccessResult -> result.drawable.toBitmapOrNull() | |
} | |
} | |
} |
In the above I am explicitly disabling the image caching in the request, but you can alter this depending what your image is used for.
By placing this in a LaunchedEffect
with a a context of Dispatches.IO
we can run this code on the background thread and not lock up the UI
thread.
Saving the result into a mutableState
then enables the rest of the composable to respond once the image has been downloaded:
loadedBitmap.let { bitmap -> | |
if (bitmap != null) { | |
Image( | |
provider = ImageProvider(bitmap), | |
contentDescription = null, | |
contentScale = ContentScale.Crop, | |
modifier = modifier, | |
) | |
} else { | |
CircularProgressIndicator() | |
} | |
} |
While the loadedBitmap
state is null we can show a loading indicator and once it is ready, display the image using the Image
Glance composable and a ImageProvider
.
Now this works well:
The author information is fetched during the first “Loading”, the image is downloaded while the spinner is displayed.
Except in this case, it doesn’t make sense to show the image author before we actually show the image. Also, if an error occurs during loading we just have this circular progress indicator without any way of recovery.
We could fix this by adding more logic to the UI to set success & error states based on the image download but this is more brittle especially when we have data coming from different sources (in this case metadata from one endpoint and image from another — for the full example see my widget demo github repository).
So ideally we don’t want to use this method if showing the rest of the widget without the image makes no sense.
To load everything together and respond to the whole data state use a Coroutine Worker.
Image file with a Coroutine Worker
This method, while more complex, is useful if you are doing other background processing (such as fetching other data from a remote source) and want the image and the new data state to be available at the same time.
For this you can use string states using the standard PreferencesGlanceStateDefinition
but I recommend using a CustomGlanceStateDefinition
so that the different widget data states (e.g. success, loading & error) are more clearly defined. You can find more details on how to implement this and the Coroutine Worker itself in my previous blog post Widgets with Glance: Beyond String States.
To achieve this the first task is to download the image as a temporary file on the device that we can read later from the widget. We can do this using Coil and the image cache:
@OptIn(ExperimentalCoilApi::class) | |
private suspend fun downloadImage( | |
url: String, | |
context: Context, | |
applicationContext: Context, | |
force: Boolean | |
): String { | |
val request = ImageRequest.Builder(context) | |
.data(url) | |
.build() | |
// Request the image to be loaded and throw error if it failed | |
with(context.imageLoader) { | |
if (force) { | |
diskCache?.remove(url) | |
memoryCache?.remove(MemoryCache.Key(url)) | |
} | |
val result = execute(request) | |
if (result is ErrorResult) { | |
throw result.throwable | |
} | |
} | |
// Next... find the image in the cache | |
} |
In this case we have enabled the Coil image cache — we want the image to be cached so we can fetch the file from the cache.
So here, the image file is downloaded into the cache using the url as the cache key so we can retrieve it later (the url is the key by default).
This method should be in your Coroutine Worker for the widget and called within the doWork method when you update the rest of the widget state.
Next, we need to access the Coil image cache. For this we need a FileProvider
and specify which files we will be accessing:
<manifest> | |
<application> | |
... | |
<provider | |
android:name=".widget.image.ImageFileProvider" | |
android:authorities="${applicationId}.provider" | |
android:exported="false" | |
android:grantUriPermissions="true"> | |
<meta-data | |
android:name="android.support.FILE_PROVIDER_PATHS" | |
android:resource="@xml/image_paths" /> | |
</provider> | |
</application> | |
</manifest> |
<?xml version="1.0" encoding="utf-8"?> | |
<paths> | |
<cache-path name="widget_images" path="image_cache/" /> | |
</paths> |
class ImageFileProvider : FileProvider() |
Setting up this provider enables us to access the files in theimage_cache
directory which is the default used by Coil.
Now, we can use this in the downloadImage
method:
private suspend fun downloadImage(...): String { | |
... | |
// Get the path of the loaded image from DiskCache. | |
val path = context.imageLoader.diskCache?.openSnapshot(url)?.use { snapshot -> | |
val imageFile = snapshot.data.toFile() | |
// Use the FileProvider to create a content URI | |
val contentUri = getUriForFile( | |
context, | |
"${applicationContext.packageName}.provider", | |
imageFile, | |
) | |
// return the path | |
contentUri.toString() | |
} | |
return requireNotNull(path) { | |
"Couldn't find cached file" | |
} | |
} |
Job Offers
Permission handling for File Providers
If you are using a FileProvider you may encouter something like this error:
Permission Denial: opening provider com.your.package.ImageFileProvider from ProcessRecord{...} that is not exported from UID xxxxx
This will usually occur if you are displaying (or changing) an image in your widget a long time after your app was last open. What happens here is that the permission for the URI is lost when the MainActivity is lost (such as being naturally closed in the background). If you have recently installed or updated your app (such as debugging and deploying to your device) you are unlikely to see this as your activity is still open. (Thanks to this Stack Overflow post for the explanation).
To fix this we need to request the permission prior to accessing the image. This won’t show anything to the user as it is not a restricted or special permission. To do this, add the following to the downloadImage
method:
private suspend fun downloadImage(...): String { | |
... | |
// Get the path of the loaded image from DiskCache. | |
val path = context.imageLoader.diskCache?.openSnapshot(url)?.use { snapshot -> | |
... | |
// Find the current launcher every time to ensure it has read permissions | |
val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) } | |
val resolveInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { | |
context.packageManager.resolveActivity( | |
intent, | |
PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()) | |
) | |
} else { | |
context.packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) | |
} | |
val launcherName = resolveInfo?.activityInfo?.packageName | |
if (launcherName != null) { | |
// Request the permission | |
context.grantUriPermission( | |
launcherName, | |
contentUri, | |
FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_PERSISTABLE_URI_PERMISSION, | |
) | |
} | |
// return the path | |
contentUri.toString() | |
} | |
return requireNotNull(path) { | |
"Couldn't find cached file" | |
} | |
} |
Now, the image and data load at the same time!
The complete downloadImage
method that returns the file url:
@OptIn(ExperimentalCoilApi::class) | |
private suspend fun downloadImage( | |
url: String, | |
context: Context, | |
applicationContext: Context, | |
force: Boolean | |
): String { | |
val request = ImageRequest.Builder(context) | |
.data(url) | |
.build() | |
// Request the image to be loaded and throw error if it failed | |
with(context.imageLoader) { | |
if (force) { | |
diskCache?.remove(url) | |
memoryCache?.remove(MemoryCache.Key(url)) | |
} | |
val result = execute(request) | |
if (result is ErrorResult) { | |
throw result.throwable | |
} | |
} | |
// Get the path of the loaded image from DiskCache. | |
val path = context.imageLoader.diskCache?.openSnapshot(url)?.use { snapshot -> | |
val imageFile = snapshot.data.toFile() | |
// Use the FileProvider to create a content URI | |
val contentUri = getUriForFile( | |
context, | |
"${applicationContext.packageName}.provider", | |
imageFile, | |
) | |
// Find the current launcher everytime to ensure it has read permissions | |
val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) } | |
val resolveInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { | |
context.packageManager.resolveActivity( | |
intent, | |
PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()), | |
) | |
} else { | |
@Suppress("DEPRECATION") | |
context.packageManager.resolveActivity( | |
intent, | |
PackageManager.MATCH_DEFAULT_ONLY, | |
) | |
} | |
val launcherName = resolveInfo?.activityInfo?.packageName | |
if (launcherName != null) { | |
context.grantUriPermission( | |
launcherName, | |
contentUri, | |
FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_PERSISTABLE_URI_PERMISSION, | |
) | |
} | |
// return the path | |
contentUri.toString() | |
} | |
return requireNotNull(path) { | |
"Couldn't find cached file" | |
} | |
} |
. . .
To see a full example, see my sample widget app:
. . .
For more widget blog posts, check out the others in my Widgets with glance series:
Widgets with Glance: Blending in, Widgets with Glance: Standing out and Widgets with Glance: Beyond String States
This article is previously published on proandroiddev.com.