Tracker tests and fixes

This commit is contained in:
Koitharu
2022-06-18 10:59:01 +03:00
parent c82bacb037
commit 634ce0dddf
8 changed files with 142 additions and 80 deletions

View File

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

View File

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

View File

@@ -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<Int?>
@Query("DELETE FROM tracks")
abstract suspend fun clear()

View File

@@ -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<Int> {
return db.tracksDao.observeNewChapters(mangaId).map { it ?: 0 }
}
suspend fun getTracks(mangaList: Collection<Manga>): List<MangaTracking> {
val ids = mangaList.mapToSet { it.id }
val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId }

View File

@@ -7,4 +7,7 @@ class MangaUpdates(
val manga: Manga,
val newChapters: List<MangaChapter>,
val isValid: Boolean,
)
) {
fun isNotEmpty() = newChapters.isNotEmpty()
}

View File

@@ -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<FragmentFeedBinding>(),
@@ -35,7 +34,6 @@ class FeedFragment :
private val viewModel by viewModel<FeedViewModel>()
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() {

View File

@@ -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<TrackingItem>): List<Deferred<MangaUpdates?>> {
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<MangaChapter>) {
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<Progress?> {
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<Boolean> {
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<WorkInfo> {
val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build()
return WorkManager.getInstance(context).getWorkInfos(query).await().orEmpty()
}
}
}

View File

@@ -1,17 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/recyclerView"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingLeft="@dimen/list_spacing"
android:paddingRight="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingBottom="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_feed" />
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingLeft="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingRight="@dimen/list_spacing"
android:paddingBottom="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_feed" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>