From b82f0eaa8eb88f7382dfa1f2367a3517d0f9e06e Mon Sep 17 00:00:00 2001 From: Domen Lanisnik Date: Wed, 5 Nov 2025 17:05:10 +0100 Subject: [PATCH 1/4] Fix widgets and image loading on Android 12 --- .../widget/FavoritesWidgetItemFactory.kt | 154 +++++++++++++++--- .../widget/SearchAndFavoritesWidget.kt | 4 + app/src/main/res/values/dimens.xml | 3 +- app/src/main/res/xml/provider_paths.xml | 3 +- 4 files changed, 134 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt index 249b3ba126c4..a4f8f3d73ed2 100644 --- a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt +++ b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt @@ -20,25 +20,32 @@ import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import android.os.Build +import android.net.Uri import android.os.Bundle import android.view.View import android.widget.RemoteViews import android.widget.RemoteViewsService +import androidx.core.content.FileProvider import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.browser.favicon.FaviconPersister +import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister.Companion.FAVICON_PERSISTED_DIR +import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister.Companion.NO_SUBFOLDER import com.duckduckgo.app.global.DuckDuckGoApplication import com.duckduckgo.app.global.view.generateDefaultDrawable import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.domain import com.duckduckgo.savedsites.api.SavedSitesRepository +import com.duckduckgo.savedsites.api.models.SavedSite +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import logcat.logcat +import java.io.File import javax.inject.Inject import com.duckduckgo.mobile.android.R as CommonR @@ -59,6 +66,9 @@ class FavoritesWidgetItemFactory( @Inject lateinit var widgetPrefs: WidgetPreferences + @Inject + lateinit var faviconPersister: FaviconPersister + @Inject lateinit var dispatchers: DispatcherProvider @@ -67,11 +77,7 @@ class FavoritesWidgetItemFactory( AppWidgetManager.INVALID_APPWIDGET_ID, ) - private val faviconItemSize = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) { - context.resources.getDimension(CommonR.dimen.savedSiteGridItemFavicon).toInt() - } else { - context.resources.getDimension(R.dimen.oldOsVersionSavedSiteGridItemFavicon).toInt() - } + private val faviconItemSize = context.resources.getDimension(CommonR.dimen.savedSiteGridItemFavicon).toInt() private val faviconItemCornerRadius = CommonR.dimen.searchWidgetFavoritesCornerRadius private val maxItems: Int @@ -82,7 +88,7 @@ class FavoritesWidgetItemFactory( data class WidgetFavorite( val title: String, val url: String, - val bitmap: Bitmap?, + val bitmapUri: Uri?, ) private val _widgetFavoritesFlow = MutableStateFlow>(emptyList()) @@ -100,33 +106,86 @@ class FavoritesWidgetItemFactory( suspend fun updateWidgetFavoritesAsync() { runCatching { - val latestWidgetFavorites = fetchFavoritesWithBitmaps() + val latestWidgetFavorites = fetchFavoritesWithBitmapUris() _widgetFavoritesFlow.value = latestWidgetFavorites }.onFailure { error -> logcat { "Failed to update favorites in Search and Favorites widget: ${error.message}" } } } - private suspend fun fetchFavoritesWithBitmaps(): List { + private suspend fun fetchFavoritesWithBitmapUris(): List { return withContext(dispatchers.io()) { - val favorites = savedSitesRepository.getFavoritesSync().take(maxItems).map { - val bitmap = faviconManager.loadFromDiskWithParams( - url = it.url, - cornerRadius = context.resources.getDimension(faviconItemCornerRadius).toInt(), - width = faviconItemSize, - height = faviconItemSize, - ) ?: generateDefaultDrawable( - context = context, - domain = it.url.extractDomain().orEmpty(), - cornerRadius = faviconItemCornerRadius, - ).toBitmap(faviconItemSize, faviconItemSize) - - WidgetFavorite(it.title, it.url, bitmap) - } - favorites + val deferredFavorites = savedSitesRepository + .getFavoritesSync() + .take(maxItems) + .map { favorite -> + async { + favorite.toWidgetFavorite() + } + } + deferredFavorites.awaitAll() } } + /** + * Converts a SavedSite.Favorite to a WidgetFavorite by ensuring we have a bitmap URI for the favicon. + */ + private suspend fun SavedSite.Favorite.toWidgetFavorite(): WidgetFavorite { + val domain = url.extractDomain().orEmpty() + + // step 1: check if any file (real favicon or placeholder) already exists on disk to avoid fetching/generating it again + val existingFile = faviconPersister.faviconFile( + directory = FAVICON_PERSISTED_DIR, + subFolder = NO_SUBFOLDER, + domain = domain, + ) + + var uri: Uri? = null + + if (existingFile != null) { + // found existing file on disk (favicon or placeholder) - use it without network call + uri = existingFile.getContentUri() + } + + if (uri != null) { + return WidgetFavorite( + title = title, + url = url, + bitmapUri = uri, + ) + } + + // step 2: No cached file or failed, try fetching real favicon from network + val fetchedFile = runCatching { faviconManager.tryFetchFaviconForUrl(url) }.getOrNull() + + if (fetchedFile != null) { + // successfully fetched real favicon from network + uri = fetchedFile.getContentUri() + } + + if (uri != null) { + return WidgetFavorite( + title = title, + url = url, + bitmapUri = uri, + ) + } + + // step 3: Network fetch failed, generate and save placeholder + val placeholderBitmap = generateDefaultDrawable( + context = context, + domain = domain, + cornerRadius = faviconItemCornerRadius, + ).toBitmap(faviconItemSize, faviconItemSize) + uri = faviconPersister.store(FAVICON_PERSISTED_DIR, NO_SUBFOLDER, placeholderBitmap, domain)?.getContentUri() + + return WidgetFavorite( + title = title, + url = url, + bitmapUri = uri, + ) + } + override fun onDestroy() { // no-op } @@ -148,9 +207,9 @@ class FavoritesWidgetItemFactory( val remoteViews = RemoteViews(context.packageName, getItemLayout()) if (item != null) { // This item has a favorite. Show the favorite view. - if (item.bitmap != null) { + if (item.bitmapUri != null) { remoteViews.setViewVisibility(R.id.quickAccessFavicon, View.VISIBLE) - remoteViews.setImageViewBitmap(R.id.quickAccessFavicon, item.bitmap) + remoteViews.setImageViewUri(R.id.quickAccessFavicon, item.bitmapUri) } remoteViews.setViewVisibility(R.id.quickAccessFaviconContainer, View.VISIBLE) remoteViews.setTextViewText(R.id.quickAccessTitle, item.title) @@ -211,6 +270,46 @@ class FavoritesWidgetItemFactory( return true } + /** + * Creates a content URI for the given file that can be used for loading an image in the widget via URI. + */ + private fun File.getContentUri(): Uri? = runCatching { + FileProvider.getUriForFile(context, "${context.packageName}.$PROVIDER_SUFFIX", this).also { uri -> + uri.grantPermissionsToWidget() + } + }.getOrNull() + + /** + * Grants URI read permissions to packages that need to display the widget. + * + * This is needed for the RemoteViews to load the images from the content URI. + */ + private fun Uri.grantPermissionsToWidget() { + runCatching { + // grant to system server which manages RemoteViews + context.grantUriPermission( + "android", + this, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + + // grant to the current default launcher/home app + val launcherIntent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + } + val resolveInfo = context.packageManager.resolveActivity(launcherIntent, 0) + resolveInfo?.activityInfo?.packageName?.let { launcherPackage -> + context.grantUriPermission( + launcherPackage, + this, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } ?: logcat { "Could not determine launcher package for URI permissions" } + }.onFailure { error -> + logcat { "Failed to grant URI permissions: ${error.message}" } + } + } + private fun inject(context: Context) { val application = context.applicationContext as DuckDuckGoApplication application.daggerAppComponent.inject(this) @@ -218,5 +317,6 @@ class FavoritesWidgetItemFactory( companion object { const val THEME_EXTRAS = "THEME_EXTRAS" + private const val PROVIDER_SUFFIX = "provider" } } diff --git a/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt index b463bdd64ad5..317b9ff7f994 100644 --- a/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt +++ b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt @@ -103,10 +103,14 @@ class SearchAndFavoritesWidget : AppWidgetProvider() { appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, ) { + // need to use goAsync since updating the widget may take some time + // and without it onUpdate could be called multiple times at same time + val pendingResult = goAsync() appCoroutineScope.launch { appWidgetIds.forEach { id -> updateWidget(context, appWidgetManager, id, null) } + pendingResult.finish() } super.onUpdate(context, appWidgetManager, appWidgetIds) } diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 2ce16eeece95..40f07fb74169 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -22,5 +22,4 @@ 136dp 24dp false - 24dp - \ No newline at end of file + diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml index 6941db37fd6b..19d0c53d6ad5 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -20,4 +20,5 @@ name="external_files" path="." /> - \ No newline at end of file + + From 73ba8df5980e9d3a6c4696399f6dd01f2efce5e8 Mon Sep 17 00:00:00 2001 From: Domen Lanisnik Date: Wed, 5 Nov 2025 17:35:37 +0100 Subject: [PATCH 2/4] Fix widgets and image loading on Android 12 --- .../widget/SearchAndFavoritesWidget.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt index 317b9ff7f994..d986e633ce07 100644 --- a/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt +++ b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt @@ -107,10 +107,13 @@ class SearchAndFavoritesWidget : AppWidgetProvider() { // and without it onUpdate could be called multiple times at same time val pendingResult = goAsync() appCoroutineScope.launch { - appWidgetIds.forEach { id -> - updateWidget(context, appWidgetManager, id, null) + try { + appWidgetIds.forEach { id -> + updateWidget(context, appWidgetManager, id, null) + } + } finally { + pendingResult.finish() } - pendingResult.finish() } super.onUpdate(context, appWidgetManager, appWidgetIds) } @@ -122,8 +125,15 @@ class SearchAndFavoritesWidget : AppWidgetProvider() { newOptions: Bundle, ) { logcat(INFO) { "SearchAndFavoritesWidget - onAppWidgetOptionsChanged" } + // need to use goAsync since updating the widget may take some time + // and without it onUpdate could be called multiple times at same time + val pendingResult = goAsync() appCoroutineScope.launch { - updateWidget(context, appWidgetManager, appWidgetId, newOptions) + try { + updateWidget(context, appWidgetManager, appWidgetId, newOptions) + } finally { + pendingResult.finish() + } } super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) } From 4216c0037562bf03019f72450fa119e7d64e51ff Mon Sep 17 00:00:00 2001 From: Domen Lanisnik Date: Thu, 6 Nov 2025 08:44:51 +0100 Subject: [PATCH 3/4] Simplify loading flow --- .../widget/FavoritesWidgetItemFactory.kt | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt index a4f8f3d73ed2..10caba2dd7f5 100644 --- a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt +++ b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt @@ -40,8 +40,6 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.domain import com.duckduckgo.savedsites.api.SavedSitesRepository import com.duckduckgo.savedsites.api.models.SavedSite -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import logcat.logcat @@ -119,11 +117,9 @@ class FavoritesWidgetItemFactory( .getFavoritesSync() .take(maxItems) .map { favorite -> - async { - favorite.toWidgetFavorite() - } + favorite.toWidgetFavorite() } - deferredFavorites.awaitAll() + deferredFavorites } } @@ -139,30 +135,12 @@ class FavoritesWidgetItemFactory( subFolder = NO_SUBFOLDER, domain = domain, ) - var uri: Uri? = null if (existingFile != null) { // found existing file on disk (favicon or placeholder) - use it without network call uri = existingFile.getContentUri() } - - if (uri != null) { - return WidgetFavorite( - title = title, - url = url, - bitmapUri = uri, - ) - } - - // step 2: No cached file or failed, try fetching real favicon from network - val fetchedFile = runCatching { faviconManager.tryFetchFaviconForUrl(url) }.getOrNull() - - if (fetchedFile != null) { - // successfully fetched real favicon from network - uri = fetchedFile.getContentUri() - } - if (uri != null) { return WidgetFavorite( title = title, @@ -171,7 +149,7 @@ class FavoritesWidgetItemFactory( ) } - // step 3: Network fetch failed, generate and save placeholder + // step 2: generate and save placeholder val placeholderBitmap = generateDefaultDrawable( context = context, domain = domain, From e0ba9dea0a46dc6cc13eedf415aa33d7cda8c075 Mon Sep 17 00:00:00 2001 From: Domen Lanisnik Date: Thu, 6 Nov 2025 08:46:48 +0100 Subject: [PATCH 4/4] Cleanup --- .../java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt index 10caba2dd7f5..177b0756e0a8 100644 --- a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt +++ b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt @@ -113,13 +113,12 @@ class FavoritesWidgetItemFactory( private suspend fun fetchFavoritesWithBitmapUris(): List { return withContext(dispatchers.io()) { - val deferredFavorites = savedSitesRepository + savedSitesRepository .getFavoritesSync() .take(maxItems) .map { favorite -> favorite.toWidgetFavorite() } - deferredFavorites } }