diff --git a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt index 249b3ba126c4..177b0756e0a8 100644 --- a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt +++ b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt @@ -20,25 +20,30 @@ 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.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 +64,9 @@ class FavoritesWidgetItemFactory( @Inject lateinit var widgetPrefs: WidgetPreferences + @Inject + lateinit var faviconPersister: FaviconPersister + @Inject lateinit var dispatchers: DispatcherProvider @@ -67,11 +75,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 +86,7 @@ class FavoritesWidgetItemFactory( data class WidgetFavorite( val title: String, val url: String, - val bitmap: Bitmap?, + val bitmapUri: Uri?, ) private val _widgetFavoritesFlow = MutableStateFlow>(emptyList()) @@ -100,31 +104,63 @@ 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 + savedSitesRepository + .getFavoritesSync() + .take(maxItems) + .map { favorite -> + favorite.toWidgetFavorite() + } + } + } + + /** + * 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: 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() { @@ -148,9 +184,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 +247,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 +294,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..d986e633ce07 100644 --- a/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt +++ b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt @@ -103,9 +103,16 @@ 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) + try { + appWidgetIds.forEach { id -> + updateWidget(context, appWidgetManager, id, null) + } + } finally { + pendingResult.finish() } } super.onUpdate(context, appWidgetManager, appWidgetIds) @@ -118,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) } 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 + +