Update widget to use RemoteCollectionItems

pull/4097/head
Alex Baker 1 week ago
parent 29a4210fae
commit cdc4febe6f

@ -181,6 +181,7 @@ dependencies {
implementation(libs.androidx.hilt.navigation)
implementation(libs.androidx.hilt.work)
implementation(libs.androidx.core.remoteviews)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.datastore)
implementation(libs.androidx.fragment.compose)

@ -340,8 +340,9 @@
<!-- ======================================================== Services = -->
<service
android:name=".widget.TasksWidgetAdapter"
android:permission="android.permission.BIND_REMOTEVIEWS"/>
android:name="androidx.core.widget.RemoteViewsCompatService"
android:permission="android.permission.BIND_REMOTEVIEWS"
android:exported="false"/>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"

@ -71,7 +71,7 @@ class LocalBroadcastManager @Inject constructor(
}
fun reconfigureWidget(appWidgetId: Int) {
appWidgetManager.reconfigureWidgets(appWidgetId)
appWidgetManager.rebuildWidgets(appWidgetId)
}
companion object {

@ -6,7 +6,6 @@ import android.app.ApplicationExitInfo
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
@ -42,7 +41,6 @@ import org.tasks.scheduling.NotificationSchedulerIntentService
import org.tasks.sync.SyncAdapters
import org.tasks.themes.ThemeBase
import org.tasks.time.DateTimeUtils2.currentTimeMillis
import org.tasks.widget.AppWidgetManager
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -58,7 +56,6 @@ class TasksApplication : Application(), Configuration.Provider {
@Inject lateinit var upgrader: Lazy<Upgrader>
@Inject lateinit var workManager: Lazy<WorkManager>
@Inject lateinit var geofenceApi: Lazy<GeofenceApi>
@Inject lateinit var appWidgetManager: Lazy<AppWidgetManager>
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var contentObserver: Lazy<OpenTaskContentObserver>
@Inject lateinit var syncAdapters: Lazy<SyncAdapters>
@ -139,7 +136,6 @@ class TasksApplication : Application(), Configuration.Provider {
}
OpenTaskContentObserver.registerObserver(context, contentObserver.get())
geofenceApi.get().registerAll()
appWidgetManager.get().reconfigureWidgets()
CaldavSynchronizer.registerFactories()
}

@ -2,12 +2,23 @@ package org.tasks.extensions
import android.graphics.Paint.ANTI_ALIAS_FLAG
import android.graphics.Paint.STRIKE_THRU_TEXT_FLAG
import android.os.Parcel
import android.widget.RemoteViews
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.core.graphics.ColorUtils
import org.tasks.R
fun RemoteViews.estimateParcelSize(): Int {
val parcel = Parcel.obtain()
return try {
writeToParcel(parcel, 0)
parcel.dataSize()
} finally {
parcel.recycle()
}
}
fun RemoteViews.setColorFilter(viewId: Int, @ColorInt color: Int) =
setInt(viewId, "setColorFilter", color)

@ -10,11 +10,9 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.tasks.R
import org.tasks.compose.throttleLatest
import org.tasks.injection.ApplicationScope
import timber.log.Timber
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
@ -26,24 +24,13 @@ class AppWidgetManager @Inject constructor(
private val appWidgetManager: AppWidgetManager? by lazy {
AppWidgetManager.getInstance(context)
}
private val _generation = AtomicLong(0)
val generation: Long get() = _generation.get()
private val updateChannel = Channel<Long>(Channel.CONFLATED)
private val updateChannel = Channel<Unit>(Channel.CONFLATED)
init {
updateChannel
.consumeAsFlow()
.throttleLatest(1000)
.onEach { gen ->
if (gen == _generation.get()) {
val appWidgetIds = widgetIds
Timber.d("updateWidgets: ${appWidgetIds.joinToString { it.toString() }}")
appWidgetManager?.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.list_view)
} else {
Timber.d("Skipping stale widget update")
}
}
.onEach { rebuildWidgets() }
.launchIn(scope)
}
@ -52,10 +39,9 @@ class AppWidgetManager @Inject constructor(
?.getAppWidgetIds(ComponentName(context, TasksWidget::class.java))
?: intArrayOf()
fun reconfigureWidgets(vararg appWidgetIds: Int) {
val newGeneration = _generation.incrementAndGet()
fun rebuildWidgets(vararg appWidgetIds: Int) {
val ids = appWidgetIds.takeIf { it.isNotEmpty() } ?: widgetIds
Timber.d("reconfigureWidgets(${ids.joinToString()}) generation=$newGeneration")
Timber.d("rebuildWidgets(${ids.joinToString()})")
val intent = Intent(context, TasksWidget::class.java)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
.apply { action = AppWidgetManager.ACTION_APPWIDGET_UPDATE }
@ -63,7 +49,7 @@ class AppWidgetManager @Inject constructor(
}
fun updateWidgets() {
updateChannel.trySend(_generation.get())
updateChannel.trySend(Unit)
}
fun exists(id: Int) = appWidgetManager?.getAppWidgetInfo(id) != null

@ -24,7 +24,7 @@ class RequestPinWidgetReceiver : BroadcastReceiver() {
val widgetPreferences = WidgetPreferences(context, preferences, widgetId)
widgetPreferences.setFilter(filter)
widgetPreferences.setColor(color)
appWidgetManager.reconfigureWidgets(widgetId)
appWidgetManager.rebuildWidgets(widgetId)
}
companion object {

@ -6,23 +6,29 @@ import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.format.Formatter
import android.view.View
import android.widget.RemoteViews
import androidx.core.net.toUri
import androidx.core.widget.RemoteViewsCompat
import com.todoroo.andlib.utility.AndroidUtilities.atLeastS
import com.todoroo.astrid.activity.MainActivity.Companion.FINISH_AFFINITY
import com.todoroo.astrid.subtasks.SubtasksHelper
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.runBlocking
import org.tasks.R
import org.tasks.compose.FilterSelectionActivity
import org.tasks.data.dao.TaskDao
import org.tasks.extensions.estimateParcelSize
import org.tasks.extensions.setBackgroundColor
import org.tasks.extensions.setColorFilter
import org.tasks.extensions.setRipple
import org.tasks.filters.Filter
import org.tasks.intents.TaskIntents
import org.tasks.markdown.MarkdownProvider
import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.Preferences
import org.tasks.tasklist.HeaderFormatter
import org.tasks.themes.ThemeColor
import timber.log.Timber
import javax.inject.Inject
@ -32,15 +38,20 @@ class TasksWidget : AppWidgetProvider() {
@Inject lateinit var preferences: Preferences
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider
@Inject @ApplicationContext lateinit var context: Context
@Inject lateinit var widgetManager: org.tasks.widget.AppWidgetManager
@Inject lateinit var subtasksHelper: SubtasksHelper
@Inject lateinit var taskDao: TaskDao
@Inject lateinit var chipProvider: WidgetChipProvider
@Inject lateinit var markdownProvider: MarkdownProvider
@Inject lateinit var headerFormatter: HeaderFormatter
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
Timber.d("onUpdate appWidgetIds=${appWidgetIds.joinToString { it.toString() }}")
val generation = widgetManager.generation
appWidgetIds.forEach { id ->
try {
val options = appWidgetManager.getAppWidgetOptions(id)
appWidgetManager.updateAppWidget(id, createWidget(context, id, options, generation))
val remoteViews = createWidget(context, id, options)
Timber.d("Widget $id main layout: ${Formatter.formatShortFileSize(context, remoteViews.estimateParcelSize().toLong())}")
appWidgetManager.updateAppWidget(id, remoteViews)
} catch (e: Exception) {
Timber.e(e)
}
@ -54,24 +65,24 @@ class TasksWidget : AppWidgetProvider() {
newOptions: Bundle
) {
Timber.d("onAppWidgetOptionsChanged appWidgetId=$appWidgetId")
appWidgetManager.updateAppWidget(
appWidgetId,
createWidget(context, appWidgetId, newOptions, widgetManager.generation)
)
val remoteViews = createWidget(context, appWidgetId, newOptions)
Timber.d("Widget $appWidgetId main layout: ${Formatter.formatShortFileSize(context, remoteViews.estimateParcelSize().toLong())}")
appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
}
private fun createWidget(context: Context, id: Int, options: Bundle, generation: Long): RemoteViews {
private fun createWidget(context: Context, id: Int, options: Bundle): RemoteViews {
val widgetPreferences = WidgetPreferences(context, preferences, id)
val settings = widgetPreferences.getWidgetHeaderSettings()
widgetPreferences.setCompact(
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH) < COMPACT_MAX
)
val filterId = widgetPreferences.filterId
val filter = runBlocking {
defaultFilterProvider.getFilterFromPreference(widgetPreferences.filterId)
defaultFilterProvider.getFilterFromPreference(filterId)
}
Timber.d("createWidget id=$id generation=$generation filter=$filter")
Timber.d("createWidget id=$id filter=$filter")
return RemoteViews(context.packageName, R.layout.scrollable_widget).apply {
val remoteViews = RemoteViews(context.packageName, R.layout.scrollable_widget).apply {
if (settings.showHeader) {
setViewVisibility(R.id.widget_header, View.VISIBLE)
setupHeader(settings, filter, id)
@ -89,15 +100,30 @@ class TasksWidget : AppWidgetProvider() {
opacity = widgetPreferences.footerOpacity,
)
setOnClickPendingIntent(R.id.empty_view, getOpenListIntent(context, filter, id))
val cacheBuster = "tasks://widget/$id/$generation".toUri()
setRemoteAdapter(
R.id.list_view,
Intent(context, TasksWidgetAdapter::class.java)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
.setData(cacheBuster)
)
setPendingIntentTemplate(R.id.list_view, getPendingIntentTemplate(context))
}
val builder = TasksWidgetBuilder(
subtasksHelper = subtasksHelper,
widgetPreferences = widgetPreferences,
filter = filter,
context = context,
widgetId = id,
taskDao = taskDao,
chipProvider = chipProvider,
markdown = markdownProvider.markdown(false),
headerFormatter = headerFormatter,
)
val items = runBlocking { builder.buildItems() }
RemoteViewsCompat.setRemoteAdapter(
context = context,
remoteViews = remoteViews,
appWidgetId = id,
viewId = R.id.list_view,
items = items
)
return remoteViews
}
private fun RemoteViews.setupHeader(

@ -1,51 +0,0 @@
package org.tasks.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.widget.RemoteViewsService
import com.todoroo.astrid.subtasks.SubtasksHelper
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.runBlocking
import org.tasks.data.dao.TaskDao
import org.tasks.markdown.MarkdownProvider
import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.Preferences
import org.tasks.tasklist.HeaderFormatter
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class TasksWidgetAdapter : RemoteViewsService() {
@ApplicationContext @Inject lateinit var context: Context
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider
@Inject lateinit var taskDao: TaskDao
@Inject lateinit var preferences: Preferences
@Inject lateinit var subtasksHelper: SubtasksHelper
@Inject lateinit var chipProvider: WidgetChipProvider
@Inject lateinit var markdownProvider: MarkdownProvider
@Inject lateinit var headerFormatter: HeaderFormatter
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory? {
val widgetId = intent.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID) ?: return null
val widgetPreferences = WidgetPreferences(context, preferences, widgetId)
val filterId = widgetPreferences.filterId
val filter = runBlocking {
defaultFilterProvider.getFilterFromPreference(filterId)
}
Timber.d("onGetViewFactory $filter")
return TasksWidgetViewFactory(
subtasksHelper,
widgetPreferences,
filter,
filterId,
applicationContext,
widgetId,
taskDao,
chipProvider,
markdownProvider.markdown(false),
headerFormatter,
)
}
}

@ -2,10 +2,10 @@ package org.tasks.widget
import android.content.Context
import android.content.Intent
import android.text.format.Formatter
import android.view.View
import android.widget.RemoteViews
import android.widget.RemoteViewsService.RemoteViewsFactory
import com.todoroo.andlib.utility.AndroidUtilities.atLeastAndroid16
import androidx.core.widget.RemoteViewsCompat.RemoteCollectionItems
import com.todoroo.astrid.core.SortHelper
import com.todoroo.astrid.subtasks.SubtasksHelper
import kotlinx.coroutines.runBlocking
@ -18,6 +18,7 @@ import org.tasks.data.hasNotes
import org.tasks.data.isHidden
import org.tasks.data.isOverdue
import org.tasks.extensions.Context.is24HourFormat
import org.tasks.extensions.estimateParcelSize
import org.tasks.extensions.setBackgroundResource
import org.tasks.extensions.setColorFilter
import org.tasks.extensions.setMaxLines
@ -40,26 +41,23 @@ import org.tasks.ui.CheckBoxProvider.Companion.getCheckboxRes
import timber.log.Timber
import kotlin.math.max
internal class TasksWidgetViewFactory(
internal class TasksWidgetBuilder(
private val subtasksHelper: SubtasksHelper,
private val widgetPreferences: WidgetPreferences,
private val filter: Filter,
private val filterId: String?,
private val context: Context,
private val widgetId: Int,
private val taskDao: TaskDao,
private val chipProvider: WidgetChipProvider,
private val markdown: Markdown,
private val headerFormatter: HeaderFormatter,
) : RemoteViewsFactory {
private val taskLimit = if (atLeastAndroid16()) 50 + 1 else Int.MAX_VALUE
) {
private val indentPadding = (20 * context.resources.displayMetrics.density).toInt()
private val settings = widgetPreferences.getWidgetListSettings()
private val hPad = context.resources.getDimension(R.dimen.widget_padding).toInt()
private val disableGroups = !filter.supportsSorting()
|| (filter.supportsManualSort() && widgetPreferences.isManualSort)
|| (filter is AstridOrderingFilter && widgetPreferences.isAstridSort)
private var tasks = SectionedDataSource()
private val onSurface = context.getColor(if (settings.isDark) R.color.white_87 else R.color.black_87)
private val onSurfaceVariant = context.getColor(if (settings.isDark) R.color.white_60 else R.color.black_60)
@ -67,70 +65,6 @@ internal class TasksWidgetViewFactory(
chipProvider.isDark = settings.isDark
}
override fun onCreate() {
Timber.d("onCreate widgetId:$widgetId filter:$filter")
}
override fun onDataSetChanged() {
Timber.v("onDataSetChanged $filter")
if (widgetPreferences.filterId != filterId) {
Timber.d("Skipping stale factory: expected $filterId, current ${widgetPreferences.filterId}")
return
}
try {
runBlocking {
val collapsed = widgetPreferences.collapsed
tasks = SectionedDataSource(
taskDao.fetchTasks(getQuery(filter)),
disableGroups,
settings.groupMode,
widgetPreferences.subtaskMode,
collapsed,
widgetPreferences.completedTasksAtBottom,
)
collapsed.toMutableSet().let {
if (it.retainAll(tasks.getSectionValues().toSet())) {
widgetPreferences.collapsed = it
}
}
}
} catch (e: InterruptedException) {
Timber.w("Widget update interrupted")
}
}
override fun onDestroy() {
Timber.d("onDestroy widgetId:$widgetId")
}
override fun getCount() = tasks.size.coerceAtMost(taskLimit)
override fun getViewAt(position: Int): RemoteViews? = tasks.let {
when {
position == taskLimit - 1 && it.size > taskLimit -> buildFooter()
it.isHeader(position) -> buildHeader(it.getSection(position))
position < it.size -> buildUpdate(it.getItem(position))
else -> null
}
}
override fun getLoadingView(): RemoteViews = newRemoteView()
override fun getViewTypeCount(): Int = 3
override fun getItemId(position: Int) = tasks.let {
when {
position == taskLimit - 1 && it.size > taskLimit -> 0
it.isHeader(position) -> it.getSection(position).value
position < it.size -> it.getItem(position).id
else -> 0
}
}
override fun hasStableIds(): Boolean = true
private fun newRemoteView() = RemoteViews(BuildConfig.APPLICATION_ID, R.layout.widget_row)
private fun buildFooter(): RemoteViews {
return RemoteViews(BuildConfig.APPLICATION_ID, R.layout.widget_footer).apply {
setTextSize(R.id.widget_view_more, settings.textSize)
@ -196,7 +130,7 @@ internal class TasksWidgetViewFactory(
!settings.showDueDates && task.isOverdue -> context.getColor(R.color.overdue)
else -> onSurface
}
newRemoteView().apply {
RemoteViews(BuildConfig.APPLICATION_ID, R.layout.widget_row).apply {
strikethrough(R.id.widget_text, task.isCompleted)
setTextSize(R.id.widget_text, settings.textSize)
if (settings.showDueDates) {
@ -304,8 +238,7 @@ internal class TasksWidgetViewFactory(
private suspend fun getQuery(filter: Filter): String {
subtasksHelper.applySubtasksToWidgetFilter(filter, widgetPreferences)
val limit = if (taskLimit == Int.MAX_VALUE) null else taskLimit
return getQuery(widgetPreferences, filter, limit)
return getQuery(widgetPreferences, filter, MAX_ITEMS)
}
private fun formatDueDate(row: RemoteViews, task: TaskContainer) = with(row) {
@ -354,4 +287,72 @@ internal class TasksWidgetViewFactory(
setViewVisibility(dueDateRes, View.GONE)
}
}
suspend fun buildItems(): RemoteCollectionItems {
var totalParcelSize = 0
val collapsed = widgetPreferences.collapsed
val tasks = SectionedDataSource(
taskDao.fetchTasks(getQuery(filter)),
disableGroups,
settings.groupMode,
widgetPreferences.subtaskMode,
collapsed,
widgetPreferences.completedTasksAtBottom,
)
collapsed.toMutableSet().let {
if (it.retainAll(tasks.getSectionValues().toSet())) {
widgetPreferences.collapsed = it
}
}
Timber.d("buildItems loaded ${tasks.size} items for widget $widgetId")
data class WidgetItem(val id: Long, val view: RemoteViews, val isHeader: Boolean)
val items = mutableListOf<WidgetItem>()
var truncatedDueToSize = false
for (position in 0 until tasks.size) {
val isHeader = tasks.isHeader(position)
val id = if (isHeader) {
tasks.getSection(position).value
} else {
tasks.getItem(position).id
}
val view = if (isHeader) {
buildHeader(tasks.getSection(position))
} else {
buildUpdate(tasks.getItem(position))
} ?: continue
val viewSize = view.estimateParcelSize()
if (totalParcelSize + viewSize > MAX_PARCEL_SIZE) {
Timber.d("Stopping at position $position due to size limit")
truncatedDueToSize = true
break
}
items.add(WidgetItem(id, view, isHeader))
totalParcelSize += viewSize
}
val builder = RemoteCollectionItems.Builder()
.setHasStableIds(true)
.setViewTypeCount(VIEW_TYPE_COUNT)
items.forEach { builder.addItem(it.id, it.view) }
if (truncatedDueToSize) {
builder.addItem(FOOTER_ID, buildFooter())
}
Timber.d("Built ${items.size} items, totalSize=${Formatter.formatShortFileSize(context, totalParcelSize.toLong())}, truncated=$truncatedDueToSize")
return builder.build()
}
companion object {
const val VIEW_TYPE_COUNT = 3
const val MAX_ITEMS = 100
const val MAX_PARCEL_SIZE = 200_000 // 200KB
const val FOOTER_ID = 0L
}
}

@ -1369,6 +1369,10 @@
+| | \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.20 -> 2.2.21 (c)
+| +--- com.google.dagger:hilt-android:2.54 -> 2.57.2 (*)
+| \--- org.jspecify:jspecify:1.0.0
++--- androidx.core:core-remoteviews:1.1.0
+| +--- androidx.annotation:annotation:1.2.0 -> 1.9.1 (*)
+| +--- androidx.core:core:1.8.0 -> 1.16.0 (*)
+| \--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 2.2.21 (*)
++--- androidx.core:core-splashscreen:1.2.0
+| +--- androidx.annotation:annotation:1.8.1 -> 1.9.1 (*)
+| +--- androidx.appcompat:appcompat-resources:1.7.0 -> 1.7.1 (*)

@ -1761,6 +1761,10 @@
+| | \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.20 -> 2.2.21 (c)
+| +--- com.google.dagger:hilt-android:2.54 -> 2.57.2 (*)
+| \--- org.jspecify:jspecify:1.0.0
++--- androidx.core:core-remoteviews:1.1.0
+| +--- androidx.annotation:annotation:1.2.0 -> 1.9.1 (*)
+| +--- androidx.core:core:1.8.0 -> 1.16.0 (*)
+| \--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 2.2.21 (*)
++--- androidx.core:core-splashscreen:1.2.0
+| +--- androidx.annotation:annotation:1.8.1 -> 1.9.1 (*)
+| +--- androidx.appcompat:appcompat-resources:1.7.0 -> 1.7.1 (*)

@ -70,6 +70,7 @@ androidx-compose = { module = "androidx.compose:compose-bom", version.ref = "com
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout-android", version = "1.2.0" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
androidx-core-remoteviews = { group = "androidx.core", name = "core-remoteviews", version = "1.1.0" }
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.2.0" }
androidx-datastore = { module = "androidx.datastore:datastore-preferences", version = "1.2.0" }
androidx-fragment-compose = { module = "androidx.fragment:fragment-compose", version = "1.8.9" }

Loading…
Cancel
Save