diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt index b97aa575c..3b63f429e 100644 --- a/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt +++ b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt @@ -149,6 +149,34 @@ class TrackerTest : KoinTest { assertEquals(0, repository.getNewChaptersCount(mangaFull.id)) } + @Test + fun syncWithHistory() = runTest { + val mangaFull = loadManga("full.json") + val mangaFirst = loadManga("first_chapters.json") + tracker.deleteTrack(mangaFull.id) + + tracker.checkUpdates(mangaFirst, commit = true).apply { + assertFalse(isValid) + assert(newChapters.isEmpty()) + } + assertEquals(0, repository.getNewChaptersCount(mangaFirst.id)) + tracker.checkUpdates(mangaFull, commit = true).apply { + assertTrue(isValid) + assertEquals(3, newChapters.size) + } + assertEquals(3, repository.getNewChaptersCount(mangaFirst.id)) + + val chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) } + repository.syncWithHistory(mangaFull, chapter.id) + + assertEquals(1, repository.getNewChaptersCount(mangaFirst.id)) + tracker.checkUpdates(mangaFull, commit = true).apply { + assertTrue(isValid) + assert(newChapters.isEmpty()) + } + assertEquals(1, repository.getNewChaptersCount(mangaFirst.id)) + } + private suspend fun loadManga(name: String): Manga { val assets = InstrumentationRegistry.getInstrumentation().context.assets val manga = assets.open("manga/$name").use { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 382899c3a..8fb901792 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -1,8 +1,15 @@ package org.koitharu.kotatsu.details.ui -import androidx.lifecycle.* -import kotlinx.coroutines.* +import androidx.lifecycle.LiveData +import androidx.lifecycle.asFlow +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaIntent @@ -23,14 +30,13 @@ import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.printStackTraceDebug -import java.io.IOException class DetailsViewModel( intent: MangaIntent, private val historyRepository: HistoryRepository, favouritesRepository: FavouritesRepository, private val localMangaRepository: LocalMangaRepository, - private val trackingRepository: TrackingRepository, + trackingRepository: TrackingRepository, mangaDataRepository: MangaDataRepository, private val bookmarksRepository: BookmarksRepository, private val settings: AppSettings, @@ -54,9 +60,8 @@ class DetailsViewModel( private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() } .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) - private val newChapters = viewModelScope.async(Dispatchers.Default) { - trackingRepository.getNewChaptersCount(delegate.mangaId) - } + private val newChapters = trackingRepository.observeNewChaptersCount(delegate.mangaId) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) private val chaptersQuery = MutableStateFlow("") @@ -65,7 +70,7 @@ class DetailsViewModel( val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext) val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext) - val newChaptersCount = liveData(viewModelScope.coroutineContext) { emit(newChapters.await()) } + val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext) val readingHistory = history.asLiveData(viewModelScope.coroutineContext) val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext) @@ -97,8 +102,9 @@ class DetailsViewModel( delegate.relatedManga, history, delegate.selectedBranch, - ) { manga, related, history, branch -> - delegate.mapChapters(manga, related, history, newChapters.await(), branch) + newChapters, + ) { manga, related, history, branch, news -> + delegate.mapChapters(manga, related, history, news, branch) }, chaptersReversed, chaptersQuery, diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt index 8a566ce9d..2fed9de12 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.tracker.data import androidx.room.* +import kotlinx.coroutines.flow.Flow @Dao abstract class TracksDao { @@ -17,6 +18,9 @@ abstract class TracksDao { @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") abstract suspend fun findNewChapters(mangaId: Long): Int? + @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") + abstract fun observeNewChapters(mangaId: Long): Flow + @Query("DELETE FROM tracks") abstract suspend fun clear() diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt index 694f66197..bf31ad68f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt @@ -3,6 +3,8 @@ package org.koitharu.kotatsu.tracker.domain import androidx.annotation.VisibleForTesting import androidx.room.withTransaction import java.util.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.toManga @@ -28,6 +30,10 @@ class TrackingRepository( return db.tracksDao.findNewChapters(mangaId) ?: 0 } + fun observeNewChaptersCount(mangaId: Long): Flow { + return db.tracksDao.observeNewChapters(mangaId).map { it ?: 0 } + } + suspend fun getTracks(mangaList: Collection): List { val ids = mangaList.mapToSet { it.id } val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId } diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt index 3c17281d0..937f06808 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt @@ -7,4 +7,7 @@ class MangaUpdates( val manga: Manga, val newChapters: List, val isValid: Boolean, -) \ No newline at end of file +) { + + fun isNotEmpty() = newChapters.isNotEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt index 32120ea94..3bcc46ea7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt @@ -25,7 +25,6 @@ import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.utils.ext.addMenuProvider import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.measureHeight -import org.koitharu.kotatsu.utils.progress.Progress class FeedFragment : BaseFragment(), @@ -35,7 +34,6 @@ class FeedFragment : private val viewModel by viewModel() private var feedAdapter: FeedAdapter? = null - private var updateStatusSnackbar: Snackbar? = null private var paddingVertical = 0 private var paddingHorizontal = 0 @@ -60,6 +58,7 @@ class FeedFragment : ) addItemDecoration(decoration) } + binding.swipeRefreshLayout.isEnabled = false addMenuProvider(FeedMenuProvider(binding.recyclerView, viewModel)) viewModel.content.observe(viewLifecycleOwner, this::onListChanged) @@ -67,13 +66,12 @@ class FeedFragment : viewModel.onFeedCleared.observe(viewLifecycleOwner) { onFeedCleared() } - TrackWorker.getProgressLiveData(view.context.applicationContext) - .observe(viewLifecycleOwner, this::onUpdateProgressChanged) + TrackWorker.getIsRunningLiveData(view.context.applicationContext) + .observe(viewLifecycleOwner, this::onIsTrackerRunningChanged) } override fun onDestroyView() { feedAdapter = null - updateStatusSnackbar = null super.onDestroyView() } @@ -115,23 +113,8 @@ class FeedFragment : ).show() } - private fun onUpdateProgressChanged(progress: Progress?) { - if (progress == null) { - updateStatusSnackbar?.dismiss() - updateStatusSnackbar = null - return - } - val summaryText = getString( - R.string.chapters_checking_progress, - progress.value + 1, - progress.total - ) - updateStatusSnackbar?.setText(summaryText) ?: run { - val snackbar = - Snackbar.make(binding.recyclerView, summaryText, Snackbar.LENGTH_INDEFINITE) - updateStatusSnackbar = snackbar - snackbar.show() - } + private fun onIsTrackerRunningChanged(isRunning: Boolean) { + binding.swipeRefreshLayout.isRefreshing = isRunning } override fun onScrolledToEnd() { diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 6cd53b67c..5545b7562 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -15,8 +15,7 @@ import androidx.work.* import coil.ImageLoader import coil.request.ImageRequest import java.util.concurrent.TimeUnit -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.koitharu.kotatsu.R @@ -25,11 +24,11 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.tracker.domain.Tracker +import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.toBitmapOrNull import org.koitharu.kotatsu.utils.ext.trySetForeground -import org.koitharu.kotatsu.utils.progress.Progress class TrackWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams), @@ -45,40 +44,59 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : override suspend fun doWork(): Result { if (!settings.isTrackerEnabled) { - return Result.success() + return Result.success(workDataOf(0, 0)) } if (TAG in tags) { // not expedited trySetForeground() } val tracks = tracker.getAllTracks() + if (tracks.isEmpty()) { + return Result.success(workDataOf(0, 0)) + } + + val updates = checkUpdatesAsync(tracks) + val results = updates.awaitAll() + tracker.gc() var success = 0 - val workData = Data.Builder().putInt(DATA_TOTAL, tracks.size) - for ((index, item) in tracks.withIndex()) { - val (track, channelId) = item - val updates = runCatching { - tracker.fetchUpdates(track, commit = true) - }.onSuccess { + var failed = 0 + results.forEach { x -> + if (x == null) { + failed++ + } else { success++ - }.getOrNull() - workData.putInt(DATA_PROGRESS, index) - setProgress(workData.build()) - if (updates != null && updates.newChapters.isNotEmpty()) { - showNotification( - manga = updates.manga, - channelId = channelId, - newChapters = updates.newChapters, - ) } } - tracker.gc() - return if (success == 0) { - Result.retry() + val resultData = workDataOf(success, failed) + return if (success == 0 && failed != 0) { + Result.failure(resultData) } else { - Result.success() + Result.success(resultData) } } + private suspend fun checkUpdatesAsync(tracks: List): List> { + val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) + val deferredList = coroutineScope { + tracks.map { (track, channelId) -> + async(dispatcher) { + runCatching { + tracker.fetchUpdates(track, commit = true) + }.onSuccess { updates -> + if (updates.isValid && updates.isNotEmpty()) { + showNotification( + manga = updates.manga, + channelId = channelId, + newChapters = updates.newChapters, + ) + } + }.getOrNull() + } + } + } + return deferredList + } + private suspend fun showNotification(manga: Manga, channelId: String?, newChapters: List) { if (newChapters.isEmpty() || channelId == null) { return @@ -160,14 +178,22 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : return ForegroundInfo(WORKER_NOTIFICATION_ID, notification) } + private fun workDataOf(success: Int, failed: Int): Data { + return Data.Builder() + .putInt(DATA_KEY_SUCCESS, success) + .putInt(DATA_KEY_FAILED, failed) + .build() + } + companion object { private const val WORKER_CHANNEL_ID = "track_worker" private const val WORKER_NOTIFICATION_ID = 35 - private const val DATA_PROGRESS = "progress" - private const val DATA_TOTAL = "total" private const val TAG = "tracking" private const val TAG_ONESHOT = "tracking_oneshot" + private const val MAX_PARALLELISM = 4 + private const val DATA_KEY_SUCCESS = "success" + private const val DATA_KEY_FAILED = "failed" fun setup(context: Context) { val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() @@ -184,17 +210,16 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : WorkManager.getInstance(context).enqueue(request) } - fun getProgressLiveData(context: Context): LiveData { - return WorkManager.getInstance(context).getWorkInfosByTagLiveData(TAG).map { list -> - list.find { work -> - work.state == WorkInfo.State.RUNNING - }?.let { workInfo -> - Progress( - value = workInfo.progress.getInt(DATA_PROGRESS, 0), - total = workInfo.progress.getInt(DATA_TOTAL, -1) - ).takeUnless { it.isIndeterminate } - } + fun getIsRunningLiveData(context: Context): LiveData { + val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build() + return WorkManager.getInstance(context).getWorkInfosLiveData(query).map { works -> + works.any { x -> x.state == WorkInfo.State.RUNNING } } } + + suspend fun getInfo(context: Context): List { + val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build() + return WorkManager.getInstance(context).getWorkInfos(query).await().orEmpty() + } } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml index f95f84b1a..dd6711d44 100644 --- a/app/src/main/res/layout/fragment_feed.xml +++ b/app/src/main/res/layout/fragment_feed.xml @@ -1,17 +1,24 @@ - \ No newline at end of file + android:layout_height="match_parent"> + + + + \ No newline at end of file