From 0caf8bf68d828a8633063856a1e5c22001e3b553 Mon Sep 17 00:00:00 2001 From: Jose Fuentes Castillo <10594577+j-fuentes@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:54:14 +0200 Subject: [PATCH] feat(notifications): optional single summary notification + launcher badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in mode that groups every new article found during a sync into a single notification instead of one notification per article. - NotificationHelper buffers new articles while a sync runs; SyncWorker flushes them at the end into one InboxStyle notification showing the count and a list of 'feed · title' (up to 8 lines + 'and N more') - Launcher icon badge via setNumber() / BADGE_ICON_SMALL on a channel with showBadge enabled (numeric on launchers that support it, dot otherwise) - New preferences notificationSummary (default off, keeps current behaviour) and notificationBadge, under Settings > Usage > Notifications - Respects the existing per-feed 'Allow notifications' flag and the account sync interval --- .../ash/reader/domain/service/SyncWorker.kt | 4 + .../android/NotificationHelper.kt | 118 +++++++++++++++++- .../preference/NotificationBadgePreference.kt | 39 ++++++ .../NotificationSummaryPreference.kt | 39 ++++++ .../infrastructure/preference/Preference.kt | 4 + .../infrastructure/preference/Settings.kt | 4 + .../preference/SettingsProvider.kt | 4 + .../java/me/ash/reader/ui/ext/DataStoreExt.kt | 10 ++ .../settings/interaction/InteractionPage.kt | 35 ++++++ app/src/main/res/values/strings.xml | 15 +++ 10 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/me/ash/reader/infrastructure/preference/NotificationBadgePreference.kt create mode 100644 app/src/main/java/me/ash/reader/infrastructure/preference/NotificationSummaryPreference.kt diff --git a/app/src/main/java/me/ash/reader/domain/service/SyncWorker.kt b/app/src/main/java/me/ash/reader/domain/service/SyncWorker.kt index deedee18b..860508eec 100644 --- a/app/src/main/java/me/ash/reader/domain/service/SyncWorker.kt +++ b/app/src/main/java/me/ash/reader/domain/service/SyncWorker.kt @@ -7,6 +7,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.util.concurrent.TimeUnit import me.ash.reader.domain.model.account.Account +import me.ash.reader.infrastructure.android.NotificationHelper import me.ash.reader.infrastructure.rss.ReaderCacheHelper @HiltWorker @@ -18,6 +19,7 @@ constructor( private val rssService: RssService, private val readerCacheHelper: ReaderCacheHelper, private val workManager: WorkManager, + private val notificationHelper: NotificationHelper, ) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { @@ -31,6 +33,8 @@ constructor( .get() .sync(accountId = accountId, feedId = feedId, groupId = groupId) .also { + // Post a single notification summarizing every new article of this sync. + notificationHelper.flushSummary() rssService.get().clearKeepArchivedArticles().forEach { readerCacheHelper.deleteCacheFor(articleId = it.id) } diff --git a/app/src/main/java/me/ash/reader/infrastructure/android/NotificationHelper.kt b/app/src/main/java/me/ash/reader/infrastructure/android/NotificationHelper.kt index 7b0898dc4..f46e832a1 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/android/NotificationHelper.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/android/NotificationHelper.kt @@ -16,6 +16,8 @@ import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import me.ash.reader.R import me.ash.reader.domain.model.article.Article @@ -23,6 +25,9 @@ import me.ash.reader.domain.model.feed.Feed import me.ash.reader.domain.model.feed.FeedWithArticle import me.ash.reader.infrastructure.di.ApplicationScope import me.ash.reader.infrastructure.di.IODispatcher +import me.ash.reader.ui.ext.DataStoreKey +import me.ash.reader.ui.ext.dataStore +import me.ash.reader.ui.ext.get import me.ash.reader.ui.page.common.ExtraName import me.ash.reader.ui.page.common.NotificationGroupName import timber.log.Timber @@ -39,17 +44,117 @@ constructor( NotificationManagerCompat.from(context).apply { createNotificationChannel( NotificationChannel( - NotificationGroupName.ARTICLE_UPDATE, - NotificationGroupName.ARTICLE_UPDATE, - NotificationManager.IMPORTANCE_DEFAULT, - ) + NotificationGroupName.ARTICLE_UPDATE, + NotificationGroupName.ARTICLE_UPDATE, + NotificationManager.IMPORTANCE_DEFAULT, + ) + .apply { setShowBadge(true) } ) } + /** Articles collected during the current sync, waiting to be summarized. */ + private val pendingMutex = Mutex() + private val pending = mutableListOf>() + + private val isSummaryEnabled: Boolean + get() = context.dataStore.get(DataStoreKey.notificationSummary) ?: false + + private val isBadgeEnabled: Boolean + get() = context.dataStore.get(DataStoreKey.notificationBadge) ?: true + fun notify(feed: Feed, articles: List
) { if (!notificationManager.areNotificationsEnabled()) return if (articles.isEmpty()) return if (!feed.isNotification) return + + if (isSummaryEnabled) { + coroutineScope.launch { + pendingMutex.withLock { articles.forEach { pending.add(feed to it) } } + } + return + } + + notifyPerFeed(feed, articles) + } + + fun notify(feedWithArticle: FeedWithArticle) { + notify(feedWithArticle.feed, feedWithArticle.articles) + } + + /** + * Posts a single notification summarizing every new article collected since the last flush, + * and updates the launcher icon badge count. Called at the end of a sync. + */ + fun flushSummary() { + if (!isSummaryEnabled) return + coroutineScope.launch { + val items = pendingMutex.withLock { pending.toList().also { pending.clear() } } + if (items.isEmpty()) return@launch + if (!notificationManager.areNotificationsEnabled()) return@launch + + Timber.d("notify summary for ${items.size} articles") + + val count = items.size + val feedCount = items.map { it.first.id }.distinct().size + val style = NotificationCompat.InboxStyle() + items.take(MAX_SUMMARY_LINES).forEach { (feed, article) -> + style.addLine("${feed.name} · ${article.title}") + } + if (count > MAX_SUMMARY_LINES) { + style.setSummaryText( + context.getString(R.string.notification_summary_more, count - MAX_SUMMARY_LINES) + ) + } else { + style.setSummaryText( + context.resources.getQuantityString( + R.plurals.notification_summary_feeds, + feedCount, + feedCount, + ) + ) + } + + val builder = + NotificationCompat.Builder(context, NotificationGroupName.ARTICLE_UPDATE) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle( + context.resources.getQuantityString( + R.plurals.notification_summary_title, + count, + count, + ) + ) + .setContentText(items.take(3).joinToString(" · ") { it.second.title }) + .setStyle(style) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL) + .setContentIntent( + PendingIntent.getActivity( + context, + SUMMARY_NOTIFICATION_ID, + Intent(context, MainActivity::class.java).apply { + flags = + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TASK + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + ) + + // Launcher icon badge: launchers that support numeric badges read this value, + // the rest show a plain dot while the notification is active. + if (isBadgeEnabled) { + builder.setNumber(count) + } else { + builder.setNumber(0) + } + + notificationManager.notify(SUMMARY_NOTIFICATION_ID, builder.build()) + } + } + + private fun notifyPerFeed(feed: Feed, articles: List
) { coroutineScope.launch { Timber.d("notify ${feed.name} for ${articles.size} articles") @@ -108,7 +213,8 @@ constructor( } } - fun notify(feedWithArticle: FeedWithArticle) { - notify(feedWithArticle.feed, feedWithArticle.articles) + companion object { + private const val SUMMARY_NOTIFICATION_ID = 0x52_59 + private const val MAX_SUMMARY_LINES = 8 } } diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/NotificationBadgePreference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/NotificationBadgePreference.kt new file mode 100644 index 000000000..0385e652b --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/NotificationBadgePreference.kt @@ -0,0 +1,39 @@ +package me.ash.reader.infrastructure.preference + +import android.content.Context +import androidx.compose.runtime.compositionLocalOf +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import me.ash.reader.ui.ext.DataStoreKey +import me.ash.reader.ui.ext.DataStoreKey.Companion.notificationBadge +import me.ash.reader.ui.ext.dataStore +import me.ash.reader.ui.ext.put + +val LocalNotificationBadge = compositionLocalOf { NotificationBadgePreference.default } + +sealed class NotificationBadgePreference(val value: Boolean) : Preference() { + data object ON : NotificationBadgePreference(true) + + data object OFF : NotificationBadgePreference(false) + + override fun put(context: Context, scope: CoroutineScope) { + scope.launch { context.dataStore.put(notificationBadge, value) } + } + + fun toggle(context: Context, scope: CoroutineScope) = + scope.launch { context.dataStore.put(notificationBadge, !value) } + + companion object { + + val default = ON + val values = listOf(ON, OFF) + + fun fromPreferences(preferences: Preferences) = + when (preferences[DataStoreKey.keys[notificationBadge]?.key as Preferences.Key]) { + true -> ON + false -> OFF + else -> default + } + } +} diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/NotificationSummaryPreference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/NotificationSummaryPreference.kt new file mode 100644 index 000000000..06791421c --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/NotificationSummaryPreference.kt @@ -0,0 +1,39 @@ +package me.ash.reader.infrastructure.preference + +import android.content.Context +import androidx.compose.runtime.compositionLocalOf +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import me.ash.reader.ui.ext.DataStoreKey +import me.ash.reader.ui.ext.DataStoreKey.Companion.notificationSummary +import me.ash.reader.ui.ext.dataStore +import me.ash.reader.ui.ext.put + +val LocalNotificationSummary = compositionLocalOf { NotificationSummaryPreference.default } + +sealed class NotificationSummaryPreference(val value: Boolean) : Preference() { + data object ON : NotificationSummaryPreference(true) + + data object OFF : NotificationSummaryPreference(false) + + override fun put(context: Context, scope: CoroutineScope) { + scope.launch { context.dataStore.put(notificationSummary, value) } + } + + fun toggle(context: Context, scope: CoroutineScope) = + scope.launch { context.dataStore.put(notificationSummary, !value) } + + companion object { + + val default = OFF + val values = listOf(ON, OFF) + + fun fromPreferences(preferences: Preferences) = + when (preferences[DataStoreKey.keys[notificationSummary]?.key as Preferences.Key]) { + true -> ON + false -> OFF + else -> default + } + } +} diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt index bdc2c057d..097efc16f 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/Preference.kt @@ -87,6 +87,10 @@ fun Preferences.toSettings(): Settings { openLinkSpecificBrowser = OpenLinkSpecificBrowserPreference.fromPreferences(this), sharedContent = SharedContentPreference.fromPreferences(this), + // Notification + notificationSummary = NotificationSummaryPreference.fromPreferences(this), + notificationBadge = NotificationBadgePreference.fromPreferences(this), + // Languages languages = LanguagesPreference.fromPreferences(this), ) diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt index af4f01012..62b34afc1 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt @@ -80,6 +80,10 @@ data class Settings( val openLinkSpecificBrowser: OpenLinkSpecificBrowserPreference = OpenLinkSpecificBrowserPreference.default, val sharedContent: SharedContentPreference = SharedContentPreference.default, + // Notification + val notificationSummary: NotificationSummaryPreference = NotificationSummaryPreference.default, + val notificationBadge: NotificationBadgePreference = NotificationBadgePreference.default, + // Languages val languages: LanguagesPreference = LanguagesPreference.default, ) diff --git a/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt b/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt index dd14b72d3..74a060224 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/preference/SettingsProvider.kt @@ -138,6 +138,10 @@ class SettingsProvider @Inject constructor( LocalOpenLinkSpecificBrowser provides settings.openLinkSpecificBrowser, LocalSharedContent provides settings.sharedContent, + // Notification + LocalNotificationSummary provides settings.notificationSummary, + LocalNotificationBadge provides settings.notificationBadge, + // Languages LocalLanguages provides settings.languages, ) { diff --git a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt index 7552b3d02..29d86bbd4 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt @@ -197,6 +197,8 @@ sealed interface PreferencesKey { const val openLink = "openLink" const val openLinkAppSpecificBrowser = "openLinkAppSpecificBrowser" const val sharedContent = "sharedContent" + const val notificationSummary = "notificationSummary" + const val notificationBadge = "notificationBadge" // Languages const val languages = "languages" @@ -273,6 +275,8 @@ sealed interface PreferencesKey { IntKey(openLink), StringKey(openLinkAppSpecificBrowser), IntKey(sharedContent), + BooleanKey(notificationSummary), + BooleanKey(notificationBadge), // Languages IntKey(languages), ) @@ -359,6 +363,8 @@ data class DataStoreKey(val key: Preferences.Key, val type: Class) { const val openLink = "openLink" const val openLinkAppSpecificBrowser = "openLinkAppSpecificBrowser" const val sharedContent = "sharedContent" + const val notificationSummary = "notificationSummary" + const val notificationBadge = "notificationBadge" // Languages const val languages = "languages" @@ -511,6 +517,10 @@ data class DataStoreKey(val key: Preferences.Key, val type: Class) { String::class.java, ), sharedContent to DataStoreKey(intPreferencesKey(sharedContent), Int::class.java), + notificationSummary to + DataStoreKey(booleanPreferencesKey(notificationSummary), Boolean::class.java), + notificationBadge to + DataStoreKey(booleanPreferencesKey(notificationBadge), Boolean::class.java), // Languages languages to DataStoreKey(intPreferencesKey(languages), Int::class.java), ) diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/interaction/InteractionPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/interaction/InteractionPage.kt index 3fd197607..d9b9a2673 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/interaction/InteractionPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/interaction/InteractionPage.kt @@ -29,6 +29,8 @@ import me.ash.reader.infrastructure.preference.LocalHideEmptyGroups import me.ash.reader.infrastructure.preference.LocalInitialFilter import me.ash.reader.infrastructure.preference.LocalInitialPage import me.ash.reader.infrastructure.preference.LocalMarkAsReadOnScroll +import me.ash.reader.infrastructure.preference.LocalNotificationBadge +import me.ash.reader.infrastructure.preference.LocalNotificationSummary import me.ash.reader.infrastructure.preference.LocalOpenLink import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle @@ -68,6 +70,8 @@ fun InteractionPage( val openLink = LocalOpenLink.current val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current val sharedContent = LocalSharedContent.current + val notificationSummary = LocalNotificationSummary.current + val notificationBadge = LocalNotificationBadge.current val settings = LocalSettings.current val pullToSwitchFeed = settings.pullToSwitchFeed @@ -236,6 +240,37 @@ fun InteractionPage( }, ) {} Spacer(modifier = Modifier.height(24.dp)) + + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.notifications), + ) + SettingItem( + title = stringResource(R.string.notification_summary), + desc = stringResource(R.string.notification_summary_desc), + onClick = { + notificationSummary.toggle(context, scope) + }, + ) { + RYSwitch(activated = notificationSummary.value) { + notificationSummary.toggle(context, scope) + } + } + SettingItem( + title = stringResource(R.string.notification_badge), + desc = stringResource(R.string.notification_badge_desc), + enabled = notificationSummary.value, + onClick = { + notificationBadge.toggle(context, scope) + }, + ) { + RYSwitch( + activated = notificationBadge.value && notificationSummary.value + ) { + notificationBadge.toggle(context, scope) + } + } + Spacer(modifier = Modifier.height(24.dp)) } item { Spacer(modifier = Modifier.height(24.dp)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7f249f63..7667231d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -386,4 +386,19 @@ Fetch full articles from webpages Enable Disable + + + Single summary notification + Group all new articles of a sync into one notification + Home icon badge + Show the number of new articles on the launcher icon + and %1$d more + + %1$d new article + %1$d new articles + + + In %1$d feed + In %1$d feeds +