diff --git a/app/build.gradle b/app/build.gradle index ef0b6a892..9c05dce61 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -103,8 +103,12 @@ dependencies { //noinspection LifecycleAnnotationProcessorWithJava8 kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1' implementation 'androidx.work:work-runtime-ktx:2.8.1' -// implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' - implementation 'com.google.guava:guava:31.1-android' + //noinspection GradleDependency + implementation('com.google.guava:guava:31.1-android') { + exclude group: 'com.google.guava', module: 'failureaccess' + exclude group: 'org.checkerframework', module: 'checker-qual' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + } implementation 'androidx.room:room-runtime:2.5.1' implementation 'androidx.room:room-ktx:2.5.1' diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt index 967fafa9c..f595a4f4e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt @@ -10,6 +10,7 @@ data class DownloadState2( val manga: Manga, val isIndeterminate: Boolean, val isPaused: Boolean = false, + val isStopped: Boolean = false, val error: String? = null, val totalChapters: Int = 0, val currentChapter: Int = 0, @@ -17,6 +18,7 @@ data class DownloadState2( val currentPage: Int = 0, val eta: Long = -1L, val localManga: LocalManga? = null, + val downloadedChapters: LongArray = LongArray(0), val timestamp: Long = System.currentTimeMillis(), ) { @@ -30,7 +32,7 @@ data class DownloadState2( get() = localManga != null || (error != null && !isPaused) val isParticularProgress: Boolean - get() = localManga == null && error == null && !isPaused && max > 0 && !isIndeterminate + get() = localManga == null && error == null && !isPaused && !isStopped && max > 0 && !isIndeterminate fun toWorkData() = Data.Builder() .putLong(DATA_MANGA_ID, manga.id) @@ -39,7 +41,7 @@ data class DownloadState2( .putLong(DATA_ETA, eta) .putLong(DATA_TIMESTAMP, timestamp) .putString(DATA_ERROR, error) - .putInt(DATA_CHAPTERS, totalChapters) + .putLongArray(DATA_CHAPTERS, downloadedChapters) .putBoolean(DATA_INDETERMINATE, isIndeterminate) .putBoolean(DATA_PAUSED, isPaused) .build() @@ -72,6 +74,6 @@ data class DownloadState2( fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L)) - fun getTotalChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0) + fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt index b9873d5a3..be25d762f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -172,7 +172,7 @@ class DownloadsViewModel @Inject constructor( } private suspend fun WorkInfo.toUiModel(): DownloadItemModel? { - val workData = if (progress != Data.EMPTY) progress else outputData + val workData = if (outputData == Data.EMPTY) progress else outputData val mangaId = DownloadState2.getMangaId(workData) if (mangaId == 0L) return null val manga = getManga(mangaId) ?: return null @@ -187,7 +187,7 @@ class DownloadsViewModel @Inject constructor( progress = DownloadState2.getProgress(workData), eta = DownloadState2.getEta(workData), timestamp = DownloadState2.getTimestamp(workData), - totalChapters = DownloadState2.getTotalChapters(workData), + totalChapters = DownloadState2.getDownloadedChapters(workData).size, ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt index 2af1a59e9..421c64f8e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -133,11 +133,29 @@ class DownloadNotificationFactory @AssistedInject constructor( builder.setWhen(System.currentTimeMillis()) } + state.isStopped -> { + builder.setProgress(0, 0, false) + builder.setContentText(context.getString(R.string.queued)) + builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) + builder.setStyle(null) + builder.setOngoing(true) + builder.setSmallIcon(R.drawable.ic_stat_paused) + builder.addAction(actionCancel) + } + state.isPaused -> { // paused (with error or manually) builder.setProgress(state.max, state.progress, false) - val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) - builder.setContentText(percent) - builder.setContentText(state.error) + val percent = if (state.percent >= 0) { + context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) + } else { + null + } + if (state.error != null) { + builder.setContentText(state.error) + builder.setSubText(percent) + } else { + builder.setContentText(percent) + } builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) builder.setOngoing(true) @@ -161,7 +179,11 @@ class DownloadNotificationFactory @AssistedInject constructor( else -> { builder.setProgress(state.max, state.progress, false) - val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) + val percent = if (state.percent >= 0f) { + context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) + } else { + null + } if (state.eta > 0L) { val eta = DateUtils.getRelativeTimeSpanString( state.eta, diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index 2923e524e..c0f45a453 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -48,6 +48,7 @@ import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.mapToSet @@ -82,7 +83,9 @@ class DownloadWorker @AssistedInject constructor( private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @Volatile - private lateinit var currentState: DownloadState2 + private var lastPublishedState: DownloadState2? = null + private val currentState: DownloadState2 + get() = checkNotNull(lastPublishedState) private val pausingHandle = PausingHandle() private val timeLeftEstimator = TimeLeftEstimator() @@ -94,30 +97,44 @@ class DownloadWorker @AssistedInject constructor( val mangaId = inputData.getLong(MANGA_ID, 0L) val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure() val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } - currentState = DownloadState2(manga, isIndeterminate = true) + val downloadedIds = getDoneChapters() + lastPublishedState = DownloadState2(manga, isIndeterminate = true) return try { - downloadMangaImpl(chaptersIds) + downloadMangaImpl(chaptersIds, downloadedIds) Result.success(currentState.toWorkData()) } catch (e: CancellationException) { + withContext(NonCancellable) { + val notification = notificationFactory.create(currentState.copy(isStopped = true)) + notificationManager.notify(id.hashCode(), notification) + } throw e } catch (e: IOException) { e.printStackTraceDebug() Result.retry() } catch (e: Exception) { e.printStackTraceDebug() - currentState = currentState.copy(error = e.getDisplayMessage(applicationContext.resources), eta = -1L) - Result.failure(currentState.toWorkData()) + Result.failure( + currentState.copy( + error = e.getDisplayMessage(applicationContext.resources), + eta = -1L, + ).toWorkData(), + ) + } finally { + notificationManager.cancel(id.hashCode()) } } override suspend fun getForegroundInfo() = ForegroundInfo( id.hashCode(), - notificationFactory.create(null), + notificationFactory.create(lastPublishedState), ) - private suspend fun downloadMangaImpl(chaptersIds: LongArray?) { + private suspend fun downloadMangaImpl( + includedIds: LongArray?, + excludedIds: LongArray, + ) { var manga = currentState.manga - val chaptersIdsSet = chaptersIds?.toMutableSet() + val chaptersToSkip = excludedIds.toMutableSet() withMangaLock(manga) { ContextCompat.registerReceiver( applicationContext, @@ -135,26 +152,24 @@ class DownloadWorker @AssistedInject constructor( ?: error("Cannot obtain remote manga instance") } val repo = mangaRepositoryFactory.create(manga.source) - val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga - output = LocalMangaOutput.getOrCreate(destination, data) - val coverUrl = data.largeCoverUrl.ifNullOrEmpty { data.coverUrl } + val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga + output = LocalMangaOutput.getOrCreate(destination, mangaDetails) + val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl } if (coverUrl.isNotEmpty()) { downloadFile(coverUrl, destination, tempFileName, repo.source).let { file -> output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) } } - val chapters = checkNotNull( - if (chaptersIdsSet == null) { - data.chapters - } else { - data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) } - }, - ) { "Chapters list must not be null" } - check(chapters.isNotEmpty()) { "Chapters list must not be empty" } - check(chaptersIdsSet.isNullOrEmpty()) { - "${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga" - } + val chapters = getChapters(mangaDetails, includedIds) for ((chapterIndex, chapter) in chapters.withIndex()) { + if (chaptersToSkip.remove(chapter.id)) { + publishState( + currentState.copy( + downloadedChapters = currentState.downloadedChapters + chapter.id, + ), + ) + continue + } val pages = runFailsafe(pausingHandle) { repo.getPages(chapter) } @@ -190,6 +205,11 @@ class DownloadWorker @AssistedInject constructor( localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga()) }.onFailure(Throwable::printStackTraceDebug) } + publishState( + currentState.copy( + downloadedChapters = currentState.downloadedChapters + chapter.id, + ), + ) } publishState(currentState.copy(isIndeterminate = true, eta = -1L)) output.mergeWithExisting() @@ -273,7 +293,7 @@ class DownloadWorker @AssistedInject constructor( private suspend fun publishState(state: DownloadState2) { val previousState = currentState - currentState = state + lastPublishedState = state if (previousState.isParticularProgress && state.isParticularProgress) { timeLeftEstimator.tick(state.progress, state.max) } else { @@ -291,6 +311,30 @@ class DownloadWorker @AssistedInject constructor( setProgress(state.toWorkData()) } + private suspend fun getDoneChapters(): LongArray { + val work = WorkManagerHelper(WorkManager.getInstance(applicationContext)).getWorkInfoById(id) + ?: return LongArray(0) + return DownloadState2.getDownloadedChapters(work.progress) + } + + private fun getChapters( + manga: Manga, + includedIds: LongArray?, + ): List { + val chapters = checkNotNull(manga.chapters?.toMutableList()) { + "Chapters list must not be null" + } + if (includedIds != null) { + val chaptersIdsSet = includedIds.toMutableSet() + chapters.retainAll { x -> chaptersIdsSet.remove(x.id) } + check(chaptersIdsSet.isEmpty()) { + "${chaptersIdsSet.size} of ${includedIds.size} requested chapters not found in manga" + } + } + check(chapters.isNotEmpty()) { "Chapters list must not be empty" } + return chapters + } + private suspend inline fun withMangaLock(manga: Manga, block: () -> T) = try { localMangaRepository.lockManga(manga.id) block() diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt index 9b5187c9c..0b768d3f5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt @@ -57,6 +57,10 @@ class WorkManagerHelper( return workManagerImpl.getWorkInfos(query).await() } + suspend fun getWorkInfoById(id: UUID): WorkInfo? { + return workManagerImpl.getWorkInfoById(id).await() + } + suspend fun updateWork(request: WorkRequest): WorkManager.UpdateResult { return workManagerImpl.updateWork(request).await() }