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)) 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 { private suspend fun loadManga(name: String): Manga {
val assets = InstrumentationRegistry.getInstrumentation().context.assets val assets = InstrumentationRegistry.getInstrumentation().context.assets
val manga = assets.open("manga/$name").use { val manga = assets.open("manga/$name").use {

View File

@@ -1,8 +1,15 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import androidx.lifecycle.* import androidx.lifecycle.LiveData
import kotlinx.coroutines.* 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.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent 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.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.IOException
class DetailsViewModel( class DetailsViewModel(
intent: MangaIntent, intent: MangaIntent,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
favouritesRepository: FavouritesRepository, favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val trackingRepository: TrackingRepository, trackingRepository: TrackingRepository,
mangaDataRepository: MangaDataRepository, mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings, private val settings: AppSettings,
@@ -54,9 +60,8 @@ class DetailsViewModel(
private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() } private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private val newChapters = viewModelScope.async(Dispatchers.Default) { private val newChapters = trackingRepository.observeNewChaptersCount(delegate.mangaId)
trackingRepository.getNewChaptersCount(delegate.mangaId) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
}
private val chaptersQuery = MutableStateFlow("") private val chaptersQuery = MutableStateFlow("")
@@ -65,7 +70,7 @@ class DetailsViewModel(
val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext) val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext)
val favouriteCategories = favourite.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 readingHistory = history.asLiveData(viewModelScope.coroutineContext)
val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext) val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext)
@@ -97,8 +102,9 @@ class DetailsViewModel(
delegate.relatedManga, delegate.relatedManga,
history, history,
delegate.selectedBranch, delegate.selectedBranch,
) { manga, related, history, branch -> newChapters,
delegate.mapChapters(manga, related, history, newChapters.await(), branch) ) { manga, related, history, branch, news ->
delegate.mapChapters(manga, related, history, news, branch)
}, },
chaptersReversed, chaptersReversed,
chaptersQuery, chaptersQuery,

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.tracker.data package org.koitharu.kotatsu.tracker.data
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao @Dao
abstract class TracksDao { abstract class TracksDao {
@@ -17,6 +18,9 @@ abstract class TracksDao {
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun findNewChapters(mangaId: Long): Int? 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") @Query("DELETE FROM tracks")
abstract suspend fun clear() abstract suspend fun clear()

View File

@@ -3,6 +3,8 @@ package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.room.withTransaction import androidx.room.withTransaction
import java.util.* 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.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
@@ -28,6 +30,10 @@ class TrackingRepository(
return db.tracksDao.findNewChapters(mangaId) ?: 0 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> { suspend fun getTracks(mangaList: Collection<Manga>): List<MangaTracking> {
val ids = mangaList.mapToSet { it.id } val ids = mangaList.mapToSet { it.id }
val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId } val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId }

View File

@@ -7,4 +7,7 @@ class MangaUpdates(
val manga: Manga, val manga: Manga,
val newChapters: List<MangaChapter>, val newChapters: List<MangaChapter>,
val isValid: Boolean, 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.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.progress.Progress
class FeedFragment : class FeedFragment :
BaseFragment<FragmentFeedBinding>(), BaseFragment<FragmentFeedBinding>(),
@@ -35,7 +34,6 @@ class FeedFragment :
private val viewModel by viewModel<FeedViewModel>() private val viewModel by viewModel<FeedViewModel>()
private var feedAdapter: FeedAdapter? = null private var feedAdapter: FeedAdapter? = null
private var updateStatusSnackbar: Snackbar? = null
private var paddingVertical = 0 private var paddingVertical = 0
private var paddingHorizontal = 0 private var paddingHorizontal = 0
@@ -60,6 +58,7 @@ class FeedFragment :
) )
addItemDecoration(decoration) addItemDecoration(decoration)
} }
binding.swipeRefreshLayout.isEnabled = false
addMenuProvider(FeedMenuProvider(binding.recyclerView, viewModel)) addMenuProvider(FeedMenuProvider(binding.recyclerView, viewModel))
viewModel.content.observe(viewLifecycleOwner, this::onListChanged) viewModel.content.observe(viewLifecycleOwner, this::onListChanged)
@@ -67,13 +66,12 @@ class FeedFragment :
viewModel.onFeedCleared.observe(viewLifecycleOwner) { viewModel.onFeedCleared.observe(viewLifecycleOwner) {
onFeedCleared() onFeedCleared()
} }
TrackWorker.getProgressLiveData(view.context.applicationContext) TrackWorker.getIsRunningLiveData(view.context.applicationContext)
.observe(viewLifecycleOwner, this::onUpdateProgressChanged) .observe(viewLifecycleOwner, this::onIsTrackerRunningChanged)
} }
override fun onDestroyView() { override fun onDestroyView() {
feedAdapter = null feedAdapter = null
updateStatusSnackbar = null
super.onDestroyView() super.onDestroyView()
} }
@@ -115,23 +113,8 @@ class FeedFragment :
).show() ).show()
} }
private fun onUpdateProgressChanged(progress: Progress?) { private fun onIsTrackerRunningChanged(isRunning: Boolean) {
if (progress == null) { binding.swipeRefreshLayout.isRefreshing = isRunning
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()
}
} }
override fun onScrolledToEnd() { override fun onScrolledToEnd() {

View File

@@ -15,8 +15,7 @@ import androidx.work.*
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.koitharu.kotatsu.R 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.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.tracker.domain.Tracker 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.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import org.koitharu.kotatsu.utils.ext.trySetForeground import org.koitharu.kotatsu.utils.ext.trySetForeground
import org.koitharu.kotatsu.utils.progress.Progress
class TrackWorker(context: Context, workerParams: WorkerParameters) : class TrackWorker(context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams), CoroutineWorker(context, workerParams),
@@ -45,40 +44,59 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
if (!settings.isTrackerEnabled) { if (!settings.isTrackerEnabled) {
return Result.success() return Result.success(workDataOf(0, 0))
} }
if (TAG in tags) { // not expedited if (TAG in tags) { // not expedited
trySetForeground() trySetForeground()
} }
val tracks = tracker.getAllTracks() 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 var success = 0
val workData = Data.Builder().putInt(DATA_TOTAL, tracks.size) var failed = 0
for ((index, item) in tracks.withIndex()) { results.forEach { x ->
val (track, channelId) = item if (x == null) {
val updates = runCatching { failed++
tracker.fetchUpdates(track, commit = true) } else {
}.onSuccess {
success++ 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() val resultData = workDataOf(success, failed)
return if (success == 0) { return if (success == 0 && failed != 0) {
Result.retry() Result.failure(resultData)
} else { } 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>) { private suspend fun showNotification(manga: Manga, channelId: String?, newChapters: List<MangaChapter>) {
if (newChapters.isEmpty() || channelId == null) { if (newChapters.isEmpty() || channelId == null) {
return return
@@ -160,14 +178,22 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
return ForegroundInfo(WORKER_NOTIFICATION_ID, notification) 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 { companion object {
private const val WORKER_CHANNEL_ID = "track_worker" private const val WORKER_CHANNEL_ID = "track_worker"
private const val WORKER_NOTIFICATION_ID = 35 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 = "tracking"
private const val TAG_ONESHOT = "tracking_oneshot" 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) { fun setup(context: Context) {
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
@@ -184,17 +210,16 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
WorkManager.getInstance(context).enqueue(request) WorkManager.getInstance(context).enqueue(request)
} }
fun getProgressLiveData(context: Context): LiveData<Progress?> { fun getIsRunningLiveData(context: Context): LiveData<Boolean> {
return WorkManager.getInstance(context).getWorkInfosByTagLiveData(TAG).map { list -> val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build()
list.find { work -> return WorkManager.getInstance(context).getWorkInfosLiveData(query).map { works ->
work.state == WorkInfo.State.RUNNING works.any { x -> x.state == WorkInfo.State.RUNNING }
}?.let { workInfo ->
Progress(
value = workInfo.progress.getInt(DATA_PROGRESS, 0),
total = workInfo.progress.getInt(DATA_TOTAL, -1)
).takeUnless { it.isIndeterminate }
}
} }
} }
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"?> <?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:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/recyclerView" android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:clipToPadding="false"
android:orientation="vertical" <androidx.recyclerview.widget.RecyclerView
android:paddingLeft="@dimen/list_spacing" android:id="@+id/recyclerView"
android:paddingRight="@dimen/list_spacing" android:layout_width="match_parent"
android:paddingTop="@dimen/grid_spacing_outer" android:layout_height="match_parent"
android:paddingBottom="@dimen/grid_spacing_outer" android:clipToPadding="false"
app:fastScrollEnabled="true" android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" android:paddingLeft="@dimen/list_spacing"
tools:listitem="@layout/item_feed" /> 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>