From 3affec0f88bae2798da797328c1f180693993ca8 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 23 Apr 2024 11:10:29 +0300 Subject: [PATCH] Group tracker notifications --- .../favourites/domain/FavouritesRepository.kt | 7 - .../tracker/TrackerSettingsFragment.kt | 9 +- .../kotatsu/tracker/domain/Tracker.kt | 29 +-- .../tracker/domain/model/MangaUpdates.kt | 1 - .../kotatsu/tracker/work/TrackWorker.kt | 147 ++++--------- .../work/TrackerNotificationChannels.kt | 127 ------------ .../tracker/work/TrackerNotificationHelper.kt | 193 ++++++++++++++++++ .../kotatsu/tracker/work/TrackingItem.kt | 8 - app/src/main/res/values/strings.xml | 1 + 9 files changed, 238 insertions(+), 284 deletions(-) delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackingItem.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 3ef7e2e19..5bc869c9f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -21,13 +21,11 @@ import org.koitharu.kotatsu.favourites.data.toMangaList import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import javax.inject.Inject @Reusable class FavouritesRepository @Inject constructor( private val db: MangaDatabase, - private val channels: TrackerNotificationChannels, ) { suspend fun getAllManga(): List { @@ -145,7 +143,6 @@ class FavouritesRepository @Inject constructor( ) val id = db.getFavouriteCategoriesDao().insert(entity) val category = entity.toFavouriteCategory(id) - channels.createChannel(category) return category } @@ -174,10 +171,6 @@ class FavouritesRepository @Inject constructor( db.getFavouriteCategoriesDao().delete(id) } } - // run after transaction success - for (id in ids) { - channels.deleteChannel(id) - } } suspend fun setCategoryOrder(id: Long, order: ListSortOrder) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt index c0ccbc413..32e488026 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt @@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet import org.koitharu.kotatsu.settings.utils.DozeHelper import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider -import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels +import org.koitharu.kotatsu.tracker.work.TrackerNotificationHelper import javax.inject.Inject @AndroidEntryPoint @@ -33,7 +33,7 @@ class TrackerSettingsFragment : private val dozeHelper = DozeHelper(this) @Inject - lateinit var channels: TrackerNotificationChannels + lateinit var notificationHelper: TrackerNotificationHelper override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_tracker) @@ -88,7 +88,7 @@ class TrackerSettingsFragment : true } - channels.areNotificationsDisabled -> { + !notificationHelper.getAreNotificationsEnabled() -> { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.fromParts("package", requireContext().packageName, null)) startActivitySafe(intent) @@ -116,8 +116,7 @@ class TrackerSettingsFragment : val pref = findPreference(AppSettings.KEY_NOTIFICATIONS_SETTINGS) ?: return pref.setSummary( when { - channels.areNotificationsDisabled -> R.string.disabled - channels.isNotificationGroupEnabled() -> R.string.show_notification_new_chapters_on + notificationHelper.getAreNotificationsEnabled() -> R.string.show_notification_new_chapters_on else -> R.string.show_notification_new_chapters_off }, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/Tracker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/Tracker.kt index f01585f5a..c1d6e7aa2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/Tracker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/Tracker.kt @@ -15,8 +15,6 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates -import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels -import org.koitharu.kotatsu.tracker.work.TrackingItem import java.time.Instant import javax.inject.Inject import kotlin.contracts.InvocationKind @@ -28,28 +26,12 @@ class Tracker @Inject constructor( private val repository: TrackingRepository, private val historyRepository: HistoryRepository, private val favouritesRepository: FavouritesRepository, - private val channels: TrackerNotificationChannels, private val mangaRepositoryFactory: MangaRepository.Factory, ) { - suspend fun getTracks(limit: Int): List { + suspend fun getTracks(limit: Int): List { repository.updateTracks() - return repository.getTracks(offset = 0, limit = limit).map { - val categoryId = repository.getCategoryId(it.manga.id) - TrackingItem( - tracking = it, - channelId = if (categoryId == NO_ID) { - channels.getHistoryChannelId() - } else { - channels.getFavouritesChannelId(categoryId) - }, - ) - } - } - - suspend fun updateNotificationsChannels() { - val categories = favouritesRepository.getCategories() - channels.updateChannels(categories) + return repository.getTracks(offset = 0, limit = limit) } suspend fun gc() { @@ -131,7 +113,7 @@ class Tracker @Inject constructor( private fun compare(track: MangaTracking, manga: Manga, branch: String?): MangaUpdates.Success { if (track.isEmpty()) { // first check or manga was empty on last check - return MangaUpdates.Success(manga, emptyList(), isValid = false, channelId = null) + return MangaUpdates.Success(manga, emptyList(), isValid = false) } val chapters = requireNotNull(manga.getChapters(branch)) val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId } @@ -141,16 +123,15 @@ class Tracker @Inject constructor( manga = manga, newChapters = emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId, - channelId = null, ) } newChapters.size == chapters.size -> { - MangaUpdates.Success(manga, emptyList(), isValid = false, channelId = null) + MangaUpdates.Success(manga, emptyList(), isValid = false) } else -> { - MangaUpdates.Success(manga, newChapters, isValid = true, channelId = null) + MangaUpdates.Success(manga, newChapters, isValid = true) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt index f81bd6ab9..9726247a8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt @@ -13,7 +13,6 @@ sealed interface MangaUpdates { override val manga: Manga, val newChapters: List, val isValid: Boolean, - val channelId: String?, ) : MangaUpdates { fun isNotEmpty() = newChapters.isNotEmpty() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 42a0a56c1..6da0ef433 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -1,16 +1,13 @@ package org.koitharu.kotatsu.tracker.work -import android.app.PendingIntent import android.content.Context import android.content.pm.ServiceInfo import android.os.Build +import androidx.annotation.CheckResult import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC -import androidx.core.app.NotificationCompat.VISIBILITY_SECRET import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat -import androidx.core.content.ContextCompat import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy import androidx.work.Constraints @@ -27,8 +24,6 @@ import androidx.work.WorkManager import androidx.work.WorkQuery import androidx.work.WorkerParameters import androidx.work.await -import coil.ImageLoader -import coil.request.ImageRequest import dagger.Reusable import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -37,6 +32,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore @@ -53,17 +49,15 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.onEachIndexed -import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.trySetForeground -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler import org.koitharu.kotatsu.tracker.domain.Tracker +import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates +import org.koitharu.kotatsu.tracker.work.TrackerNotificationHelper.NotificationInfo import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Provider @@ -74,7 +68,7 @@ import com.google.android.material.R as materialR class TrackWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParams: WorkerParameters, - private val coil: ImageLoader, + private val notificationHelper: TrackerNotificationHelper, private val settings: AppSettings, private val tracker: Tracker, private val workManager: WorkManager, @@ -84,6 +78,7 @@ class TrackWorker @AssistedInject constructor( private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) } override suspend fun doWork(): Result { + notificationHelper.updateChannels() val isForeground = trySetForeground() logger.log("doWork(): attempt $runAttemptCount") return try { @@ -105,32 +100,33 @@ class TrackWorker @AssistedInject constructor( if (!settings.isTrackerEnabled) { return Result.success(workDataOf(0, 0)) } - tracker.updateNotificationsChannels() val tracks = tracker.getTracks(if (isFullRun) Int.MAX_VALUE else BATCH_SIZE) logger.log("Total ${tracks.size} tracks") if (tracks.isEmpty()) { return Result.success(workDataOf(0, 0)) } - checkUpdatesAsync(tracks) + val notifications = checkUpdatesAsync(tracks) + if (notifications.isNotEmpty() && applicationContext.checkNotificationPermission(null)) { + val groupNotification = notificationHelper.createGroupNotification(notifications) + notifications.forEach { notificationManager.notify(it.tag, it.id, it.notification) } + if (groupNotification != null) { + notificationManager.notify(TAG, TrackerNotificationHelper.GROUP_NOTIFICATION_ID, groupNotification) + } + } return Result.success() } - private suspend fun checkUpdatesAsync(tracks: List): List { + @CheckResult + private suspend fun checkUpdatesAsync(tracks: List): List { val semaphore = Semaphore(MAX_PARALLELISM) return channelFlow { - for ((track, channelId) in tracks) { + for (track in tracks) { launch { semaphore.withPermit { send( runCatchingCancellable { - tracker.fetchUpdates(track, commit = true).let { - if (it is MangaUpdates.Success) { - it.copy(channelId = channelId) - } else { - it - } - } + tracker.fetchUpdates(track, commit = true) }.getOrElse { error -> MangaUpdates.Failure( manga = track.manga, @@ -145,94 +141,26 @@ class TrackWorker @AssistedInject constructor( if (applicationContext.checkNotificationPermission(WORKER_CHANNEL_ID)) { notificationManager.notify(WORKER_NOTIFICATION_ID, createWorkerNotification(tracks.size, index + 1)) } - when (it) { - is MangaUpdates.Failure -> { - val e = it.error - logger.log("checkUpdatesAsync", e) - if (e is CloudFlareProtectedException) { - CaptchaNotifier(applicationContext).notify(e) - } - } - - is MangaUpdates.Success -> { - if (it.isValid && it.isNotEmpty()) { - showNotification( - manga = it.manga, - channelId = it.channelId, - newChapters = it.newChapters, - ) - } + if (it is MangaUpdates.Failure) { + val e = it.error + logger.log("checkUpdatesAsync", e) + if (e is CloudFlareProtectedException) { + CaptchaNotifier(applicationContext).notify(e) } } - }.toList(ArrayList(tracks.size)) - } - - private suspend fun showNotification( - manga: Manga, - channelId: String?, - newChapters: List, - ) { - if (newChapters.isEmpty() || channelId == null || !applicationContext.checkNotificationPermission(channelId)) { - return - } - val id = manga.url.hashCode() - val colorPrimary = ContextCompat.getColor(applicationContext, R.color.blue_primary) - val builder = NotificationCompat.Builder(applicationContext, channelId) - val summary = applicationContext.resources.getQuantityString( - R.plurals.new_chapters, - newChapters.size, - newChapters.size, - ) - with(builder) { - setContentText(summary) - setContentTitle(manga.title) - setNumber(newChapters.size) - setLargeIcon( - coil.execute( - ImageRequest.Builder(applicationContext) - .data(manga.coverUrl) - .tag(manga.source) - .build(), - ).toBitmapOrNull(), - ) - setSmallIcon(R.drawable.ic_stat_book_plus) - setGroup(GROUP_NEW_CHAPTERS) - val style = NotificationCompat.InboxStyle(this) - for (chapter in newChapters) { - style.addLine(chapter.name) - } - style.setSummaryText(manga.title) - style.setBigContentTitle(summary) - setStyle(style) - val intent = DetailsActivity.newIntent(applicationContext, manga) - setContentIntent( - PendingIntentCompat.getActivity( - applicationContext, - id, - intent, - PendingIntent.FLAG_UPDATE_CURRENT, - false, - ), - ) - setAutoCancel(true) - setCategory(NotificationCompat.CATEGORY_PROMO) - setVisibility(if (manga.isNsfw) VISIBILITY_SECRET else VISIBILITY_PUBLIC) - setShortcutId(manga.id.toString()) - priority = NotificationCompat.PRIORITY_DEFAULT - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - builder.setSound(settings.notificationSound) - var defaults = if (settings.notificationLight) { - setLights(colorPrimary, 1000, 5000) - NotificationCompat.DEFAULT_LIGHTS - } else 0 - if (settings.notificationVibrate) { - builder.setVibrate(longArrayOf(500, 500, 500, 500)) - defaults = defaults or NotificationCompat.DEFAULT_VIBRATE + }.mapNotNull { + when (it) { + is MangaUpdates.Failure -> null + is MangaUpdates.Success -> if (it.isValid && it.isNotEmpty()) { + notificationHelper.createNotification( + manga = it.manga, + newChapters = it.newChapters, + ) + } else { + null } - builder.setDefaults(defaults) } - } - notificationManager.notify(TAG, id, builder.build()) + }.toList() } override suspend fun getForegroundInfo(): ForegroundInfo { @@ -250,11 +178,7 @@ class TrackWorker @AssistedInject constructor( val notification = createWorkerNotification(0, 0) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ForegroundInfo( - WORKER_NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, - ) + ForegroundInfo(WORKER_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { ForegroundInfo(WORKER_NOTIFICATION_ID, notification) } @@ -367,7 +291,6 @@ class TrackWorker @AssistedInject constructor( const val WORKER_CHANNEL_ID = "track_worker" const val WORKER_NOTIFICATION_ID = 35 - const val GROUP_NEW_CHAPTERS = "org.koitharu.kotatsu.NEW_CHAPTERS" const val TAG = "tracking" const val TAG_ONESHOT = "tracking_oneshot" const val MAX_PARALLELISM = 6 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt deleted file mode 100644 index 4525aa9b3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt +++ /dev/null @@ -1,127 +0,0 @@ -package org.koitharu.kotatsu.tracker.work - -import android.app.NotificationManager -import android.content.Context -import android.os.Build -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationChannelGroupCompat -import androidx.core.app.NotificationManagerCompat -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.prefs.AppSettings - -class TrackerNotificationChannels @Inject constructor( - @ApplicationContext private val context: Context, - private val settings: AppSettings, -) { - - private val manager = NotificationManagerCompat.from(context) - - val areNotificationsDisabled: Boolean - get() = !manager.areNotificationsEnabled() - - fun updateChannels(categories: Collection) { - manager.deleteNotificationChannel(OLD_CHANNEL_ID) - val group = createGroup() - val existingChannels = group.channels.associateByTo(HashMap()) { it.id } - for (category in categories) { - val id = getFavouritesChannelId(category.id) - if (existingChannels.remove(id)?.name == category.title) { - continue - } - val channel = NotificationChannelCompat.Builder(id, NotificationManagerCompat.IMPORTANCE_DEFAULT) - .setName(category.title) - .setGroup(GROUP_ID) - .build() - manager.createNotificationChannel(channel) - } - existingChannels.remove(CHANNEL_ID_HISTORY) - createHistoryChannel() - for (id in existingChannels.keys) { - manager.deleteNotificationChannel(id) - } - } - - fun createChannel(category: FavouriteCategory) { - val id = getFavouritesChannelId(category.id) - val channel = NotificationChannelCompat.Builder(id, NotificationManagerCompat.IMPORTANCE_DEFAULT) - .setName(category.title) - .setGroup(createGroup().id) - .build() - manager.createNotificationChannel(channel) - } - - fun deleteChannel(categoryId: Long) { - manager.deleteNotificationChannel(getFavouritesChannelId(categoryId)) - } - - fun isFavouriteNotificationsEnabled(category: FavouriteCategory): Boolean { - if (!manager.areNotificationsEnabled()) { - return false - } - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = manager.getNotificationChannel(getFavouritesChannelId(category.id)) - channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE - } else { - // fallback - settings.isTrackerNotificationsEnabled - } - } - - fun isHistoryNotificationsEnabled(): Boolean { - if (!manager.areNotificationsEnabled()) { - return false - } - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = manager.getNotificationChannel(getHistoryChannelId()) - channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE - } else { - // fallback - settings.isTrackerNotificationsEnabled - } - } - - fun isNotificationGroupEnabled(): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return settings.isTrackerNotificationsEnabled - } - val group = manager.getNotificationChannelGroupCompat(GROUP_ID) ?: return true - return !group.isBlocked && group.channels.any { it.importance != NotificationManagerCompat.IMPORTANCE_NONE } - } - - fun getFavouritesChannelId(categoryId: Long): String { - return CHANNEL_ID_PREFIX + categoryId - } - - fun getHistoryChannelId(): String { - return CHANNEL_ID_HISTORY - } - - private fun createGroup(): NotificationChannelGroupCompat { - return manager.getNotificationChannelGroupCompat(GROUP_ID) ?: run { - val group = NotificationChannelGroupCompat.Builder(GROUP_ID) - .setName(context.getString(R.string.new_chapters)) - .build() - manager.createNotificationChannelGroup(group) - group - } - } - - private fun createHistoryChannel() { - val channel = NotificationChannelCompat.Builder(CHANNEL_ID_HISTORY, NotificationManagerCompat.IMPORTANCE_DEFAULT) - .setName(context.getString(R.string.history)) - .setGroup(GROUP_ID) - .build() - manager.createNotificationChannel(channel) - } - - companion object { - - const val GROUP_ID = "trackers" - private const val CHANNEL_ID_PREFIX = "track_fav_" - private const val CHANNEL_ID_HISTORY = "track_history" - private const val OLD_CHANNEL_ID = "tracking" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt new file mode 100644 index 000000000..000646210 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt @@ -0,0 +1,193 @@ +package org.koitharu.kotatsu.tracker.work + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC +import androidx.core.app.NotificationCompat.VISIBILITY_SECRET +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat +import coil.ImageLoader +import coil.request.ImageRequest +import dagger.hilt.android.qualifiers.ApplicationContext +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission +import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity +import javax.inject.Inject + +class TrackerNotificationHelper @Inject constructor( + @ApplicationContext private val applicationContext: Context, + private val settings: AppSettings, + private val coil: ImageLoader, +) { + + fun getAreNotificationsEnabled(): Boolean { + val manager = NotificationManagerCompat.from(applicationContext) + if (!manager.areNotificationsEnabled()) { + return false + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = manager.getNotificationChannel(CHANNEL_ID) + channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE + } else { + // fallback + settings.isTrackerNotificationsEnabled + } + } + + suspend fun createNotification(manga: Manga, newChapters: List): NotificationInfo? { + if (newChapters.isEmpty() || !applicationContext.checkNotificationPermission(CHANNEL_ID)) { + return null + } + val id = manga.url.hashCode() + val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + val summary = applicationContext.resources.getQuantityString( + R.plurals.new_chapters, + newChapters.size, + newChapters.size, + ) + with(builder) { + setContentText(summary) + setContentTitle(manga.title) + setNumber(newChapters.size) + setLargeIcon( + coil.execute( + ImageRequest.Builder(applicationContext) + .data(manga.coverUrl) + .tag(manga.source) + .build(), + ).toBitmapOrNull(), + ) + setSmallIcon(R.drawable.ic_stat_book_plus) + setGroup(GROUP_NEW_CHAPTERS) + val style = NotificationCompat.InboxStyle(this) + for (chapter in newChapters) { + style.addLine(chapter.name) + } + style.setSummaryText(manga.title) + style.setBigContentTitle(summary) + setStyle(style) + val intent = DetailsActivity.newIntent(applicationContext, manga) + setContentIntent( + PendingIntentCompat.getActivity( + applicationContext, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT, + false, + ), + ) + setVisibility(if (manga.isNsfw) VISIBILITY_SECRET else VISIBILITY_PUBLIC) + setShortcutId(manga.id.toString()) + applyCommonSettings(this) + } + return NotificationInfo(id, TAG, builder.build(), manga, newChapters.size) + } + + fun createGroupNotification( + notifications: List + ): Notification? { + if (notifications.size <= 1) { + return null + } + val newChaptersCount = notifications.sumOf { it.newChapters } + val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + with(builder) { + val title = applicationContext.resources.getQuantityString( + R.plurals.new_chapters, + newChaptersCount, + newChaptersCount, + ) + setContentTitle(title) + setContentText(notifications.joinToString { it.manga.title }) + setSmallIcon(R.drawable.ic_stat_book_plus) + val style = NotificationCompat.InboxStyle(this) + for (item in notifications) { + style.addLine( + applicationContext.getString(R.string.new_chapters_pattern, item.manga.title, item.newChapters), + ) + } + style.setBigContentTitle(title) + setStyle(style) + setNumber(newChaptersCount) + setGroup(GROUP_NEW_CHAPTERS) + setGroupSummary(true) + val intent = UpdatesActivity.newIntent(applicationContext) + setContentIntent( + PendingIntentCompat.getActivity( + applicationContext, + GROUP_NOTIFICATION_ID, + intent, + PendingIntent.FLAG_UPDATE_CURRENT, + false, + ), + ) + applyCommonSettings(this) + } + return builder.build() + } + + fun updateChannels() { + val manager = NotificationManagerCompat.from(applicationContext) + manager.deleteNotificationChannel(LEGACY_CHANNEL_ID) + manager.deleteNotificationChannel(LEGACY_CHANNEL_ID_HISTORY) + manager.deleteNotificationChannelGroup(LEGACY_CHANNELS_GROUP_ID) + + val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName(applicationContext.getString(R.string.new_chapters)) + .setDescription(applicationContext.getString(R.string.show_notification_new_chapters_on)) + .setShowBadge(true) + .setLightColor(ContextCompat.getColor(applicationContext, R.color.blue_primary)) + .build() + manager.createNotificationChannel(channel) + } + + private fun applyCommonSettings(builder: NotificationCompat.Builder) { + builder.setAutoCancel(true) + builder.setCategory(NotificationCompat.CATEGORY_SOCIAL) + builder.priority = NotificationCompat.PRIORITY_DEFAULT + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + builder.setSound(settings.notificationSound) + var defaults = if (settings.notificationLight) { + builder.setLights(ContextCompat.getColor(applicationContext, R.color.blue_primary), 1000, 5000) + NotificationCompat.DEFAULT_LIGHTS + } else 0 + if (settings.notificationVibrate) { + builder.setVibrate(longArrayOf(500, 500, 500, 500)) + defaults = defaults or NotificationCompat.DEFAULT_VIBRATE + } + builder.setDefaults(defaults) + } + } + + class NotificationInfo( + val id: Int, + val tag: String, + val notification: Notification, + val manga: Manga, + val newChapters: Int, + ) + + companion object { + + const val CHANNEL_ID = "tracker_chapters" + const val GROUP_NOTIFICATION_ID = 0 + const val GROUP_NEW_CHAPTERS = "org.koitharu.kotatsu.NEW_CHAPTERS" + const val TAG = "tracker" + + private const val LEGACY_CHANNELS_GROUP_ID = "trackers" + private const val LEGACY_CHANNEL_ID_PREFIX = "track_fav_" + private const val LEGACY_CHANNEL_ID_HISTORY = "track_history" + private const val LEGACY_CHANNEL_ID = "tracking" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackingItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackingItem.kt deleted file mode 100644 index 38ad9d4ec..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackingItem.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.tracker.work - -import org.koitharu.kotatsu.tracker.domain.model.MangaTracking - -data class TrackingItem( - val tracking: MangaTracking, - val channelId: String?, -) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17fab912e..4c65040d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -633,4 +633,5 @@ Less frequently More frequently Frequency of check + %1$s: %2$d