Implement new chapters tracking system

master
Zakhar Timoshenko 2 years ago
parent ac9626616d
commit d3e10583d3
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -208,6 +208,7 @@ fun Navigation(
composable(FEED_DESTINATION) {
FeedView(
coil = coil,
navigateBack = navigateBack,
navigateToShelf = { navController.navigate(SHELF_SETTINGS_DESTINATION) }
)

@ -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

@ -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<List<TrackLogWithManga>>
@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
}

@ -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<TrackEntity>
@Query("SELECT * FROM tracks WHERE manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<TrackEntity>
@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<Map<@MapColumn(columnName = "manga_id") Long, @MapColumn(
columnName = "chapters_new"
) Int>>
@Query("SELECT chapters_new FROM tracks")
abstract fun observeNewChapters(): Flow<List<Int>>
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract fun observeNewChapters(mangaId: Long): Flow<Int?>
@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<List<MangaWithTags>>
@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<List<MangaWithTags>>
@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)
}

@ -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<BookmarkEntity>.toBookmarks(manga: Manga) = map {
@JvmName("bookmarksIds")
fun Collection<Bookmark>.ids() = map { it.pageId }
fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): 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<Long, Int>.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(

@ -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
)

@ -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(),
)

@ -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<TagEntity>,
)

@ -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)

@ -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<TrackingItem> {
val knownManga = MutableLongSet()
val result = ArrayList<TrackingItem>()
// 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<Long>): List<TrackingItem> {
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<Long>()
@OptIn(ExperimentalContracts::class)
suspend inline fun <T> withMangaLock(id: Long, action: () -> T): T {
contract {
callsInPlace(action, InvocationKind.EXACTLY_ONCE)
}
mangaMutex.lock(id)
try {
return action()
} finally {
mangaMutex.unlock(id)
}
}
}
}

@ -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
}
}

@ -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<MangaChapter>,
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
}
}

@ -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<String>,
val createdAt: Instant,
val isNew: Boolean,
)

@ -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<LocalMangaRepository>,
) {
private var isGcCalled = AtomicBoolean(false)
suspend fun getNewChaptersCount(mangaId: Long): Int {
return db.getTracksDao().findNewChapters(mangaId) ?: 0
}
fun observeNewChaptersCount(mangaId: Long): Flow<Int> {
return db.getTracksDao().observeNewChapters(mangaId).map { it ?: 0 }
}
fun observeUpdatedMangaCount(): Flow<Int> {
return db.getTracksDao().observeNewChapters().map { list -> list.count { it > 0 } }
.onStart { gcIfNotCalled() }
}
fun observeUpdatedManga(limit: Int = 0): Flow<List<Manga>> {
return if (limit == 0) {
db.getTracksDao().observeUpdatedManga()
} else {
db.getTracksDao().observeUpdatedManga(limit)
}.mapItems { it.toManga() }
.distinctUntilChanged()
.onStart { gcIfNotCalled() }
}
suspend fun getTracks(mangaList: Collection<Manga>): List<MangaTracking> {
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<MangaTracking>(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<Int>): Flow<List<TrackingLogItem>> {
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<Long>) {
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<FavouriteCategory, List<Manga>> {
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<Manga> {
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<MangaEntity>.toMangaList() = map { it.toManga(emptySet()) }
}

@ -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<FeedUiModel>
) {
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*/ })
}
}
}
}

@ -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),
)
}
}
}
}

@ -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<Unit>()
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<FeedUiModel> {
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
}
}
}
}

@ -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
}
}

@ -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,
)

@ -25,3 +25,18 @@ fun <T> List<T>.takeMostFrequent(limit: Int): List<T> {
}
}
}
fun <T : R, R : Any> List<T>.insertSeparators(
generator: (T?, T?) -> R?,
): List<R> {
if (isEmpty()) return emptyList()
val newList = mutableListOf<R>()
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
}

@ -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 <T> Flow<T>.onEachWhile(action: suspend (T) -> Boolean): Flow<T> {
var isCalled = false
@ -17,6 +19,14 @@ fun <T> Flow<T>.onEachWhile(action: suspend (T) -> Boolean): Flow<T> {
}
}
fun <T> Flow<T>.onEachIndexed(action: suspend (index: Int, T) -> Unit): Flow<T> {
val counter = AtomicInteger(0)
return transform { value ->
action(counter.getAndIncrement(), value)
return@transform emit(value)
}
}
inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> {
return map { list -> list.map(transform) }
}

@ -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)
}
}

@ -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<Long>()
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<TrackingItem>): List<MangaUpdates> {
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<MangaChapter>,
) {
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<Long>) = 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<Long> {
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<TrackWorker>(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<TrackWorker>()
.setConstraints(constraints)
.addTag(TAG_ONESHOT)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
workManager.enqueue(request)
}
fun observeIsRunning(): Flow<Boolean> {
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"
}
}

@ -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<FavouriteCategory>) {
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"
}
}

@ -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?,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 981 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@ -36,4 +36,8 @@
<item quantity="one">%1$d month ago</item>
<item quantity="other">%1$d months ago</item>
</plurals>
<plurals name="new_chapters">
<item quantity="one">%1$d new chapter</item>
<item quantity="other">%1$d new chapters</item>
</plurals>
</resources>

@ -219,4 +219,11 @@
<string name="images_optimization_proxy_desc">Use the wsrv.nl service to reduce traffic usage and speed up image loading if possible</string>
<string name="grid_columns_count">Grid columns count</string>
<string name="grid_columns_count_desc">%d column(s) would be used for manga grids</string>
<string name="fraction_pattern">%1$d/%2$d</string>
<string name="check_for_new_chapters">Check for new chapters</string>
<string name="new_chapters">New chapters</string>
<string name="clear_updates_feed">Clear updates feed</string>
<string name="clear_info_about_new_chapters">Also clear information about new chapters</string>
<string name="clear_updates_feed_desc">Clear all update history permanently?</string>
<string name="no_recent_updates">No recent updates</string>
</resources>
Loading…
Cancel
Save