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 + +