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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/java/me/ash/reader/domain/service/SyncWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ 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
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
Expand All @@ -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<Pair<Feed, Article>>()

private val isSummaryEnabled: Boolean
get() = context.dataStore.get<Boolean>(DataStoreKey.notificationSummary) ?: false

private val isBadgeEnabled: Boolean
get() = context.dataStore.get<Boolean>(DataStoreKey.notificationBadge) ?: true

fun notify(feed: Feed, articles: List<Article>) {
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<Article>) {
coroutineScope.launch {
Timber.d("notify ${feed.name} for ${articles.size} articles")

Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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> { 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<Boolean>]) {
true -> ON
false -> OFF
else -> default
}
}
}
Original file line number Diff line number Diff line change
@@ -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> { 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<Boolean>]) {
true -> ON
false -> OFF
else -> default
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -273,6 +275,8 @@ sealed interface PreferencesKey {
IntKey(openLink),
StringKey(openLinkAppSpecificBrowser),
IntKey(sharedContent),
BooleanKey(notificationSummary),
BooleanKey(notificationBadge),
// Languages
IntKey(languages),
)
Expand Down Expand Up @@ -359,6 +363,8 @@ data class DataStoreKey<T>(val key: Preferences.Key<T>, val type: Class<T>) {
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"
Expand Down Expand Up @@ -511,6 +517,10 @@ data class DataStoreKey<T>(val key: Preferences.Key<T>, val type: Class<T>) {
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),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading