diff --git a/app/build.gradle b/app/build.gradle
index f99de7ae9e30..bb37abc9c0b8 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -417,6 +417,9 @@ dependencies {
implementation project(':serp-logos-api')
implementation project(':serp-logos-impl')
+ // Widgets
+ implementation "androidx.core:core-remoteviews:1.1.0"
+
// Deprecated. TODO: Stop using this artifact.
implementation "androidx.legacy:legacy-support-v4:_"
debugImplementation Square.leakCanary.android
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9edd4ea70d19..6b38a0440670 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -488,16 +488,6 @@
android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity"
/>
-
-
-
-
{
fun inject(searchOnlyWidget: SearchOnlyWidget)
- fun inject(searchAndFavsWidget: SearchAndFavoritesWidget)
+ fun inject(searchAndFavoritesWidget: SearchAndFavoritesWidget)
- fun inject(favoritesWidgetItemFactory: FavoritesWidgetService.FavoritesWidgetItemFactory)
+ fun inject(favoritesWidgetItemFactory: FavoritesWidgetItemFactory)
- fun inject(emptyFavoritesWidgetItemFactory: EmptyFavoritesWidgetService.EmptyFavoritesWidgetItemFactory)
+ fun inject(emptyFavoritesWidgetItemFactory: EmptyFavoritesWidgetItemFactory)
// accessor to Retrofit instance for test only only for test
@Named("api")
diff --git a/app/src/main/java/com/duckduckgo/app/widget/FavoritesObserver.kt b/app/src/main/java/com/duckduckgo/app/widget/FavoritesObserver.kt
index b308f7de7187..b5641fff05c0 100644
--- a/app/src/main/java/com/duckduckgo/app/widget/FavoritesObserver.kt
+++ b/app/src/main/java/com/duckduckgo/app/widget/FavoritesObserver.kt
@@ -19,9 +19,9 @@ package com.duckduckgo.app.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
+import android.content.Intent
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.coroutineScope
-import com.duckduckgo.app.browser.R
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
@@ -33,7 +33,7 @@ import javax.inject.Inject
@SingleInstanceIn(AppScope::class)
class FavoritesObserver @Inject constructor(
- context: Context,
+ private val context: Context,
private val savedSitesRepository: SavedSitesRepository,
private val dispatcherProvider: DispatcherProvider,
) : MainProcessLifecycleObserver {
@@ -47,8 +47,13 @@ class FavoritesObserver @Inject constructor(
owner.lifecycle.coroutineScope.launch(dispatcherProvider.io()) {
appWidgetManager?.let { instance ->
savedSitesRepository.getFavorites().collect {
- instance.notifyAppWidgetViewDataChanged(instance.getAppWidgetIds(componentName), R.id.favoritesGrid)
- instance.notifyAppWidgetViewDataChanged(instance.getAppWidgetIds(componentName), R.id.emptyfavoritesGrid)
+ val appWidgetIds = instance.getAppWidgetIds(componentName)
+ if (appWidgetIds.isNotEmpty()) {
+ val updateIntent = Intent(context, SearchAndFavoritesWidget::class.java)
+ updateIntent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
+ updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
+ context.sendBroadcast(updateIntent)
+ }
}
}
}
diff --git a/app/src/main/java/com/duckduckgo/widget/EmptyFavoritesWidgetItemFactory.kt b/app/src/main/java/com/duckduckgo/widget/EmptyFavoritesWidgetItemFactory.kt
index e69de29bb2d1..aeb96085cd18 100644
--- a/app/src/main/java/com/duckduckgo/widget/EmptyFavoritesWidgetItemFactory.kt
+++ b/app/src/main/java/com/duckduckgo/widget/EmptyFavoritesWidgetItemFactory.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2021 DuckDuckGo
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.duckduckgo.widget
+
+import android.content.Context
+import android.widget.RemoteViews
+import android.widget.RemoteViewsService
+import com.duckduckgo.app.browser.R
+import com.duckduckgo.app.global.DuckDuckGoApplication
+import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.savedsites.api.SavedSitesRepository
+import kotlinx.coroutines.withContext
+import logcat.logcat
+import javax.inject.Inject
+
+/**
+ * This RemoteViewsFactory will not render any item. It's used for convenience to simplify executing background operations to show/hide empty widget CTA.
+ * If this RemoteViewsFactory count is 0, SearchAndFavoritesWidget R.id.emptyFavoritesGrid will show the configured EmptyView.
+ */
+class EmptyFavoritesWidgetItemFactory(
+ val context: Context,
+) : RemoteViewsService.RemoteViewsFactory {
+
+ @Inject
+ lateinit var savedSitesRepository: SavedSitesRepository
+
+ @Inject
+ lateinit var dispatchers: DispatcherProvider
+
+ private var count = 0
+
+ override fun onCreate() {
+ inject(context)
+ }
+
+ override fun onDataSetChanged() {
+ // no-op, we use our own update mechanism
+ }
+
+ suspend fun updateEmptyWidgetFavoritesAsync() {
+ runCatching {
+ count = getItemsCountFromFavorites()
+ }.onFailure { error ->
+ logcat { "Failed to update Search and Favorites widget when empty: ${error.message}" }
+ }
+ }
+
+ override fun onDestroy() {
+ // no-op
+ }
+
+ override fun getCount(): Int {
+ return count
+ }
+
+ override fun getViewAt(position: Int): RemoteViews {
+ return RemoteViews(context.packageName, R.layout.empty_view)
+ }
+
+ override fun getLoadingView(): RemoteViews {
+ return RemoteViews(context.packageName, R.layout.empty_view)
+ }
+
+ override fun getViewTypeCount(): Int {
+ return 1
+ }
+
+ override fun getItemId(position: Int): Long {
+ return position.toLong()
+ }
+
+ override fun hasStableIds(): Boolean {
+ return true
+ }
+
+ private suspend fun getItemsCountFromFavorites(): Int {
+ return withContext(dispatchers.io()) {
+ if (savedSitesRepository.hasFavorites()) 1 else 0
+ }
+ }
+
+ private fun inject(context: Context) {
+ val application = context.applicationContext as DuckDuckGoApplication
+ application.daggerAppComponent.inject(this)
+ }
+}
diff --git a/app/src/main/java/com/duckduckgo/widget/EmptyFavoritesWidgetService.kt b/app/src/main/java/com/duckduckgo/widget/EmptyFavoritesWidgetService.kt
deleted file mode 100644
index b575d9f76163..000000000000
--- a/app/src/main/java/com/duckduckgo/widget/EmptyFavoritesWidgetService.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Copyright (c) 2021 DuckDuckGo
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.duckduckgo.widget
-
-import android.content.Context
-import android.content.Intent
-import android.widget.RemoteViews
-import android.widget.RemoteViewsService
-import com.duckduckgo.app.browser.R
-import com.duckduckgo.app.global.DuckDuckGoApplication
-import com.duckduckgo.savedsites.api.SavedSitesRepository
-import javax.inject.Inject
-
-class EmptyFavoritesWidgetService : RemoteViewsService() {
-
- companion object {
- const val MAX_ITEMS_EXTRAS = "MAX_ITEMS_EXTRAS"
- }
-
- override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
- return EmptyFavoritesWidgetItemFactory(this.applicationContext, intent)
- }
-
- /**
- * This RemoteViewsFactory will not render any item. It's used by is used for convenience to simplify executing background operations to show/hide empty widget CTA.
- * If this RemoteViewsFactory count is 0, SearchAndFavoritesWidget R.id.emptyfavoritesGrid will show the configured EmptyView.
- */
- class EmptyFavoritesWidgetItemFactory(
- val context: Context,
- intent: Intent,
- ) : RemoteViewsFactory {
-
- @Inject
- lateinit var savedSitesRepository: SavedSitesRepository
-
- private var count = 0
-
- override fun onCreate() {
- inject(context)
- }
-
- override fun onDataSetChanged() {
- count = if (savedSitesRepository.hasFavorites()) 1 else 0
- }
-
- override fun onDestroy() {
- }
-
- override fun getCount(): Int {
- return count
- }
-
- override fun getViewAt(position: Int): RemoteViews {
- return RemoteViews(context.packageName, R.layout.empty_view)
- }
-
- override fun getLoadingView(): RemoteViews {
- return RemoteViews(context.packageName, R.layout.empty_view)
- }
-
- override fun getViewTypeCount(): Int {
- return 1
- }
-
- override fun getItemId(position: Int): Long {
- return position.toLong()
- }
-
- override fun hasStableIds(): Boolean {
- return true
- }
-
- private fun inject(context: Context) {
- val application = context.applicationContext as DuckDuckGoApplication
- application.daggerAppComponent.inject(this)
- }
- }
-}
diff --git a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt
index e69de29bb2d1..177b0756e0a8 100644
--- a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt
+++ b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetItemFactory.kt
@@ -0,0 +1,299 @@
+/*
+ * Copyright (c) 2021 DuckDuckGo
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.duckduckgo.widget
+
+import android.annotation.SuppressLint
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.Intent
+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
+
+@SuppressLint("DenyListedApi")
+class FavoritesWidgetItemFactory(
+ val context: Context,
+ intent: Intent,
+) : RemoteViewsService.RemoteViewsFactory {
+
+ private val theme = WidgetTheme.getThemeFrom(intent.extras?.getString(THEME_EXTRAS))
+
+ @Inject
+ lateinit var savedSitesRepository: SavedSitesRepository
+
+ @Inject
+ lateinit var faviconManager: FaviconManager
+
+ @Inject
+ lateinit var widgetPrefs: WidgetPreferences
+
+ @Inject
+ lateinit var faviconPersister: FaviconPersister
+
+ @Inject
+ lateinit var dispatchers: DispatcherProvider
+
+ private val appWidgetId = intent.getIntExtra(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID,
+ )
+
+ private val faviconItemSize = context.resources.getDimension(CommonR.dimen.savedSiteGridItemFavicon).toInt()
+ private val faviconItemCornerRadius = CommonR.dimen.searchWidgetFavoritesCornerRadius
+
+ private val maxItems: Int
+ get() {
+ return widgetPrefs.widgetSize(appWidgetId).let { it.first * it.second }
+ }
+
+ data class WidgetFavorite(
+ val title: String,
+ val url: String,
+ val bitmapUri: Uri?,
+ )
+
+ private val _widgetFavoritesFlow = MutableStateFlow>(emptyList())
+
+ private val currentFavorites: List
+ get() = _widgetFavoritesFlow.value
+
+ override fun onCreate() {
+ inject(context)
+ }
+
+ override fun onDataSetChanged() {
+ // no-op, we use our own update mechanism
+ }
+
+ suspend fun updateWidgetFavoritesAsync() {
+ runCatching {
+ val latestWidgetFavorites = fetchFavoritesWithBitmapUris()
+ _widgetFavoritesFlow.value = latestWidgetFavorites
+ }.onFailure { error ->
+ logcat { "Failed to update favorites in Search and Favorites widget: ${error.message}" }
+ }
+ }
+
+ private suspend fun fetchFavoritesWithBitmapUris(): List {
+ return withContext(dispatchers.io()) {
+ 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() {
+ // no-op
+ }
+
+ override fun getCount(): Int {
+ return maxItems
+ }
+
+ private fun String.extractDomain(): String? {
+ return if (this.startsWith("http")) {
+ this.toUri().domain()
+ } else {
+ "https://$this".extractDomain()
+ }
+ }
+
+ override fun getViewAt(position: Int): RemoteViews {
+ val item = if (position >= currentFavorites.size) null else currentFavorites[position]
+ val remoteViews = RemoteViews(context.packageName, getItemLayout())
+ if (item != null) {
+ // This item has a favorite. Show the favorite view.
+ if (item.bitmapUri != null) {
+ remoteViews.setViewVisibility(R.id.quickAccessFavicon, View.VISIBLE)
+ remoteViews.setImageViewUri(R.id.quickAccessFavicon, item.bitmapUri)
+ }
+ remoteViews.setViewVisibility(R.id.quickAccessFaviconContainer, View.VISIBLE)
+ remoteViews.setTextViewText(R.id.quickAccessTitle, item.title)
+ remoteViews.setViewVisibility(R.id.quickAccessTitle, View.VISIBLE)
+ remoteViews.setViewVisibility(R.id.placeholderFavicon, View.GONE)
+ configureClickListener(remoteViews, item.url)
+ } else {
+ if (currentFavorites.isEmpty()) {
+ // We don't have any favorites, show placeholder view.
+ remoteViews.setViewVisibility(R.id.quickAccessFaviconContainer, View.VISIBLE)
+ remoteViews.setViewVisibility(R.id.quickAccessFavicon, View.GONE)
+ remoteViews.setViewVisibility(R.id.placeholderFavicon, View.VISIBLE)
+ } else {
+ // We had at least one favorite, but not in this view. Don't show anything.
+ remoteViews.setViewVisibility(R.id.quickAccessFaviconContainer, View.INVISIBLE)
+ }
+ remoteViews.setViewVisibility(R.id.quickAccessTitle, View.GONE)
+ }
+
+ return remoteViews
+ }
+
+ private fun getItemLayout(): Int {
+ return when (theme) {
+ WidgetTheme.LIGHT -> R.layout.view_favorite_widget_light_item
+ WidgetTheme.DARK -> R.layout.view_favorite_widget_dark_item
+ WidgetTheme.SYSTEM_DEFAULT -> R.layout.view_favorite_widget_daynight_item
+ }
+ }
+
+ private fun configureClickListener(
+ remoteViews: RemoteViews,
+ item: String,
+ ) {
+ val bundle = Bundle()
+ bundle.putString(Intent.EXTRA_TEXT, item)
+ bundle.putBoolean(BrowserActivity.NEW_SEARCH_EXTRA, false)
+ bundle.putBoolean(BrowserActivity.LAUNCH_FROM_FAVORITES_WIDGET, true)
+ bundle.putBoolean(BrowserActivity.NOTIFY_DATA_CLEARED_EXTRA, false)
+ val intent = Intent()
+ intent.putExtras(bundle)
+ remoteViews.setOnClickFillInIntent(R.id.quickAccessFaviconContainer, intent)
+ }
+
+ override fun getLoadingView(): RemoteViews {
+ return RemoteViews(context.packageName, getItemLayout())
+ }
+
+ override fun getViewTypeCount(): Int {
+ return 1
+ }
+
+ override fun getItemId(position: Int): Long {
+ return position.toLong()
+ }
+
+ override fun hasStableIds(): Boolean {
+ 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"
+ }
+}
diff --git a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetService.kt b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetService.kt
deleted file mode 100644
index 5568c838b8ea..000000000000
--- a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetService.kt
+++ /dev/null
@@ -1,285 +0,0 @@
-/*
- * Copyright (c) 2021 DuckDuckGo
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.duckduckgo.widget
-
-import android.appwidget.AppWidgetManager
-import android.content.Context
-import android.content.Intent
-import android.graphics.Bitmap
-import android.os.Bundle
-import android.view.View
-import android.widget.RemoteViews
-import android.widget.RemoteViewsService
-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.di.AppCoroutineScope
-import com.duckduckgo.app.global.DuckDuckGoApplication
-import com.duckduckgo.app.global.view.generateDefaultDrawable
-import com.duckduckgo.common.utils.ConflatedJob
-import com.duckduckgo.common.utils.DispatcherProvider
-import com.duckduckgo.common.utils.domain
-import com.duckduckgo.savedsites.api.SavedSitesRepository
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.logcat
-import javax.inject.Inject
-import com.duckduckgo.mobile.android.R as CommonR
-
-class FavoritesWidgetService : RemoteViewsService() {
-
- companion object {
- const val THEME_EXTRAS = "THEME_EXTRAS"
- }
-
- override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
- return FavoritesWidgetItemFactory(this.applicationContext, intent)
- }
-
- class FavoritesWidgetItemFactory(
- val context: Context,
- intent: Intent,
- ) : RemoteViewsFactory {
-
- private val theme = WidgetTheme.getThemeFrom(intent.extras?.getString(THEME_EXTRAS))
-
- @Inject
- lateinit var savedSitesRepository: SavedSitesRepository
-
- @Inject
- lateinit var faviconManager: FaviconManager
-
- @Inject
- lateinit var widgetPrefs: WidgetPreferences
-
- @Inject
- @AppCoroutineScope
- lateinit var appCoroutineScope: CoroutineScope
-
- @Inject
- lateinit var dispatchers: DispatcherProvider
-
- private val appWidgetId = intent.getIntExtra(
- AppWidgetManager.EXTRA_APPWIDGET_ID,
- AppWidgetManager.INVALID_APPWIDGET_ID,
- )
-
- private val faviconItemSize = context.resources.getDimension(CommonR.dimen.savedSiteGridItemFavicon).toInt()
- private val faviconItemCornerRadius = CommonR.dimen.searchWidgetFavoritesCornerRadius
-
- private val maxItems: Int
- get() {
- return widgetPrefs.widgetSize(appWidgetId).let { it.first * it.second }
- }
-
- data class WidgetFavorite(
- val title: String,
- val url: String,
- val bitmap: Bitmap?,
- )
-
- private val _widgetFavoritesFlow = MutableStateFlow>(emptyList())
-
- private val currentFavorites: List
- get() = _widgetFavoritesFlow.value
-
- private val updateJob = ConflatedJob()
- private val updateDebounceTimeMs = 200L
-
- override fun onCreate() {
- inject(context)
- }
-
- override fun onDataSetChanged() {
- updateJob += appCoroutineScope.launch {
- delay(updateDebounceTimeMs)
- updateWidgetFavoritesAsync()
- }
- }
-
- private suspend fun updateWidgetFavoritesAsync() {
- runCatching {
- val latestWidgetFavorites = fetchFavoritesWithBitmaps()
-
- if (isFavoritesDataChanged(currentFavorites, latestWidgetFavorites)) {
- logcat { "Widget favorites data has changed, updating widget view" }
-
- _widgetFavoritesFlow.value = latestWidgetFavorites
-
- withContext(dispatchers.main()) {
- notifyWidgetDataChanged()
- }
- }
- }.onFailure { error ->
- logcat { "Failed to update favorites for widget: ${error.message}" }
- }
- }
-
- private fun notifyWidgetDataChanged() {
- val updateIntent = Intent(context, SearchAndFavoritesWidget::class.java)
- updateIntent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
- updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId))
- context.sendBroadcast(updateIntent)
- }
-
- private suspend fun fetchFavoritesWithBitmaps(): 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
- }
- }
-
- private fun isFavoritesDataChanged(oldList: List, newList: List): Boolean {
- if (oldList.size != newList.size) {
- logcat { "isFavoritesDataChanged: list sizes differ" }
- return true
- }
-
- val oldMap = oldList.associateBy { it.url }
- val newMap = newList.associateBy { it.url }
- if (oldMap.keys != newMap.keys) {
- logcat { "isFavoritesDataChanged: different URLs in lists" }
- return true
- }
-
- for ((url, oldItem) in oldMap) {
- val newItem = newMap[url] ?: continue
- if (oldItem.title != newItem.title) {
- logcat { "isFavoritesDataChanged: title changed for $url" }
- return true
- }
- }
-
- oldList.indices.forEach { i ->
- if (oldList[i].url != newList[i].url) {
- logcat { "isFavoritesDataChanged: order changed" }
- return true
- }
- }
-
- return false
- }
-
- override fun onDestroy() {
- _widgetFavoritesFlow.value = emptyList()
- updateJob.cancel()
- }
-
- override fun getCount(): Int {
- return maxItems
- }
-
- private fun String.extractDomain(): String? {
- return if (this.startsWith("http")) {
- this.toUri().domain()
- } else {
- "https://$this".extractDomain()
- }
- }
-
- override fun getViewAt(position: Int): RemoteViews {
- val item = if (position >= currentFavorites.size) null else currentFavorites[position]
- val remoteViews = RemoteViews(context.packageName, getItemLayout())
- if (item != null) {
- // This item has a favorite. Show the favorite view.
- if (item.bitmap != null) {
- remoteViews.setViewVisibility(R.id.quickAccessFavicon, View.VISIBLE)
- remoteViews.setImageViewBitmap(R.id.quickAccessFavicon, item.bitmap)
- }
- remoteViews.setViewVisibility(R.id.quickAccessFaviconContainer, View.VISIBLE)
- remoteViews.setTextViewText(R.id.quickAccessTitle, item.title)
- remoteViews.setViewVisibility(R.id.quickAccessTitle, View.VISIBLE)
- remoteViews.setViewVisibility(R.id.placeholderFavicon, View.GONE)
- configureClickListener(remoteViews, item.url)
- } else {
- if (currentFavorites.isEmpty()) {
- // We don't have any favorites, show placeholder view.
- remoteViews.setViewVisibility(R.id.quickAccessFaviconContainer, View.VISIBLE)
- remoteViews.setViewVisibility(R.id.quickAccessFavicon, View.GONE)
- remoteViews.setViewVisibility(R.id.placeholderFavicon, View.VISIBLE)
- } else {
- // We had at least one favorite, but not in this view. Don't show anything.
- remoteViews.setViewVisibility(R.id.quickAccessFaviconContainer, View.INVISIBLE)
- }
- remoteViews.setViewVisibility(R.id.quickAccessTitle, View.GONE)
- }
-
- return remoteViews
- }
-
- private fun getItemLayout(): Int {
- return when (theme) {
- WidgetTheme.LIGHT -> R.layout.view_favorite_widget_light_item
- WidgetTheme.DARK -> R.layout.view_favorite_widget_dark_item
- WidgetTheme.SYSTEM_DEFAULT -> R.layout.view_favorite_widget_daynight_item
- }
- }
-
- private fun configureClickListener(
- remoteViews: RemoteViews,
- item: String,
- ) {
- val bundle = Bundle()
- bundle.putString(Intent.EXTRA_TEXT, item)
- bundle.putBoolean(BrowserActivity.NEW_SEARCH_EXTRA, false)
- bundle.putBoolean(BrowserActivity.LAUNCH_FROM_FAVORITES_WIDGET, true)
- bundle.putBoolean(BrowserActivity.NOTIFY_DATA_CLEARED_EXTRA, false)
- val intent = Intent()
- intent.putExtras(bundle)
- remoteViews.setOnClickFillInIntent(R.id.quickAccessFaviconContainer, intent)
- }
-
- override fun getLoadingView(): RemoteViews {
- return RemoteViews(context.packageName, getItemLayout())
- }
-
- override fun getViewTypeCount(): Int {
- return 1
- }
-
- override fun getItemId(position: Int): Long {
- return position.toLong()
- }
-
- override fun hasStableIds(): Boolean {
- return true
- }
-
- private fun inject(context: Context) {
- val application = context.applicationContext as DuckDuckGoApplication
- application.daggerAppComponent.inject(this)
- }
- }
-}
diff --git a/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesGridCalculator.kt b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesGridCalculator.kt
index 196e347d57f4..46d231228775 100644
--- a/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesGridCalculator.kt
+++ b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesGridCalculator.kt
@@ -31,17 +31,20 @@ class SearchAndFavoritesGridCalculator {
val margins = context.resources.getDimension(CommonR.dimen.searchWidgetFavoriteMargin).toDp()
val item = context.resources.getDimension(CommonR.dimen.searchWidgetFavoriteItemContainerWidth).toDp()
val divider = context.resources.getDimension(CommonR.dimen.searchWidgetFavoritesHorizontalSpacing).toDp()
+
+ // Buffer to prevent column count from changing with small size variations.
+ val sizeBuffer = 8
var n = 2
var totalSize = (n * item) + ((n - 1) * divider) + (margins * 2)
logcat(INFO) { "SearchAndFavoritesWidget width n:$n $totalSize vs $width" }
- while (totalSize <= width) {
+ while (totalSize + sizeBuffer <= width) {
++n
totalSize = (n * item) + ((n - 1) * divider) + (margins * 2)
logcat(INFO) { "SearchAndFavoritesWidget width n:$n $totalSize vs $width" }
}
- return WIDGET_COLUMNS_MIN.coerceAtLeast(n - 1).coerceAtMost(limitColumns)
+ return (n - 1).coerceIn(WIDGET_COLUMNS_MIN, limitColumns)
}
fun calculateRows(
@@ -53,20 +56,20 @@ class SearchAndFavoritesGridCalculator {
(context.resources.getDimension(CommonR.dimen.searchWidgetPadding).toDp() * 2)
val item = context.resources.getDimension(CommonR.dimen.searchWidgetFavoriteItemContainerHeight).toDp()
val divider = context.resources.getDimension(CommonR.dimen.searchWidgetFavoritesVerticalSpacing).toDp()
+
+ // Buffer to prevent row count from changing with small size variations.
+ val sizeBuffer = 8
var n = 1
var totalSize = searchBar + (n * item) + ((n - 1) * divider) + margins
logcat(INFO) { "SearchAndFavoritesWidget height n:$n $totalSize vs $height" }
- while (totalSize <= height) {
+ while (totalSize + sizeBuffer <= height) {
++n
totalSize = searchBar + (n * item) + ((n - 1) * divider) + margins
logcat(INFO) { "SearchAndFavoritesWidget height n:$n $totalSize vs $height" }
}
- var rows = n - 1
- rows = WIDGET_ROWS_MIN.coerceAtLeast(rows)
- rows = WIDGET_ROWS_MAX.coerceAtMost(rows)
- return rows
+ return (n - 1).coerceIn(WIDGET_ROWS_MIN, WIDGET_ROWS_MAX)
}
companion object {
diff --git a/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt
index fa3deeacb6fb..dc9fc701559b 100644
--- a/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt
+++ b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt
@@ -22,10 +22,10 @@ import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
-import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.RemoteViews
+import androidx.core.widget.RemoteViewsCompat
import com.duckduckgo.app.browser.BrowserActivity
import com.duckduckgo.app.browser.R
import com.duckduckgo.app.di.AppCoroutineScope
@@ -35,7 +35,7 @@ import com.duckduckgo.app.pixels.AppPixelName.SEARCH_AND_FAVORITES_WIDGET_DELETE
import com.duckduckgo.app.systemsearch.SystemSearchActivity
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.common.utils.DispatcherProvider
-import com.duckduckgo.widget.FavoritesWidgetService.Companion.THEME_EXTRAS
+import com.duckduckgo.widget.FavoritesWidgetItemFactory.Companion.THEME_EXTRAS
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -103,10 +103,16 @@ class SearchAndFavoritesWidget : AppWidgetProvider() {
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
- logcat(INFO) { "SearchAndFavoritesWidget - onUpdate" }
+ // 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)
@@ -119,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)
}
@@ -154,7 +167,7 @@ class SearchAndFavoritesWidget : AppWidgetProvider() {
logcat(INFO) { "SearchAndFavoritesWidget theme for $appWidgetId is $widgetTheme" }
val (columns, rows) = getCurrentWidgetSize(context, appWidgetManager.getAppWidgetOptions(appWidgetId), newOptions)
- layoutId = getLayoutThemed(columns, widgetTheme)
+ layoutId = getLayoutThemed(widgetTheme)
withContext(dispatchers.io()) {
widgetPrefs.storeWidgetSize(appWidgetId, columns, rows)
@@ -171,50 +184,19 @@ class SearchAndFavoritesWidget : AppWidgetProvider() {
fromFavWidget = true,
)
configureFavoritesGridView(context, appWidgetId, remoteViews, widgetTheme)
- configureEmptyWidgetCta(context, appWidgetId, remoteViews, widgetTheme)
+ configureEmptyWidgetCta(context, appWidgetId, remoteViews)
appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
- appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.favoritesGrid)
- appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.emptyfavoritesGrid)
}
}
private fun getLayoutThemed(
- numColumns: Int,
theme: WidgetTheme,
): Int {
- // numcolumns method is not available for remoteViews. We rely on different xml to use different values on that attribute
return when (theme) {
- WidgetTheme.LIGHT -> {
- when (numColumns) {
- 2 -> R.layout.search_favorites_widget_light_col2
- 3 -> R.layout.search_favorites_widget_light_col3
- 4 -> R.layout.search_favorites_widget_light_col4
- 5 -> R.layout.search_favorites_widget_light_col5
- 6 -> R.layout.search_favorites_widget_light_col6
- else -> R.layout.search_favorites_widget_light_auto
- }
- }
- WidgetTheme.DARK -> {
- when (numColumns) {
- 2 -> R.layout.search_favorites_widget_dark_col2
- 3 -> R.layout.search_favorites_widget_dark_col3
- 4 -> R.layout.search_favorites_widget_dark_col4
- 5 -> R.layout.search_favorites_widget_dark_col5
- 6 -> R.layout.search_favorites_widget_dark_col6
- else -> R.layout.search_favorites_widget_dark_auto
- }
- }
- WidgetTheme.SYSTEM_DEFAULT -> {
- when (numColumns) {
- 2 -> R.layout.search_favorites_widget_daynight_col2
- 3 -> R.layout.search_favorites_widget_daynight_col3
- 4 -> R.layout.search_favorites_widget_daynight_col4
- 5 -> R.layout.search_favorites_widget_daynight_col5
- 6 -> R.layout.search_favorites_widget_daynight_col6
- else -> R.layout.search_favorites_widget_daynight_auto
- }
- }
+ WidgetTheme.LIGHT -> R.layout.search_favorites_widget_light_auto
+ WidgetTheme.DARK -> R.layout.search_favorites_widget_dark_auto
+ WidgetTheme.SYSTEM_DEFAULT -> R.layout.search_favorites_widget_daynight_auto
}
}
@@ -247,14 +229,14 @@ class SearchAndFavoritesWidget : AppWidgetProvider() {
portraitHeight
}
- var columns = gridCalculator.calculateColumns(context, width)
- var rows = gridCalculator.calculateRows(context, height)
+ val columns = gridCalculator.calculateColumns(context, width)
+ val rows = gridCalculator.calculateRows(context, height)
logcat(INFO) { "SearchAndFavoritesWidget $portraitWidth x $portraitHeight -> $columns x $rows" }
return Pair(columns, rows)
}
- private fun configureFavoritesGridView(
+ private suspend fun configureFavoritesGridView(
context: Context,
appWidgetId: Int,
remoteViews: RemoteViews,
@@ -264,32 +246,19 @@ class SearchAndFavoritesWidget : AppWidgetProvider() {
val pendingIntentFlags = if (appBuildConfig.sdkInt >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
val favoriteClickPendingIntent = PendingIntent.getActivity(context, 0, favoriteItemClickIntent, pendingIntentFlags)
- val extras = Bundle()
- extras.putInt(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
- extras.putString(THEME_EXTRAS, widgetTheme.toString())
-
- val adapterIntent = Intent(context, FavoritesWidgetService::class.java)
- adapterIntent.putExtras(extras)
- adapterIntent.data = Uri.parse(adapterIntent.toUri(Intent.URI_INTENT_SCHEME))
- remoteViews.setRemoteAdapter(R.id.favoritesGrid, adapterIntent)
+ val items = buildRemoteCollectionItems(context, appWidgetId, widgetTheme)
+ RemoteViewsCompat.setRemoteAdapter(context, remoteViews, appWidgetId, R.id.favoritesGrid, items)
remoteViews.setPendingIntentTemplate(R.id.favoritesGrid, favoriteClickPendingIntent)
}
- private fun configureEmptyWidgetCta(
+ private suspend fun configureEmptyWidgetCta(
context: Context,
appWidgetId: Int,
remoteViews: RemoteViews,
- widgetTheme: WidgetTheme,
) {
- val extras = Bundle()
- extras.putInt(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
- extras.putString(THEME_EXTRAS, widgetTheme.toString())
-
- val emptyAdapterIntent = Intent(context, EmptyFavoritesWidgetService::class.java)
- emptyAdapterIntent.putExtras(extras)
- emptyAdapterIntent.data = Uri.parse(emptyAdapterIntent.toUri(Intent.URI_INTENT_SCHEME))
- remoteViews.setEmptyView(R.id.emptyfavoritesGrid, R.id.emptyGridViewContainer)
- remoteViews.setRemoteAdapter(R.id.emptyfavoritesGrid, emptyAdapterIntent)
+ val items = buildRemoteEmptyCollectionItems(context)
+ remoteViews.setEmptyView(R.id.emptyFavoritesGrid, R.id.emptyGridViewContainer)
+ RemoteViewsCompat.setRemoteAdapter(context, remoteViews, appWidgetId, R.id.emptyFavoritesGrid, items)
}
private fun buildPendingIntent(context: Context): PendingIntent {
@@ -307,6 +276,52 @@ class SearchAndFavoritesWidget : AppWidgetProvider() {
application.daggerAppComponent.inject(this)
}
+ private suspend fun buildRemoteCollectionItems(
+ context: Context,
+ appWidgetId: Int,
+ widgetTheme: WidgetTheme,
+ ): RemoteViewsCompat.RemoteCollectionItems {
+ val factory = FavoritesWidgetItemFactory(
+ context,
+ Intent().apply {
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ putExtra(THEME_EXTRAS, widgetTheme.toString())
+ },
+ )
+ factory.onCreate()
+ factory.updateWidgetFavoritesAsync()
+
+ val builder = RemoteViewsCompat.RemoteCollectionItems.Builder()
+ val count = factory.count
+ for (i in 0 until count) {
+ val itemId = factory.getItemId(i)
+ val remoteView = factory.getViewAt(i)
+ builder.addItem(itemId, remoteView)
+ }
+ factory.onDestroy()
+ return builder.build()
+ }
+
+ private suspend fun buildRemoteEmptyCollectionItems(
+ context: Context,
+ ): RemoteViewsCompat.RemoteCollectionItems {
+ val factory = EmptyFavoritesWidgetItemFactory(
+ context,
+ )
+ factory.onCreate()
+ factory.updateEmptyWidgetFavoritesAsync()
+
+ val builder = RemoteViewsCompat.RemoteCollectionItems.Builder()
+ val count = factory.count
+ for (i in 0 until count) {
+ val itemId = factory.getItemId(i)
+ val remoteView = factory.getViewAt(i)
+ builder.addItem(itemId, remoteView)
+ }
+ factory.onDestroy()
+ return builder.build()
+ }
+
companion object {
private const val SEARCH_AND_FAVORITES_WIDGET_REQUEST_CODE = 1540
}
diff --git a/app/src/main/res/layout/search_favorites_widget_dark_auto.xml b/app/src/main/res/layout/search_favorites_widget_dark_auto.xml
index bbf24a994e52..e8eb587e44fb 100644
--- a/app/src/main/res/layout/search_favorites_widget_dark_auto.xml
+++ b/app/src/main/res/layout/search_favorites_widget_dark_auto.xml
@@ -30,7 +30,7 @@
android:layout_marginEnd="@dimen/searchWidgetFavoritesSideMargin">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_dark_col3.xml b/app/src/main/res/layout/search_favorites_widget_dark_col3.xml
deleted file mode 100644
index afede1f74f08..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_dark_col3.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_dark_col4.xml b/app/src/main/res/layout/search_favorites_widget_dark_col4.xml
deleted file mode 100644
index 6a00f3d416fe..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_dark_col4.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_dark_col5.xml b/app/src/main/res/layout/search_favorites_widget_dark_col5.xml
deleted file mode 100644
index 6951e02fc709..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_dark_col5.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_dark_col6.xml b/app/src/main/res/layout/search_favorites_widget_dark_col6.xml
deleted file mode 100644
index 25547e3490cc..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_dark_col6.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_daynight_auto.xml b/app/src/main/res/layout/search_favorites_widget_daynight_auto.xml
index 3539cdc7e34e..d49d06632dbe 100644
--- a/app/src/main/res/layout/search_favorites_widget_daynight_auto.xml
+++ b/app/src/main/res/layout/search_favorites_widget_daynight_auto.xml
@@ -30,7 +30,7 @@
android:layout_marginEnd="@dimen/searchWidgetFavoritesSideMargin">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_daynight_col3.xml b/app/src/main/res/layout/search_favorites_widget_daynight_col3.xml
deleted file mode 100644
index 8a6075b3aa6d..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_daynight_col3.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_daynight_col4.xml b/app/src/main/res/layout/search_favorites_widget_daynight_col4.xml
deleted file mode 100644
index 1bbed9428d3f..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_daynight_col4.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_daynight_col5.xml b/app/src/main/res/layout/search_favorites_widget_daynight_col5.xml
deleted file mode 100644
index 647a97f8d2c5..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_daynight_col5.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_daynight_col6.xml b/app/src/main/res/layout/search_favorites_widget_daynight_col6.xml
deleted file mode 100644
index f328ea92962c..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_daynight_col6.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_light_auto.xml b/app/src/main/res/layout/search_favorites_widget_light_auto.xml
index a9dfd94c8fa1..ff033878246a 100644
--- a/app/src/main/res/layout/search_favorites_widget_light_auto.xml
+++ b/app/src/main/res/layout/search_favorites_widget_light_auto.xml
@@ -30,7 +30,7 @@
android:layout_marginEnd="@dimen/searchWidgetFavoritesSideMargin">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_light_col3.xml b/app/src/main/res/layout/search_favorites_widget_light_col3.xml
deleted file mode 100644
index 39510e8a00c3..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_light_col3.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_light_col4.xml b/app/src/main/res/layout/search_favorites_widget_light_col4.xml
deleted file mode 100644
index f6829276120f..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_light_col4.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_light_col5.xml b/app/src/main/res/layout/search_favorites_widget_light_col5.xml
deleted file mode 100644
index 8f6157d6368d..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_light_col5.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/search_favorites_widget_light_col6.xml b/app/src/main/res/layout/search_favorites_widget_light_col6.xml
deleted file mode 100644
index d2316b30f1f5..000000000000
--- a/app/src/main/res/layout/search_favorites_widget_light_col6.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 0328e79db49a..40f07fb74169 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -22,4 +22,4 @@
136dp
24dp
false
-
\ 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
+
+