Implement new chapters tracking system
parent
ac9626616d
commit
d3e10583d3
@ -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)
|
||||||
|
}
|
||||||
@ -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>,
|
||||||
|
)
|
||||||
@ -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()) }
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
@ -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 |
Loading…
Reference in New Issue