Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 104 additions & 27 deletions app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -59,6 +64,9 @@ class FavoritesWidgetItemFactory(
@Inject
lateinit var widgetPrefs: WidgetPreferences

@Inject
lateinit var faviconPersister: FaviconPersister

@Inject
lateinit var dispatchers: DispatcherProvider

Expand All @@ -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
Expand All @@ -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<List<WidgetFavorite>>(emptyList())
Expand All @@ -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<WidgetFavorite> {
private suspend fun fetchFavoritesWithBitmapUris(): List<WidgetFavorite> {
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() {
Expand All @@ -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)
Expand Down Expand Up @@ -211,12 +247,53 @@ 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)
}

companion object {
const val THEME_EXTRAS = "THEME_EXTRAS"
private const val PROVIDER_SUFFIX = "provider"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
3 changes: 1 addition & 2 deletions app/src/main/res/values/dimens.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,4 @@
<dimen name="recyclerViewTwoFabsBottomPadding">136dp</dimen>
<dimen name="extraLargeShapeCornerRadius">24dp</dimen>
<bool name="show_wing_animation">false</bool>
<dimen name="oldOsVersionSavedSiteGridItemFavicon">24dp</dimen>
</resources>
</resources>
3 changes: 2 additions & 1 deletion app/src/main/res/xml/provider_paths.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@
name="external_files"
path="." />
<cache-path name="sync" path="sync" />
</paths>
<cache-path name="favicons" path="favicons/" />
</paths>
Loading