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 0000000..935f746 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_tokusho.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_tokusho.png b/app/src/main/res/drawable-mdpi/ic_stat_tokusho.png new file mode 100644 index 0000000..c085836 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_tokusho.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_tokusho.png b/app/src/main/res/drawable-xhdpi/ic_stat_tokusho.png new file mode 100644 index 0000000..0b83b9a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_tokusho.png differ 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 0000000..dcc3f23 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_tokusho.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stat_tokusho.png b/app/src/main/res/drawable-xxxhdpi/ic_stat_tokusho.png new file mode 100644 index 0000000..7e10f32 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_stat_tokusho.png differ diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index 511237b..17d669c 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -36,4 +36,8 @@ %1$d 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