Tracker tests and fixes
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -7,4 +7,7 @@ class MangaUpdates(
|
||||
val manga: Manga,
|
||||
val newChapters: List<MangaChapter>,
|
||||
val isValid: Boolean,
|
||||
)
|
||||
) {
|
||||
|
||||
fun isNotEmpty() = newChapters.isNotEmpty()
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user