@@ -20,25 +20,30 @@ import android.annotation.SuppressLint
2020import android.appwidget.AppWidgetManager
2121import android.content.Context
2222import android.content.Intent
23- import android.graphics.Bitmap
24- import android.os.Build
23+ import android.net.Uri
2524import android.os.Bundle
2625import android.view.View
2726import android.widget.RemoteViews
2827import android.widget.RemoteViewsService
28+ import androidx.core.content.FileProvider
2929import androidx.core.graphics.drawable.toBitmap
3030import androidx.core.net.toUri
3131import com.duckduckgo.app.browser.BrowserActivity
3232import com.duckduckgo.app.browser.R
3333import 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
3437import com.duckduckgo.app.global.DuckDuckGoApplication
3538import com.duckduckgo.app.global.view.generateDefaultDrawable
3639import com.duckduckgo.common.utils.DispatcherProvider
3740import com.duckduckgo.common.utils.domain
3841import com.duckduckgo.savedsites.api.SavedSitesRepository
42+ import com.duckduckgo.savedsites.api.models.SavedSite
3943import kotlinx.coroutines.flow.MutableStateFlow
4044import kotlinx.coroutines.withContext
4145import logcat.logcat
46+ import java.io.File
4247import javax.inject.Inject
4348import 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}
0 commit comments