Skip to content

Commit bc16d99

Browse files
authored
[Widgets] Fix image loading on Android 12 and older (#7067)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1210594645151737/task/1211718453527283?focus=true ### Description Switches from using Bitmaps to image URIs in favorites widget to support Android 12 and older. Note: there is an issue on Android 12 where the widget will randomly resize when opening and closing the app. Needs to be investigated. ### Steps to test this PR _Feature 1_ - [x] Verify widget works fine on Android 16 (consider loading the version from develop first to verify that it doesn't work with that version) - [x] Verify widget works fine on Android 12 (consider loading a build from #6885 first to verify it doesn't work there) ### UI changes No UI changes
1 parent 325bc1b commit bc16d99

File tree

4 files changed

+124
-33
lines changed

4 files changed

+124
-33
lines changed

app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt

Lines changed: 104 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,30 @@ import android.annotation.SuppressLint
2020
import android.appwidget.AppWidgetManager
2121
import android.content.Context
2222
import android.content.Intent
23-
import android.graphics.Bitmap
24-
import android.os.Build
23+
import android.net.Uri
2524
import android.os.Bundle
2625
import android.view.View
2726
import android.widget.RemoteViews
2827
import android.widget.RemoteViewsService
28+
import androidx.core.content.FileProvider
2929
import androidx.core.graphics.drawable.toBitmap
3030
import androidx.core.net.toUri
3131
import com.duckduckgo.app.browser.BrowserActivity
3232
import com.duckduckgo.app.browser.R
3333
import com.duckduckgo.app.browser.favicon.FaviconManager
34+
import com.duckduckgo.app.browser.favicon.FaviconPersister
35+
import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister.Companion.FAVICON_PERSISTED_DIR
36+
import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister.Companion.NO_SUBFOLDER
3437
import com.duckduckgo.app.global.DuckDuckGoApplication
3538
import com.duckduckgo.app.global.view.generateDefaultDrawable
3639
import com.duckduckgo.common.utils.DispatcherProvider
3740
import com.duckduckgo.common.utils.domain
3841
import com.duckduckgo.savedsites.api.SavedSitesRepository
42+
import com.duckduckgo.savedsites.api.models.SavedSite
3943
import kotlinx.coroutines.flow.MutableStateFlow
4044
import kotlinx.coroutines.withContext
4145
import logcat.logcat
46+
import java.io.File
4247
import javax.inject.Inject
4348
import com.duckduckgo.mobile.android.R as CommonR
4449

@@ -59,6 +64,9 @@ class FavoritesWidgetItemFactory(
5964
@Inject
6065
lateinit var widgetPrefs: WidgetPreferences
6166

67+
@Inject
68+
lateinit var faviconPersister: FaviconPersister
69+
6270
@Inject
6371
lateinit var dispatchers: DispatcherProvider
6472

@@ -67,11 +75,7 @@ class FavoritesWidgetItemFactory(
6775
AppWidgetManager.INVALID_APPWIDGET_ID,
6876
)
6977

70-
private val faviconItemSize = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) {
71-
context.resources.getDimension(CommonR.dimen.savedSiteGridItemFavicon).toInt()
72-
} else {
73-
context.resources.getDimension(R.dimen.oldOsVersionSavedSiteGridItemFavicon).toInt()
74-
}
78+
private val faviconItemSize = context.resources.getDimension(CommonR.dimen.savedSiteGridItemFavicon).toInt()
7579
private val faviconItemCornerRadius = CommonR.dimen.searchWidgetFavoritesCornerRadius
7680

7781
private val maxItems: Int
@@ -82,7 +86,7 @@ class FavoritesWidgetItemFactory(
8286
data class WidgetFavorite(
8387
val title: String,
8488
val url: String,
85-
val bitmap: Bitmap?,
89+
val bitmapUri: Uri?,
8690
)
8791

8892
private val _widgetFavoritesFlow = MutableStateFlow<List<WidgetFavorite>>(emptyList())
@@ -100,31 +104,63 @@ class FavoritesWidgetItemFactory(
100104

101105
suspend fun updateWidgetFavoritesAsync() {
102106
runCatching {
103-
val latestWidgetFavorites = fetchFavoritesWithBitmaps()
107+
val latestWidgetFavorites = fetchFavoritesWithBitmapUris()
104108
_widgetFavoritesFlow.value = latestWidgetFavorites
105109
}.onFailure { error ->
106110
logcat { "Failed to update favorites in Search and Favorites widget: ${error.message}" }
107111
}
108112
}
109113

110-
private suspend fun fetchFavoritesWithBitmaps(): List<WidgetFavorite> {
114+
private suspend fun fetchFavoritesWithBitmapUris(): List<WidgetFavorite> {
111115
return withContext(dispatchers.io()) {
112-
val favorites = savedSitesRepository.getFavoritesSync().take(maxItems).map {
113-
val bitmap = faviconManager.loadFromDiskWithParams(
114-
url = it.url,
115-
cornerRadius = context.resources.getDimension(faviconItemCornerRadius).toInt(),
116-
width = faviconItemSize,
117-
height = faviconItemSize,
118-
) ?: generateDefaultDrawable(
119-
context = context,
120-
domain = it.url.extractDomain().orEmpty(),
121-
cornerRadius = faviconItemCornerRadius,
122-
).toBitmap(faviconItemSize, faviconItemSize)
123-
124-
WidgetFavorite(it.title, it.url, bitmap)
125-
}
126-
favorites
116+
savedSitesRepository
117+
.getFavoritesSync()
118+
.take(maxItems)
119+
.map { favorite ->
120+
favorite.toWidgetFavorite()
121+
}
122+
}
123+
}
124+
125+
/**
126+
* Converts a SavedSite.Favorite to a WidgetFavorite by ensuring we have a bitmap URI for the favicon.
127+
*/
128+
private suspend fun SavedSite.Favorite.toWidgetFavorite(): WidgetFavorite {
129+
val domain = url.extractDomain().orEmpty()
130+
131+
// step 1: check if any file (real favicon or placeholder) already exists on disk to avoid fetching/generating it again
132+
val existingFile = faviconPersister.faviconFile(
133+
directory = FAVICON_PERSISTED_DIR,
134+
subFolder = NO_SUBFOLDER,
135+
domain = domain,
136+
)
137+
var uri: Uri? = null
138+
139+
if (existingFile != null) {
140+
// found existing file on disk (favicon or placeholder) - use it without network call
141+
uri = existingFile.getContentUri()
142+
}
143+
if (uri != null) {
144+
return WidgetFavorite(
145+
title = title,
146+
url = url,
147+
bitmapUri = uri,
148+
)
127149
}
150+
151+
// step 2: generate and save placeholder
152+
val placeholderBitmap = generateDefaultDrawable(
153+
context = context,
154+
domain = domain,
155+
cornerRadius = faviconItemCornerRadius,
156+
).toBitmap(faviconItemSize, faviconItemSize)
157+
uri = faviconPersister.store(FAVICON_PERSISTED_DIR, NO_SUBFOLDER, placeholderBitmap, domain)?.getContentUri()
158+
159+
return WidgetFavorite(
160+
title = title,
161+
url = url,
162+
bitmapUri = uri,
163+
)
128164
}
129165

130166
override fun onDestroy() {
@@ -148,9 +184,9 @@ class FavoritesWidgetItemFactory(
148184
val remoteViews = RemoteViews(context.packageName, getItemLayout())
149185
if (item != null) {
150186
// This item has a favorite. Show the favorite view.
151-
if (item.bitmap != null) {
187+
if (item.bitmapUri != null) {
152188
remoteViews.setViewVisibility(R.id.quickAccessFavicon, View.VISIBLE)
153-
remoteViews.setImageViewBitmap(R.id.quickAccessFavicon, item.bitmap)
189+
remoteViews.setImageViewUri(R.id.quickAccessFavicon, item.bitmapUri)
154190
}
155191
remoteViews.setViewVisibility(R.id.quickAccessFaviconContainer, View.VISIBLE)
156192
remoteViews.setTextViewText(R.id.quickAccessTitle, item.title)
@@ -211,12 +247,53 @@ class FavoritesWidgetItemFactory(
211247
return true
212248
}
213249

250+
/**
251+
* Creates a content URI for the given file that can be used for loading an image in the widget via URI.
252+
*/
253+
private fun File.getContentUri(): Uri? = runCatching {
254+
FileProvider.getUriForFile(context, "${context.packageName}.$PROVIDER_SUFFIX", this).also { uri ->
255+
uri.grantPermissionsToWidget()
256+
}
257+
}.getOrNull()
258+
259+
/**
260+
* Grants URI read permissions to packages that need to display the widget.
261+
*
262+
* This is needed for the RemoteViews to load the images from the content URI.
263+
*/
264+
private fun Uri.grantPermissionsToWidget() {
265+
runCatching {
266+
// grant to system server which manages RemoteViews
267+
context.grantUriPermission(
268+
"android",
269+
this,
270+
Intent.FLAG_GRANT_READ_URI_PERMISSION,
271+
)
272+
273+
// grant to the current default launcher/home app
274+
val launcherIntent = Intent(Intent.ACTION_MAIN).apply {
275+
addCategory(Intent.CATEGORY_HOME)
276+
}
277+
val resolveInfo = context.packageManager.resolveActivity(launcherIntent, 0)
278+
resolveInfo?.activityInfo?.packageName?.let { launcherPackage ->
279+
context.grantUriPermission(
280+
launcherPackage,
281+
this,
282+
Intent.FLAG_GRANT_READ_URI_PERMISSION,
283+
)
284+
} ?: logcat { "Could not determine launcher package for URI permissions" }
285+
}.onFailure { error ->
286+
logcat { "Failed to grant URI permissions: ${error.message}" }
287+
}
288+
}
289+
214290
private fun inject(context: Context) {
215291
val application = context.applicationContext as DuckDuckGoApplication
216292
application.daggerAppComponent.inject(this)
217293
}
218294

219295
companion object {
220296
const val THEME_EXTRAS = "THEME_EXTRAS"
297+
private const val PROVIDER_SUFFIX = "provider"
221298
}
222299
}

app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,16 @@ class SearchAndFavoritesWidget : AppWidgetProvider() {
103103
appWidgetManager: AppWidgetManager,
104104
appWidgetIds: IntArray,
105105
) {
106+
// need to use goAsync since updating the widget may take some time
107+
// and without it onUpdate could be called multiple times at same time
108+
val pendingResult = goAsync()
106109
appCoroutineScope.launch {
107-
appWidgetIds.forEach { id ->
108-
updateWidget(context, appWidgetManager, id, null)
110+
try {
111+
appWidgetIds.forEach { id ->
112+
updateWidget(context, appWidgetManager, id, null)
113+
}
114+
} finally {
115+
pendingResult.finish()
109116
}
110117
}
111118
super.onUpdate(context, appWidgetManager, appWidgetIds)
@@ -118,8 +125,15 @@ class SearchAndFavoritesWidget : AppWidgetProvider() {
118125
newOptions: Bundle,
119126
) {
120127
logcat(INFO) { "SearchAndFavoritesWidget - onAppWidgetOptionsChanged" }
128+
// need to use goAsync since updating the widget may take some time
129+
// and without it onUpdate could be called multiple times at same time
130+
val pendingResult = goAsync()
121131
appCoroutineScope.launch {
122-
updateWidget(context, appWidgetManager, appWidgetId, newOptions)
132+
try {
133+
updateWidget(context, appWidgetManager, appWidgetId, newOptions)
134+
} finally {
135+
pendingResult.finish()
136+
}
123137
}
124138
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
125139
}

app/src/main/res/values/dimens.xml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,4 @@
2222
<dimen name="recyclerViewTwoFabsBottomPadding">136dp</dimen>
2323
<dimen name="extraLargeShapeCornerRadius">24dp</dimen>
2424
<bool name="show_wing_animation">false</bool>
25-
<dimen name="oldOsVersionSavedSiteGridItemFavicon">24dp</dimen>
26-
</resources>
25+
</resources>

app/src/main/res/xml/provider_paths.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@
2020
name="external_files"
2121
path="." />
2222
<cache-path name="sync" path="sync" />
23-
</paths>
23+
<cache-path name="favicons" path="favicons/" />
24+
</paths>

0 commit comments

Comments
 (0)