From d3e10583d39c50dbc77439ac74fe2b8263a6cdae Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Tue, 26 Mar 2024 22:07:42 +0300 Subject: [PATCH] Implement new chapters tracking system --- .../org/xtimms/tokusho/core/Navigation.kt | 1 + .../tokusho/core/database/TokushoDatabase.kt | 12 +- .../tokusho/core/database/dao/TrackLogsDao.kt | 33 ++ .../tokusho/core/database/dao/TracksDao.kt | 63 +++ .../core/database/entity/EntityMapping.kt | 25 ++ .../core/database/entity/TrackEntity.kt | 29 ++ .../core/database/entity/TrackLogEntity.kt | 25 ++ .../core/database/entity/TrackLogWithManga.kt | 20 + .../xtimms/tokusho/core/prefs/AppSettings.kt | 3 + .../xtimms/tokusho/core/tracker/Tracker.kt | 157 ++++++++ .../core/tracker/model/MangaTracking.kt | 14 + .../core/tracker/model/MangaUpdates.kt | 28 ++ .../core/tracker/model/TrackingLogItem.kt | 12 + .../data/repository/TrackingRepository.kt | 256 ++++++++++++ .../xtimms/tokusho/sections/feed/FeedView.kt | 184 ++++++++- .../tokusho/sections/feed/FeedViewItem.kt | 116 ++++++ .../tokusho/sections/feed/FeedViewModel.kt | 71 ++++ .../tokusho/sections/feed/model/FeedItem.kt | 17 + .../feed/model/ListModelConversionExt.kt | 12 + .../xtimms/tokusho/utils/lang/Collections.kt | 15 + .../org/xtimms/tokusho/utils/lang/Flow.kt | 10 + .../tokusho/work/WorkScheduleManager.kt | 11 +- .../tokusho/work/tracker/TrackWorker.kt | 372 ++++++++++++++++++ .../tracker/TrackerNotificationChannels.kt | 111 ++++++ .../tokusho/work/tracker/TrackingItem.kt | 8 + .../res/drawable-hdpi/ic_stat_tokusho.png | Bin 0 -> 981 bytes .../res/drawable-mdpi/ic_stat_tokusho.png | Bin 0 -> 784 bytes .../res/drawable-xhdpi/ic_stat_tokusho.png | Bin 0 -> 1398 bytes .../res/drawable-xxhdpi/ic_stat_tokusho.png | Bin 0 -> 1695 bytes .../res/drawable-xxxhdpi/ic_stat_tokusho.png | Bin 0 -> 2432 bytes app/src/main/res/values/plurals.xml | 4 + app/src/main/res/values/strings.xml | 7 + 32 files changed, 1611 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/dao/TrackLogsDao.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/dao/TracksDao.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackEntity.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackLogEntity.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackLogWithManga.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/tracker/Tracker.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/tracker/model/MangaTracking.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/tracker/model/MangaUpdates.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/tracker/model/TrackingLogItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/TrackingRepository.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/feed/FeedViewItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/feed/FeedViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/feed/model/FeedItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/feed/model/ListModelConversionExt.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/work/tracker/TrackWorker.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/work/tracker/TrackerNotificationChannels.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/work/tracker/TrackingItem.kt create mode 100644 app/src/main/res/drawable-hdpi/ic_stat_tokusho.png create mode 100644 app/src/main/res/drawable-mdpi/ic_stat_tokusho.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_stat_tokusho.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_stat_tokusho.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_stat_tokusho.png diff --git a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt index f50096f..f6880ca 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt @@ -208,6 +208,7 @@ fun Navigation( composable(FEED_DESTINATION) { FeedView( + coil = coil, navigateBack = navigateBack, navigateToShelf = { navController.navigate(SHELF_SETTINGS_DESTINATION) } ) diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt index cd69353..1be0e93 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt @@ -18,6 +18,8 @@ import org.xtimms.tokusho.core.database.dao.MangaDao import org.xtimms.tokusho.core.database.dao.MangaSourcesDao import org.xtimms.tokusho.core.database.dao.SuggestionDao import org.xtimms.tokusho.core.database.dao.TagsDao +import org.xtimms.tokusho.core.database.dao.TrackLogsDao +import org.xtimms.tokusho.core.database.dao.TracksDao import org.xtimms.tokusho.core.database.entity.BookmarkEntity import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity import org.xtimms.tokusho.core.database.entity.FavouriteEntity @@ -27,6 +29,8 @@ import org.xtimms.tokusho.core.database.entity.MangaSourceEntity import org.xtimms.tokusho.core.database.entity.MangaTagsEntity import org.xtimms.tokusho.core.database.entity.SuggestionEntity import org.xtimms.tokusho.core.database.entity.TagEntity +import org.xtimms.tokusho.core.database.entity.TrackEntity +import org.xtimms.tokusho.core.database.entity.TrackLogEntity import org.xtimms.tokusho.utils.lang.processLifecycleScope const val DATABASE_VERSION = 1 @@ -41,7 +45,9 @@ const val DATABASE_VERSION = 1 FavouriteEntity::class, FavouriteCategoryEntity::class, BookmarkEntity::class, - SuggestionEntity::class + SuggestionEntity::class, + TrackEntity::class, + TrackLogEntity::class ], version = DATABASE_VERSION ) @@ -63,6 +69,10 @@ abstract class TokushoDatabase : RoomDatabase() { abstract fun getSuggestionDao(): SuggestionDao + abstract fun getTracksDao(): TracksDao + + abstract fun getTrackLogsDao(): TrackLogsDao + } fun TokushoDatabase(context: Context): TokushoDatabase = Room diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/TrackLogsDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/TrackLogsDao.kt new file mode 100644 index 0000000..7fa66b8 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/TrackLogsDao.kt @@ -0,0 +1,33 @@ +package org.xtimms.tokusho.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.xtimms.tokusho.core.database.entity.TrackLogEntity +import org.xtimms.tokusho.core.database.entity.TrackLogWithManga + +@Dao +interface TrackLogsDao { + + @Transaction + @Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0") + fun observeAll(limit: Int): Flow> + + @Query("DELETE FROM track_logs") + suspend fun clear() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: TrackLogEntity): Long + + @Query("DELETE FROM track_logs WHERE manga_id = :mangaId") + suspend fun removeAll(mangaId: Long) + + @Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)") + suspend fun gc() + + @Query("SELECT COUNT(*) FROM track_logs") + suspend fun count(): Int +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/TracksDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/TracksDao.kt new file mode 100644 index 0000000..17469ac --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/TracksDao.kt @@ -0,0 +1,63 @@ +package org.xtimms.tokusho.core.database.dao + +import androidx.room.Dao +import androidx.room.MapColumn +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow +import org.xtimms.tokusho.core.database.entity.MangaWithTags +import org.xtimms.tokusho.core.database.entity.TrackEntity + +@Dao +abstract class TracksDao { + + @Query("SELECT * FROM tracks") + abstract suspend fun findAll(): List + + @Query("SELECT * FROM tracks WHERE manga_id IN (:ids)") + abstract suspend fun findAll(ids: Collection): List + + @Query("SELECT * FROM tracks WHERE manga_id = :mangaId") + abstract suspend fun find(mangaId: Long): TrackEntity? + + @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") + abstract suspend fun findNewChapters(mangaId: Long): Int? + + @Query("SELECT manga_id, chapters_new FROM tracks") + abstract fun observeNewChaptersMap(): Flow> + + @Query("SELECT chapters_new FROM tracks") + abstract fun observeNewChapters(): Flow> + + @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") + abstract fun observeNewChapters(mangaId: Long): Flow + + @Transaction + @Query("SELECT manga.* FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY chapters_new DESC") + abstract fun observeUpdatedManga(): Flow> + + @Transaction + @Query("SELECT manga.* FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY chapters_new DESC LIMIT :limit") + abstract fun observeUpdatedManga(limit: Int): Flow> + + @Query("DELETE FROM tracks") + abstract suspend fun clear() + + @Query("UPDATE tracks SET chapters_new = 0") + abstract suspend fun clearCounters() + + @Query("UPDATE tracks SET chapters_new = 0 WHERE manga_id = :mangaId") + abstract suspend fun clearCounter(mangaId: Long) + + @Query("DELETE FROM tracks WHERE manga_id = :mangaId") + abstract suspend fun delete(mangaId: Long) + + @Query("DELETE FROM tracks WHERE manga_id NOT IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites WHERE category_id IN (SELECT category_id FROM favourite_categories WHERE track = 1))") + abstract suspend fun gc() + + @Upsert + abstract suspend fun upsert(entity: TrackEntity) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt index 8add3a4..af0abcb 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt @@ -11,6 +11,7 @@ import org.xtimms.tokusho.core.model.FavouriteCategory import org.xtimms.tokusho.core.model.ListSortOrder import org.xtimms.tokusho.core.model.MangaHistory import org.xtimms.tokusho.core.model.MangaSource +import org.xtimms.tokusho.core.tracker.model.TrackingLogItem import org.xtimms.tokusho.sections.shelf.FavouriteManga import org.xtimms.tokusho.utils.lang.longHashCode import java.time.Instant @@ -77,6 +78,30 @@ fun Collection.toBookmarks(manga: Manga) = map { @JvmName("bookmarksIds") fun Collection.ids() = map { it.pageId } +fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap): TrackingLogItem { + val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() } + return TrackingLogItem( + id = trackLog.id, + chapters = chaptersList, + manga = manga.toManga(tags.toMangaTags()), + createdAt = Instant.ofEpochMilli(trackLog.createdAt), + isNew = counters.decrement(trackLog.mangaId, chaptersList.size), + ) +} + +private fun MutableMap.decrement(key: Long, count: Int): Boolean = synchronized(this) { + val counter = get(key) + if (counter == null || counter <= 0) { + return false + } + if (counter < count) { + remove(key) + } else { + put(key, counter - count) + } + return true +} + // Model to entity fun Manga.toEntity() = MangaEntity( diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackEntity.kt new file mode 100644 index 0000000..a398e25 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackEntity.kt @@ -0,0 +1,29 @@ +package org.xtimms.tokusho.core.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "tracks", + foreignKeys = [ + ForeignKey( + entity = MangaEntity::class, + parentColumns = ["manga_id"], + childColumns = ["manga_id"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +class TrackEntity( + @PrimaryKey(autoGenerate = false) + @ColumnInfo(name = "manga_id") val mangaId: Long, + @get:Deprecated(message = "Should not be used", level = DeprecationLevel.WARNING) + @ColumnInfo(name = "chapters_total") val totalChapters: Int, + @ColumnInfo(name = "last_chapter_id") val lastChapterId: Long, + @ColumnInfo(name = "chapters_new") val newChapters: Int, + @ColumnInfo(name = "last_check") val lastCheck: Long, + @get:Deprecated(message = "Should not be used", level = DeprecationLevel.WARNING) + @ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackLogEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackLogEntity.kt new file mode 100644 index 0000000..179f8e9 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackLogEntity.kt @@ -0,0 +1,25 @@ +package org.xtimms.tokusho.core.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "track_logs", + foreignKeys = [ + ForeignKey( + entity = MangaEntity::class, + parentColumns = ["manga_id"], + childColumns = ["manga_id"], + onDelete = ForeignKey.CASCADE + ) + ] +) +class TrackLogEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0L, + @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, + @ColumnInfo(name = "chapters") val chapters: String, + @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackLogWithManga.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackLogWithManga.kt new file mode 100644 index 0000000..dd9ebe2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/TrackLogWithManga.kt @@ -0,0 +1,20 @@ +package org.xtimms.tokusho.core.database.entity + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +class TrackLogWithManga( + @Embedded val trackLog: TrackLogEntity, + @Relation( + parentColumn = "manga_id", + entityColumn = "manga_id" + ) + val manga: MangaEntity, + @Relation( + parentColumn = "manga_id", + entityColumn = "tag_id", + associateBy = Junction(MangaTagsEntity::class) + ) + val tags: List, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt index 9304605..68ca701 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt @@ -45,6 +45,7 @@ const val SSL_BYPASS = "ssl_bypass" const val NSFW = "nsfw" const val TABS_MANGA_COUNT = "tabs_manga_count" const val SUGGESTIONS = "suggestions" +const val TRACKER = "tracker" val paletteStyles = listOf( PaletteStyle.TonalSpot, @@ -116,6 +117,8 @@ object AppSettings { fun isSuggestionsEnabled() = SUGGESTIONS.getBoolean(true) + fun isTrackerEnabled() = TRACKER.getBoolean(true) + fun isSwipeTutorialEnabled() = SWIPE_TUTORIAL.getBoolean(true) fun isImagesProxyEnabled() = WSRV.getBoolean(false) diff --git a/app/src/main/java/org/xtimms/tokusho/core/tracker/Tracker.kt b/app/src/main/java/org/xtimms/tokusho/core/tracker/Tracker.kt new file mode 100644 index 0000000..5dddfc8 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/tracker/Tracker.kt @@ -0,0 +1,157 @@ +package org.xtimms.tokusho.core.tracker + +import androidx.annotation.VisibleForTesting +import androidx.collection.MutableLongSet +import coil.request.CachePolicy +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.tokusho.core.model.getPreferredBranch +import org.xtimms.tokusho.core.parser.MangaRepository +import org.xtimms.tokusho.core.parser.RemoteMangaRepository +import org.xtimms.tokusho.core.tracker.model.MangaTracking +import org.xtimms.tokusho.core.tracker.model.MangaUpdates +import org.xtimms.tokusho.data.repository.HistoryRepository +import org.xtimms.tokusho.data.repository.TrackingRepository +import org.xtimms.tokusho.utils.CompositeMutex2 +import org.xtimms.tokusho.work.tracker.TrackerNotificationChannels +import org.xtimms.tokusho.work.tracker.TrackingItem +import javax.inject.Inject +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +class Tracker @Inject constructor( + private val repository: TrackingRepository, + private val historyRepository: HistoryRepository, + private val channels: TrackerNotificationChannels, + private val mangaRepositoryFactory: MangaRepository.Factory, +) { + + suspend fun getAllTracks(): List { + val knownManga = MutableLongSet() + val result = ArrayList() + // Favourites + val favourites = repository.getAllFavouritesManga() + channels.updateChannels(favourites.keys) + for ((category, mangaList) in favourites) { + if (!category.isTrackingEnabled || mangaList.isEmpty()) { + continue + } + val categoryTracks = repository.getTracks(mangaList) + val channelId = if (channels.isFavouriteNotificationsEnabled(category)) { + channels.getFavouritesChannelId(category.id) + } else { + null + } + for (track in categoryTracks) { + if (knownManga.add(track.manga.id)) { + result.add(TrackingItem(track, channelId)) + } + } + } + // History + val history = repository.getAllHistoryManga() + val historyTracks = repository.getTracks(history) + val channelId = if (channels.isHistoryNotificationsEnabled()) { + channels.getHistoryChannelId() + } else { + null + } + for (track in historyTracks) { + if (knownManga.add(track.manga.id)) { + result.add(TrackingItem(track, channelId)) + } + } + result.trimToSize() + return result + } + + suspend fun getTracks(ids: Set): List { + return getAllTracks().filterTo(ArrayList(ids.size)) { x -> x.tracking.manga.id in ids } + } + + suspend fun gc() { + repository.gc() + } + + suspend fun fetchUpdates( + track: MangaTracking, + commit: Boolean + ): MangaUpdates.Success = withMangaLock(track.manga.id) { + val repo = mangaRepositoryFactory.create(track.manga.source) + require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" } + val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY) + val updates = compare(track, manga, getBranch(manga)) + if (commit) { + repository.saveUpdates(updates) + } + return updates + } + + @VisibleForTesting + suspend fun checkUpdates(manga: Manga, commit: Boolean): MangaUpdates.Success { + val track = repository.getTrack(manga) + val updates = compare(track, manga, getBranch(manga)) + if (commit) { + repository.saveUpdates(updates) + } + return updates + } + + @VisibleForTesting + suspend fun deleteTrack(mangaId: Long) = withMangaLock(mangaId) { + repository.deleteTrack(mangaId) + } + + private suspend fun getBranch(manga: Manga): String? { + val history = historyRepository.getOne(manga) + return manga.getPreferredBranch(history) + } + + /** + * The main functionality of tracker: check new chapters in [manga] comparing to the [track] + */ + 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) + } + val chapters = requireNotNull(manga.getChapters(branch)) + val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId } + return when { + newChapters.isEmpty() -> { + MangaUpdates.Success( + manga = manga, + newChapters = emptyList(), + isValid = chapters.lastOrNull()?.id == track.lastChapterId, + channelId = null, + ) + } + + newChapters.size == chapters.size -> { + MangaUpdates.Success(manga, emptyList(), isValid = false, channelId = null) + } + + else -> { + MangaUpdates.Success(manga, newChapters, isValid = true, channelId = null) + } + } + } + + private companion object { + + private val mangaMutex = CompositeMutex2() + + @OptIn(ExperimentalContracts::class) + suspend inline fun withMangaLock(id: Long, action: () -> T): T { + contract { + callsInPlace(action, InvocationKind.EXACTLY_ONCE) + } + mangaMutex.lock(id) + try { + return action() + } finally { + mangaMutex.unlock(id) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/tracker/model/MangaTracking.kt b/app/src/main/java/org/xtimms/tokusho/core/tracker/model/MangaTracking.kt new file mode 100644 index 0000000..418b8c6 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/tracker/model/MangaTracking.kt @@ -0,0 +1,14 @@ +package org.xtimms.tokusho.core.tracker.model + +import org.koitharu.kotatsu.parsers.model.Manga +import java.time.Instant + +data class MangaTracking( + val manga: Manga, + val lastChapterId: Long, + val lastCheck: Instant?, +) { + fun isEmpty(): Boolean { + return lastChapterId == 0L + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/tracker/model/MangaUpdates.kt b/app/src/main/java/org/xtimms/tokusho/core/tracker/model/MangaUpdates.kt new file mode 100644 index 0000000..b6abe75 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/tracker/model/MangaUpdates.kt @@ -0,0 +1,28 @@ +package org.xtimms.tokusho.core.tracker.model + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.xtimms.tokusho.core.exceptions.TooManyRequestExceptions + +sealed interface MangaUpdates { + + val manga: Manga + + data class Success( + override val manga: Manga, + val newChapters: List, + val isValid: Boolean, + val channelId: String?, + ) : MangaUpdates { + + fun isNotEmpty() = newChapters.isNotEmpty() + } + + data class Failure( + override val manga: Manga, + val error: Throwable?, + ) : MangaUpdates { + + fun shouldRetry() = error is TooManyRequestExceptions + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/tracker/model/TrackingLogItem.kt b/app/src/main/java/org/xtimms/tokusho/core/tracker/model/TrackingLogItem.kt new file mode 100644 index 0000000..8234c76 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/tracker/model/TrackingLogItem.kt @@ -0,0 +1,12 @@ +package org.xtimms.tokusho.core.tracker.model + +import org.koitharu.kotatsu.parsers.model.Manga +import java.time.Instant + +data class TrackingLogItem( + val id: Long, + val manga: Manga, + val chapters: List, + val createdAt: Instant, + val isNew: Boolean, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/TrackingRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/TrackingRepository.kt new file mode 100644 index 0000000..3beff23 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/TrackingRepository.kt @@ -0,0 +1,256 @@ +package org.xtimms.tokusho.data.repository + +import androidx.annotation.VisibleForTesting +import androidx.collection.MutableLongSet +import androidx.room.withTransaction +import dagger.Reusable +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.xtimms.tokusho.core.database.TokushoDatabase +import org.xtimms.tokusho.core.database.entity.MangaEntity +import org.xtimms.tokusho.core.database.entity.TrackEntity +import org.xtimms.tokusho.core.database.entity.TrackLogEntity +import org.xtimms.tokusho.core.database.entity.toFavouriteCategory +import org.xtimms.tokusho.core.database.entity.toManga +import org.xtimms.tokusho.core.database.entity.toTrackingLogItem +import org.xtimms.tokusho.core.model.FavouriteCategory +import org.xtimms.tokusho.core.model.isLocal +import org.xtimms.tokusho.core.parser.local.LocalMangaRepository +import org.xtimms.tokusho.core.tracker.model.MangaTracking +import org.xtimms.tokusho.core.tracker.model.MangaUpdates +import org.xtimms.tokusho.core.tracker.model.TrackingLogItem +import org.xtimms.tokusho.utils.lang.mapItems +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Provider + +private const val NO_ID = 0L +private const val MAX_QUERY_IDS = 100 + +@Reusable +class TrackingRepository @Inject constructor( + private val db: TokushoDatabase, + private val localMangaRepositoryProvider: Provider, +) { + + private var isGcCalled = AtomicBoolean(false) + + suspend fun getNewChaptersCount(mangaId: Long): Int { + return db.getTracksDao().findNewChapters(mangaId) ?: 0 + } + + fun observeNewChaptersCount(mangaId: Long): Flow { + return db.getTracksDao().observeNewChapters(mangaId).map { it ?: 0 } + } + + fun observeUpdatedMangaCount(): Flow { + return db.getTracksDao().observeNewChapters().map { list -> list.count { it > 0 } } + .onStart { gcIfNotCalled() } + } + + fun observeUpdatedManga(limit: Int = 0): Flow> { + return if (limit == 0) { + db.getTracksDao().observeUpdatedManga() + } else { + db.getTracksDao().observeUpdatedManga(limit) + }.mapItems { it.toManga() } + .distinctUntilChanged() + .onStart { gcIfNotCalled() } + } + + suspend fun getTracks(mangaList: Collection): List { + val ids = mangaList.mapToSet { it.id } + val dao = db.getTracksDao() + val tracks = if (ids.size <= MAX_QUERY_IDS) { + dao.findAll(ids) + } else { + // TODO split tracks in the worker + ids.windowed(MAX_QUERY_IDS, MAX_QUERY_IDS, true) + .flatMap { dao.findAll(it) } + }.groupBy { it.mangaId } + val idSet = MutableLongSet(mangaList.size) + val result = ArrayList(mangaList.size) + for (item in mangaList) { + val manga = if (item.isLocal) { + localMangaRepositoryProvider.get().getRemoteManga(item) ?: continue + } else { + item + } + if (!idSet.add(manga.id)) { + continue + } + val track = tracks[manga.id]?.lastOrNull() + result += MangaTracking( + manga = manga, + lastChapterId = track?.lastChapterId ?: NO_ID, + lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(Instant::ofEpochMilli), + ) + } + return result + } + + @VisibleForTesting + suspend fun getTrack(manga: Manga): MangaTracking { + val track = db.getTracksDao().find(manga.id) + return MangaTracking( + manga = manga, + lastChapterId = track?.lastChapterId ?: NO_ID, + lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(Instant::ofEpochMilli), + ) + } + + @VisibleForTesting + suspend fun deleteTrack(mangaId: Long) { + db.getTracksDao().delete(mangaId) + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun observeTrackingLog(limit: Flow): Flow> { + return limit.flatMapLatest { limitValue -> + combine( + db.getTracksDao().observeNewChaptersMap(), + db.getTrackLogsDao().observeAll(limitValue), + ) { counters, entities -> + val countersMap = counters.toMutableMap() + entities.map { x -> x.toTrackingLogItem(countersMap) } + } + }.onStart { + gcIfNotCalled() + } + } + + suspend fun getLogsCount() = db.getTrackLogsDao().count() + + suspend fun clearLogs() = db.getTrackLogsDao().clear() + + suspend fun clearCounters() = db.getTracksDao().clearCounters() + + suspend fun gc() { + db.getTracksDao().gc() + db.getTrackLogsDao().gc() + } + + suspend fun saveUpdates(updates: MangaUpdates.Success) { + db.withTransaction { + val track = getOrCreateTrack(updates.manga.id).mergeWith(updates) + db.getTracksDao().upsert(track) + if (updates.isValid && updates.newChapters.isNotEmpty()) { + updatePercent(updates) + val logEntity = TrackLogEntity( + mangaId = updates.manga.id, + chapters = updates.newChapters.joinToString("\n") { x -> x.name }, + createdAt = System.currentTimeMillis(), + ) + db.getTrackLogsDao().insert(logEntity) + } + } + } + + suspend fun clearUpdates(ids: Collection) { + when { + ids.isEmpty() -> return + ids.size == 1 -> db.getTracksDao().clearCounter(ids.single()) + else -> db.withTransaction { + for (id in ids) { + db.getTracksDao().clearCounter(id) + } + } + } + } + + suspend fun syncWithHistory(manga: Manga, chapterId: Long) { + val chapters = manga.chapters ?: return + val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } + val track = getOrCreateTrack(manga.id) + val lastNewChapterIndex = chapters.size - track.newChapters + val lastChapterId = chapters.lastOrNull()?.id ?: NO_ID + val entity = TrackEntity( + mangaId = manga.id, + totalChapters = chapters.size, + lastChapterId = lastChapterId, + newChapters = when { + track.newChapters == 0 -> 0 + chapterIndex < 0 -> track.newChapters + chapterIndex >= lastNewChapterIndex -> chapters.lastIndex - chapterIndex + else -> track.newChapters + }, + lastCheck = System.currentTimeMillis(), + lastNotifiedChapterId = lastChapterId, + ) + db.getTracksDao().upsert(entity) + } + + suspend fun getCategoriesCount(): IntArray { + val categories = db.getFavouriteCategoriesDao().findAll() + return intArrayOf( + categories.count { it.track }, + categories.size, + ) + } + + suspend fun getAllFavouritesManga(): Map> { + val categories = db.getFavouriteCategoriesDao().findAll() + return categories.associateTo(LinkedHashMap(categories.size)) { categoryEntity -> + categoryEntity.toFavouriteCategory() to + db.getFavouritesDao().findAllManga(categoryEntity.categoryId).toMangaList() + } + } + + suspend fun getAllHistoryManga(): List { + return db.getHistoryDao().findAllManga().toMangaList() + } + + private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity { + return db.getTracksDao().find(mangaId) ?: TrackEntity( + mangaId = mangaId, + totalChapters = 0, + lastChapterId = 0L, + newChapters = 0, + lastCheck = 0L, + lastNotifiedChapterId = 0L, + ) + } + + private suspend fun updatePercent(updates: MangaUpdates.Success) { + val history = db.getHistoryDao().find(updates.manga.id) ?: return + val chapters = updates.manga.chapters + if (chapters.isNullOrEmpty()) { + return + } + val chapterIndex = chapters.indexOfFirst { it.id == history.chapterId } + if (chapterIndex < 0) { + return + } + val position = (chapters.size - updates.newChapters.size) * history.percent + val newPercent = position / chapters.size.toFloat() + db.getHistoryDao().update(history.copy(percent = newPercent)) + } + + private fun TrackEntity.mergeWith(updates: MangaUpdates.Success): TrackEntity { + val chapters = updates.manga.chapters.orEmpty() + return TrackEntity( + mangaId = mangaId, + totalChapters = chapters.size, + lastChapterId = chapters.lastOrNull()?.id ?: NO_ID, + newChapters = if (updates.isValid) newChapters + updates.newChapters.size else 0, + lastCheck = System.currentTimeMillis(), + lastNotifiedChapterId = NO_ID, + ) + } + + private suspend fun gcIfNotCalled() { + if (isGcCalled.compareAndSet(false, true)) { + gc() + } + } + + private fun Collection.toMangaList() = map { it.toManga(emptySet()) } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedView.kt b/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedView.kt index 8c692ee..10a97a7 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedView.kt @@ -1,40 +1,78 @@ package org.xtimms.tokusho.sections.feed +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ClearAll +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.RssFeed import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.ImageLoader import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.ConfirmButton +import org.xtimms.tokusho.core.components.DialogCheckBoxItem +import org.xtimms.tokusho.core.components.DismissButton +import org.xtimms.tokusho.core.components.ListGroupHeader import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar +import org.xtimms.tokusho.core.components.TokushoDialog +import org.xtimms.tokusho.core.screens.EmptyScreen +import org.xtimms.tokusho.core.tracker.model.TrackingLogItem +import org.xtimms.tokusho.sections.feed.model.toFeedItem +import org.xtimms.tokusho.utils.lang.calculateTimeAgo +import java.time.Instant const val FEED_DESTINATION = "feed" +@OptIn(ExperimentalFoundationApi::class) @Composable fun FeedView( + coil: ImageLoader, + viewModel: FeedViewModel = hiltViewModel(), navigateBack: () -> Unit, navigateToShelf: () -> Unit, ) { - rememberScrollState() + + var showClearDialog by remember { mutableStateOf(false) } + + val feed by viewModel.content.collectAsStateWithLifecycle(emptyList()) ScaffoldWithClassicTopAppBar( title = stringResource(R.string.feed), navigateBack = navigateBack, actions = { + IconButton(onClick = { viewModel.updateFeed() }) { + Icon(imageVector = Icons.Outlined.Refresh, contentDescription = null) + } IconButton(onClick = { navigateToShelf() }) { Icon(imageVector = Icons.Outlined.Tune, contentDescription = null) } }, floatingActionButton = { - ExtendedFloatingActionButton(onClick = { /*TODO*/ } + ExtendedFloatingActionButton(onClick = { showClearDialog = true } ) { Icon( imageVector = Icons.Outlined.ClearAll, @@ -47,6 +85,146 @@ fun FeedView( } } ) { padding -> + Box( + Modifier.fillMaxSize() + ) { + Column(Modifier.fillMaxSize()) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = padding + ) { + feedUiItems(coil, viewModel.getUiModel()) + } + } + if (feed.isEmpty()) { + EmptyScreen( + icon = Icons.Outlined.RssFeed, + title = R.string.empty_here, + description = R.string.no_recent_updates + ) + } + } + } + + if (showClearDialog) { + ClearFeedDialog( + onDismissRequest = { showClearDialog = false }, + isClearInfoAboutNewChaptersSelected = false, + onConfirm = { isClearInfoAboutNewChaptersSelected -> + if (isClearInfoAboutNewChaptersSelected) { + viewModel.clearFeed(true) + } else { + viewModel.clearFeed(false) + } + } + ) + } +} + +@Composable +fun ClearFeedDialog( + onDismissRequest: () -> Unit = {}, + isClearInfoAboutNewChaptersSelected: Boolean, + onConfirm: (isPagesCacheSelected: Boolean) -> Unit = { _ -> } +) { + + var infoAboutNewChapters by remember { + mutableStateOf(isClearInfoAboutNewChaptersSelected) + } + + TokushoDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + ConfirmButton { + onConfirm(infoAboutNewChapters) + onDismissRequest() + } + }, + dismissButton = { + DismissButton { + onDismissRequest() + } + }, + title = { + Text( + text = stringResource( + id = R.string.clear_updates_feed + ) + ) + }, + icon = { Icon(imageVector = Icons.Outlined.ClearAll, contentDescription = null) }, + text = { + Column { + Text( + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 12.dp), + style = MaterialTheme.typography.bodyLarge, + text = stringResource(id = R.string.clear_updates_feed_desc) + ) + DialogCheckBoxItem( + text = stringResource(id = R.string.clear_info_about_new_chapters), + checked = infoAboutNewChapters + ) { + infoAboutNewChapters = !infoAboutNewChapters + } + } + }) +} + +@Preview +@Composable +private fun ClearFeedDialogPreview() { + ClearFeedDialog( + onDismissRequest = {}, + isClearInfoAboutNewChaptersSelected = false + ) +} + +sealed interface FeedUiModel { + data class Header(val date: Instant) : FeedUiModel + data class Item(val item: TrackingLogItem) : FeedUiModel +} +@OptIn(ExperimentalFoundationApi::class) +internal fun LazyListScope.feedUiItems( + coil: ImageLoader, + uiModels: List +) { + items( + items = uiModels, + contentType = { + when (it) { + is FeedUiModel.Header -> "header" + is FeedUiModel.Item -> "item" + } + }, + key = { + when (it) { + is FeedUiModel.Header -> "feedHeader-${it.hashCode()}" + is FeedUiModel.Item -> "feed-${it.item.manga.id}" + } + }, + ) { item -> + when (item) { + is FeedUiModel.Header -> { + ListGroupHeader( + modifier = Modifier.animateItemPlacement(), + text = calculateTimeAgo(item.date).format( + LocalContext.current.resources + ) + ) + } + is FeedUiModel.Item -> { + val track = item.item + FeedViewItem( + modifier = Modifier.animateItemPlacement(), + coil = coil, + selected = false, + feed = track.toFeedItem(), + onClick = { /*TODO*/ }, + onLongClick = { /*TODO*/ }) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedViewItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedViewItem.kt new file mode 100644 index 0000000..a482ccc --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedViewItem.kt @@ -0,0 +1,116 @@ +package org.xtimms.tokusho.sections.feed + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.ImageLoader +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.MangaCover +import org.xtimms.tokusho.sections.feed.model.FeedItem +import org.xtimms.tokusho.utils.composable.selectedBackground + +const val ReadItemAlpha = .38f + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FeedViewItem( + coil: ImageLoader, + selected: Boolean, + feed: FeedItem, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + + val haptic = LocalHapticFeedback.current + val textAlpha = if (!feed.isNew) 1f else ReadItemAlpha + + Row( + modifier = modifier + .selectedBackground(selected) + .combinedClickable( + onClick = onClick, + onLongClick = { + onLongClick() + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + ) + .height(64.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MangaCover.Square( + coil = coil, + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxHeight(), + data = feed.manga.coverUrl, + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .weight(1f), + ) { + Text( + text = feed.manga.title, + maxLines = 1, + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current.copy(alpha = textAlpha), + overflow = TextOverflow.Ellipsis, + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + var textHeight by remember { mutableIntStateOf(0) } + if (!feed.isNew) { + Icon( + imageVector = Icons.Filled.Circle, + contentDescription = stringResource(R.string.unread), + modifier = Modifier + .height(8.dp) + .padding(end = 8.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = pluralStringResource( + id = R.plurals.new_chapters, + feed.count, + feed.count + ), + maxLines = 1, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = textAlpha), + overflow = TextOverflow.Ellipsis, + onTextLayout = { textHeight = it.size.height }, + modifier = Modifier + .weight(weight = 1f, fill = false), + ) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedViewModel.kt new file mode 100644 index 0000000..5bf9398 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedViewModel.kt @@ -0,0 +1,71 @@ +package org.xtimms.tokusho.sections.feed + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel +import org.xtimms.tokusho.data.repository.TrackingRepository +import org.xtimms.tokusho.utils.lang.MutableEventFlow +import org.xtimms.tokusho.utils.lang.call +import org.xtimms.tokusho.utils.lang.insertSeparators +import org.xtimms.tokusho.work.tracker.TrackWorker +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject + +private const val PAGE_SIZE = 20 + +@HiltViewModel +class FeedViewModel @Inject constructor( + private val repository: TrackingRepository, + private val trackScheduler: TrackWorker.Scheduler, +) : KotatsuBaseViewModel() { + + private val limit = MutableStateFlow(PAGE_SIZE) + private val isReady = AtomicBoolean(false) + + val isRunning = trackScheduler.observeIsRunning() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) + + val onFeedCleared = MutableEventFlow() + + val content = repository.observeTrackingLog(limit) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + + init { + launchJob(Dispatchers.Default) { + repository.gc() + } + } + + fun clearFeed(clearCounters: Boolean) { + launchLoadingJob(Dispatchers.Default) { + repository.clearLogs() + if (clearCounters) { + repository.clearCounters() + } + onFeedCleared.call(Unit) + } + } + + fun updateFeed() { + trackScheduler.startNow() + } + + fun getUiModel(): List { + return content.value + .map { FeedUiModel.Item(it) } + .insertSeparators { before, after -> + val beforeDate = before?.item?.createdAt + val afterDate = after?.item?.createdAt + when { + beforeDate != afterDate && afterDate != null -> FeedUiModel.Header(afterDate) + // Return null to avoid adding a separator between two items. + else -> null + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/feed/model/FeedItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/feed/model/FeedItem.kt new file mode 100644 index 0000000..75cde68 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/feed/model/FeedItem.kt @@ -0,0 +1,17 @@ +package org.xtimms.tokusho.sections.feed.model + +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.tokusho.core.model.ListModel + +data class FeedItem( + val id: Long, + val imageUrl: String, + val title: String, + val manga: Manga, + val count: Int, + val isNew: Boolean, +) : ListModel { + override fun areItemsTheSame(other: ListModel): Boolean { + return other is FeedItem && other.id == id + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/feed/model/ListModelConversionExt.kt b/app/src/main/java/org/xtimms/tokusho/sections/feed/model/ListModelConversionExt.kt new file mode 100644 index 0000000..7e4d6d1 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/feed/model/ListModelConversionExt.kt @@ -0,0 +1,12 @@ +package org.xtimms.tokusho.sections.feed.model + +import org.xtimms.tokusho.core.tracker.model.TrackingLogItem + +fun TrackingLogItem.toFeedItem() = FeedItem( + id = id, + imageUrl = manga.coverUrl, + title = manga.title, + count = chapters.size, + manga = manga, + isNew = isNew, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt index 9670d45..905b431 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt @@ -24,4 +24,19 @@ fun List.takeMostFrequent(limit: Int): List { add(entries[i].key) } } +} + +fun List.insertSeparators( + generator: (T?, T?) -> R?, +): List { + if (isEmpty()) return emptyList() + val newList = mutableListOf() + for (i in -1..lastIndex) { + val before = getOrNull(i) + before?.let(newList::add) + val after = getOrNull(i + 1) + val separator = generator.invoke(before, after) + separator?.let(newList::add) + } + return newList } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt index cc846c5..254aad0 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt @@ -5,6 +5,8 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.transform +import java.util.concurrent.atomic.AtomicInteger fun Flow.onEachWhile(action: suspend (T) -> Boolean): Flow { var isCalled = false @@ -17,6 +19,14 @@ fun Flow.onEachWhile(action: suspend (T) -> Boolean): Flow { } } +fun Flow.onEachIndexed(action: suspend (index: Int, T) -> Unit): Flow { + val counter = AtomicInteger(0) + return transform { value -> + action(counter.getAndIncrement(), value) + return@transform emit(value) + } +} + inline fun Flow>.mapItems(crossinline transform: (T) -> R): Flow> { return map { list -> list.map(transform) } } diff --git a/app/src/main/java/org/xtimms/tokusho/work/WorkScheduleManager.kt b/app/src/main/java/org/xtimms/tokusho/work/WorkScheduleManager.kt index 35db37f..e0979b4 100644 --- a/app/src/main/java/org/xtimms/tokusho/work/WorkScheduleManager.kt +++ b/app/src/main/java/org/xtimms/tokusho/work/WorkScheduleManager.kt @@ -4,17 +4,26 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.xtimms.tokusho.utils.lang.processLifecycleScope import org.xtimms.tokusho.work.suggestions.SuggestionsWorker +import org.xtimms.tokusho.work.tracker.TrackWorker import javax.inject.Inject import javax.inject.Singleton @Singleton class WorkScheduleManager @Inject constructor( private val suggestionScheduler: SuggestionsWorker.Scheduler, + private val trackerScheduler: TrackWorker.Scheduler, ) { fun init() { processLifecycleScope.launch(Dispatchers.Default) { - updateWorkerImpl(suggestionScheduler, isEnabled = true, force = false) + updateWorkerImpl(trackerScheduler, isEnabled = true, force = false) // TODO + updateWorkerImpl(suggestionScheduler, isEnabled = true, force = false) // TODO + } + } + + private fun updateWorker(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) { + processLifecycleScope.launch(Dispatchers.Default) { + updateWorkerImpl(scheduler, isEnabled, force) } } diff --git a/app/src/main/java/org/xtimms/tokusho/work/tracker/TrackWorker.kt b/app/src/main/java/org/xtimms/tokusho/work/tracker/TrackWorker.kt new file mode 100644 index 0000000..5b76060 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/work/tracker/TrackWorker.kt @@ -0,0 +1,372 @@ +package org.xtimms.tokusho.work.tracker + +import android.content.Context +import android.content.pm.ServiceInfo +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.content.edit +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +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 +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.exceptions.CloudflareProtectedException +import org.xtimms.tokusho.core.logs.FileLogger +import org.xtimms.tokusho.core.logs.TrackerLogger +import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.core.tracker.Tracker +import org.xtimms.tokusho.core.tracker.model.MangaUpdates +import org.xtimms.tokusho.utils.lang.awaitUniqueWorkInfoByName +import org.xtimms.tokusho.utils.lang.onEachIndexed +import org.xtimms.tokusho.utils.lang.toBitmapOrNull +import org.xtimms.tokusho.utils.system.checkNotificationPermission +import org.xtimms.tokusho.utils.system.trySetForeground +import org.xtimms.tokusho.work.PeriodicWorkScheduler +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltWorker +class TrackWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParams: WorkerParameters, + private val coil: ImageLoader, + private val tracker: Tracker, + private val workManager: WorkManager, + @TrackerLogger private val logger: FileLogger, +) : CoroutineWorker(context, workerParams) { + + private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) } + + override suspend fun doWork(): Result { + trySetForeground() + logger.log("doWork(): attempt $runAttemptCount") + return try { + doWorkImpl() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.log("fatal", e) + Result.failure() + } finally { + withContext(NonCancellable) { + logger.flush() + notificationManager.cancel(WORKER_NOTIFICATION_ID) + } + } + } + + private suspend fun doWorkImpl(): Result { + if (!AppSettings.isTrackerEnabled()) { + return Result.success(workDataOf(0, 0)) + } + val retryIds = getRetryIds() + val tracks = if (retryIds.isNotEmpty()) { + tracker.getTracks(retryIds) + } else { + tracker.getAllTracks() + } + logger.log("Total ${tracks.size} tracks") + if (tracks.isEmpty()) { + return Result.success(workDataOf(0, 0)) + } + + val results = checkUpdatesAsync(tracks) + tracker.gc() + + var success = 0 + var failed = 0 + val retry = HashSet() + results.forEach { x -> + when (x) { + is MangaUpdates.Success -> success++ + is MangaUpdates.Failure -> { + failed++ + if (x.shouldRetry()) { + retry += x.manga.id + } + } + } + } + if (runAttemptCount > MAX_ATTEMPTS) { + retry.clear() + } + setRetryIds(retry) + logger.log("Result: success: $success, failed: $failed, retry: ${retry.size}") + val resultData = workDataOf(success, failed) + return when { + retry.isNotEmpty() -> Result.retry() + success == 0 && failed != 0 -> Result.failure(resultData) + else -> Result.success(resultData) + } + } + + private suspend fun checkUpdatesAsync(tracks: List): List { + val semaphore = Semaphore(MAX_PARALLELISM) + return channelFlow { + for ((track, channelId) in tracks) { + launch { + semaphore.withPermit { + send( + runCatchingCancellable { + tracker.fetchUpdates(track, commit = true) + .copy(channelId = channelId) + }.onFailure { e -> + logger.log("checkUpdatesAsync", e) + }.getOrElse { error -> + MangaUpdates.Failure( + manga = track.manga, + error = error, + ) + }, + ) + } + } + } + }.onEachIndexed { index, it -> + if (applicationContext.checkNotificationPermission()) { + notificationManager.notify(WORKER_NOTIFICATION_ID, createWorkerNotification(tracks.size, index + 1)) + } + when (it) { + is MangaUpdates.Failure -> { + val e = it.error + if (e is CloudflareProtectedException) { + e.printStackTrace() + } + } + + is MangaUpdates.Success -> { + if (it.isValid && it.isNotEmpty()) { + showNotification( + manga = it.manga, + channelId = it.channelId, + newChapters = it.newChapters, + ) + } + } + } + }.toList(ArrayList(tracks.size)) + } + + private suspend fun showNotification( + manga: Manga, + channelId: String?, + newChapters: List, + ) { + if (newChapters.isEmpty() || channelId == null || !applicationContext.checkNotificationPermission()) { + return + } + val id = manga.url.hashCode() + 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_tokusho) + val style = NotificationCompat.InboxStyle(this) + for (chapter in newChapters) { + style.addLine(chapter.name) + } + style.setSummaryText(manga.title) + style.setBigContentTitle(summary) + setStyle(style) + setAutoCancel(true) + setCategory(NotificationCompat.CATEGORY_PROMO) + setVisibility(if (manga.isNsfw) VISIBILITY_SECRET else VISIBILITY_PUBLIC) + setShortcutId(manga.id.toString()) + priority = NotificationCompat.PRIORITY_DEFAULT + } + notificationManager.notify(TAG, id, builder.build()) + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val channel = NotificationChannelCompat.Builder( + WORKER_CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_LOW, + ) + .setName(applicationContext.getString(R.string.check_for_new_chapters)) + .setShowBadge(false) + .setVibrationEnabled(false) + .setSound(null, null) + .setLightsEnabled(false) + .build() + notificationManager.createNotificationChannel(channel) + + 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, + ) + } else { + ForegroundInfo(WORKER_NOTIFICATION_ID, notification) + } + } + + private fun createWorkerNotification(max: Int, progress: Int) = NotificationCompat.Builder( + applicationContext, + WORKER_CHANNEL_ID, + ).apply { + setContentTitle(applicationContext.getString(R.string.check_for_new_chapters)) + priority = NotificationCompat.PRIORITY_MIN + setCategory(NotificationCompat.CATEGORY_SERVICE) + setDefaults(0) + setOngoing(false) + setOnlyAlertOnce(true) + setSilent(true) + addAction( + com.google.android.material.R.drawable.material_ic_clear_black_24dp, + applicationContext.getString(android.R.string.cancel), + workManager.createCancelPendingIntent(id), + ) + if (max > 0) { + setSubText(applicationContext.getString(R.string.fraction_pattern, progress, max)) + } + setProgress(max, progress, max == 0) + setSmallIcon(android.R.drawable.stat_notify_sync) + foregroundServiceBehavior = if (TAG_ONESHOT in tags) { + NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE + } else { + NotificationCompat.FOREGROUND_SERVICE_DEFERRED + } + }.build() + + private suspend fun setRetryIds(ids: Set) = runInterruptible(Dispatchers.IO) { + val prefs = applicationContext.getSharedPreferences(TAG, Context.MODE_PRIVATE) + prefs.edit(commit = true) { + if (ids.isEmpty()) { + remove(KEY_RETRY_IDS) + } else { + putStringSet(KEY_RETRY_IDS, ids.mapToSet { it.toString() }) + } + } + } + + private fun getRetryIds(): Set { + val prefs = applicationContext.getSharedPreferences(TAG, Context.MODE_PRIVATE) + return prefs.getStringSet(KEY_RETRY_IDS, null)?.mapToSet { it.toLong() }.orEmpty() + } + + private fun workDataOf(success: Int, failed: Int): Data { + return Data.Builder() + .putInt(DATA_KEY_SUCCESS, success) + .putInt(DATA_KEY_FAILED, failed) + .build() + } + + @Reusable + class Scheduler @Inject constructor( + private val workManager: WorkManager, + ) : PeriodicWorkScheduler { + + override suspend fun schedule() { + val constraints = createConstraints() + val request = PeriodicWorkRequestBuilder(4, TimeUnit.HOURS) + .setConstraints(constraints) + .addTag(TAG) + .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES) + .build() + workManager + .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) + .await() + } + + override suspend fun unschedule() { + workManager + .cancelUniqueWork(TAG) + .await() + } + + override suspend fun isScheduled(): Boolean { + return workManager + .awaitUniqueWorkInfoByName(TAG) + .any { !it.state.isFinished } + } + + fun startNow() { + val constraints = + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag(TAG_ONESHOT) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + workManager.enqueue(request) + } + + fun observeIsRunning(): Flow { + val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build() + return workManager.getWorkInfosFlow(query) + .map { works -> + works.any { x -> x.state == WorkInfo.State.RUNNING } + } + } + + private fun createConstraints() = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + } + + private companion object { + + const val WORKER_CHANNEL_ID = "track_worker" + const val WORKER_NOTIFICATION_ID = 35 + const val TAG = "tracking" + const val TAG_ONESHOT = "tracking_oneshot" + const val MAX_PARALLELISM = 3 + const val MAX_ATTEMPTS = 3 + const val DATA_KEY_SUCCESS = "success" + const val DATA_KEY_FAILED = "failed" + const val KEY_RETRY_IDS = "retry" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/work/tracker/TrackerNotificationChannels.kt b/app/src/main/java/org/xtimms/tokusho/work/tracker/TrackerNotificationChannels.kt new file mode 100644 index 0000000..99f1c30 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/work/tracker/TrackerNotificationChannels.kt @@ -0,0 +1,111 @@ +package org.xtimms.tokusho.work.tracker + +import android.app.NotificationManager +import android.content.Context +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationChannelGroupCompat +import androidx.core.app.NotificationManagerCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.model.FavouriteCategory +import javax.inject.Inject + +class TrackerNotificationChannels @Inject constructor( + @ApplicationContext private val context: Context, +) { + + 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 + } + val channel = manager.getNotificationChannel(getFavouritesChannelId(category.id)) + return channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE + } + + fun isHistoryNotificationsEnabled(): Boolean { + if (!manager.areNotificationsEnabled()) { + return false + } + val channel = manager.getNotificationChannel(getHistoryChannelId()) + return channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE + } + + fun isNotificationGroupEnabled(): Boolean { + 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" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/work/tracker/TrackingItem.kt b/app/src/main/java/org/xtimms/tokusho/work/tracker/TrackingItem.kt new file mode 100644 index 0000000..a72f353 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/work/tracker/TrackingItem.kt @@ -0,0 +1,8 @@ +package org.xtimms.tokusho.work.tracker + +import org.xtimms.tokusho.core.tracker.model.MangaTracking + +data class TrackingItem( + val tracking: MangaTracking, + val channelId: String?, +) \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_stat_tokusho.png b/app/src/main/res/drawable-hdpi/ic_stat_tokusho.png new file mode 100644 index 0000000000000000000000000000000000000000..935f746e4cbf87e5d7b93639efe3028a5b064e5b GIT binary patch literal 981 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 Ef_$2XrvLx| literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_tokusho.png b/app/src/main/res/drawable-xxhdpi/ic_stat_tokusho.png new file mode 100644 index 0000000000000000000000000000000000000000..dcc3f231cd0f18626187043d5b56249382082ceb GIT binary patch literal 1695 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 month ago %1$d months ago + + %1$d new chapter + %1$d new chapters + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 72f7a57..3a1a478 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -219,4 +219,11 @@ Use the wsrv.nl service to reduce traffic usage and speed up image loading if possible Grid columns count %d column(s) would be used for manga grids + %1$d/%2$d + Check for new chapters + New chapters + Clear updates feed + Also clear information about new chapters + Clear all update history permanently? + No recent updates \ No newline at end of file