diff --git a/app/build.gradle b/app/build.gradle index f0dde7b78..bf3e4b9e9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,12 +98,24 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:1.3.0' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.preference:preference-ktx:1.2.0' - implementation 'androidx.work:work-runtime-ktx:2.8.1' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'com.google.android.material:material:1.9.0' //noinspection LifecycleAnnotationProcessorWithJava8 kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1' + /** + * TODO: check + * https://issuetracker.google.com/issues/270245927 + * https://issuetracker.google.com/issues/280504155 + */ + implementation 'androidx.work:work-runtime-ktx:2.8.1' + //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' kapt 'androidx.room:room-compiler:2.5.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0e760fca5..0b2110a2b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -129,7 +129,7 @@ android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity" android:windowSoftInputMode="adjustResize" /> diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 4c05c7662..01b79b675 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -241,8 +241,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isDownloadsSlowdownEnabled: Boolean get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false) - val downloadsParallelism: Int - get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2) + val isDownloadsWiFiOnly: Boolean + get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) val isSuggestionsEnabled: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS, false) @@ -384,8 +384,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_SHIKIMORI = "shikimori" const val KEY_ANILIST = "anilist" const val KEY_MAL = "mal" - const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" + const val KEY_DOWNLOADS_WIFI = "downloads_wifi" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_DOH = "doh" const val KEY_EXIT_CONFIRM = "exit_confirm" diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt index c5d8ae675..4915d3221 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -19,7 +19,6 @@ import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.model.ChapterListItem -import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.reader.ui.ReaderActivity @@ -95,11 +94,7 @@ class ChaptersFragment : override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_save -> { - DownloadService.start( - binding.recyclerViewChapters, - viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false, - selectionController?.snapshot(), - ) + viewModel.download(selectionController?.snapshot()) mode.finish() true } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 4e3e5f37b..4752e52f4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -35,7 +35,7 @@ import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.HistoryInfo -import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.reader.ui.ReaderActivity @@ -120,6 +120,7 @@ class DetailsActivity : binding.buttonDropdown.isVisible = it.size > 1 } viewModel.chapters.observe(this, PrefetchObserver(this)) + viewModel.onDownloadStarted.observe(this, DownloadStartedObserver(binding.containerDetails)) addMenuProvider( DetailsMenuProvider( @@ -236,7 +237,7 @@ class DetailsActivity : ) } setNeutralButton(R.string.download) { _, _ -> - DownloadService.start(binding.appbar, remoteManga, setOf(chapterId)) + viewModel.download(setOf(chapterId)) } setCancelable(true) }.show() diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt index 4592b4976..5aab7e32a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.core.os.ShortcutsUpdater -import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource @@ -86,7 +85,7 @@ class DetailsMenuProvider( if (chaptersCount > 5 || branches.size > 1) { showSaveConfirmation(it, chaptersCount, branches) } else { - DownloadService.start(snackbarHost, it) + viewModel.download(null) } } } @@ -140,7 +139,7 @@ class DetailsMenuProvider( val chaptersIds = manga.chapters?.mapNotNullToSet { c -> if (c.branch in selectedBranches) c.id else null } - DownloadService.start(snackbarHost, manga, chaptersIds) + viewModel.download(chaptersIds) } } else { dialogBuilder.setMessage( @@ -149,7 +148,7 @@ class DetailsMenuProvider( activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount), ), ).setPositiveButton(R.string.save) { _, _ -> - DownloadService.start(snackbarHost, manga) + viewModel.download(null) } } dialogBuilder.show() 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 bffa668a0..1c5804997 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 @@ -36,6 +36,7 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.HistoryInfo +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.local.data.LocalManga @@ -69,11 +70,13 @@ class DetailsViewModel @Inject constructor( private val imageGetter: Html.ImageGetter, private val delegate: MangaDetailsDelegate, @LocalStorageChanges private val localStorageChanges: SharedFlow, + private val downloadScheduler: DownloadWorker.Scheduler, ) : BaseViewModel() { private var loadingJob: Job val onShowToast = SingleLiveEvent() + val onDownloadStarted = SingleLiveEvent() private val history = historyRepository.observeOne(delegate.mangaId) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) @@ -282,6 +285,16 @@ class DetailsViewModel @Inject constructor( } } + fun download(chaptersIds: Set?) { + launchJob(Dispatchers.Default) { + downloadScheduler.schedule( + getRemoteManga() ?: checkNotNull(manga.value), + chaptersIds, + ) + onDownloadStarted.emitCall(Unit) + } + } + private fun doLoad() = launchLoadingJob(Dispatchers.Default) { delegate.doLoad() } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt deleted file mode 100644 index 88f4a6aa0..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ /dev/null @@ -1,281 +0,0 @@ -package org.koitharu.kotatsu.download.domain - -import android.app.Service -import android.content.Context -import android.webkit.MimeTypeMap -import androidx.lifecycle.LifecycleService -import androidx.lifecycle.lifecycleScope -import coil.ImageLoader -import coil.request.ImageRequest -import coil.size.Scale -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.android.scopes.ServiceScoped -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.internal.closeQuietly -import okio.IOException -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.download.ui.service.PausingHandle -import org.koitharu.kotatsu.local.data.LocalManga -import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.data.PagesCache -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.MangaSource -import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.utils.ext.copyToSuspending -import org.koitharu.kotatsu.utils.ext.deleteAwait -import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty -import org.koitharu.kotatsu.utils.ext.printStackTraceDebug -import org.koitharu.kotatsu.utils.ext.runCatchingCancellable -import org.koitharu.kotatsu.utils.progress.PausingProgressJob -import java.io.File -import javax.inject.Inject - -private const val MAX_FAILSAFE_ATTEMPTS = 2 -private const val DOWNLOAD_ERROR_DELAY = 500L -private const val SLOWDOWN_DELAY = 150L - -@ServiceScoped -class DownloadManager @Inject constructor( - service: Service, - @ApplicationContext private val context: Context, - private val imageLoader: ImageLoader, - private val okHttp: OkHttpClient, - private val cache: PagesCache, - private val localMangaRepository: LocalMangaRepository, - private val settings: AppSettings, - private val mangaRepositoryFactory: MangaRepository.Factory, - @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, -) { - - private val coverWidth = context.resources.getDimensionPixelSize( - androidx.core.R.dimen.compat_notification_large_icon_max_width, - ) - private val coverHeight = context.resources.getDimensionPixelSize( - androidx.core.R.dimen.compat_notification_large_icon_max_height, - ) - private val semaphore = Semaphore(settings.downloadsParallelism) - private val coroutineScope = (service as LifecycleService).lifecycleScope - - fun downloadManga( - manga: Manga, - chaptersIds: LongArray?, - startId: Int, - ): PausingProgressJob { - val stateFlow = MutableStateFlow( - DownloadState.Queued(startId = startId, manga = manga, cover = null), - ) - val pausingHandle = PausingHandle() - val job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(stateFlow)) { - try { - downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId) - } catch (e: CancellationException) { // handle cancellation if not handled already - val state = stateFlow.value - if (state !is DownloadState.Cancelled) { - stateFlow.value = DownloadState.Cancelled(startId, state.manga, state.cover) - } - throw e - } - } - return PausingProgressJob(job, stateFlow, pausingHandle) - } - - private suspend fun downloadMangaImpl( - manga: Manga, - chaptersIds: LongArray?, - outState: MutableStateFlow, - pausingHandle: PausingHandle, - startId: Int, - ) { - @Suppress("NAME_SHADOWING") - var manga = manga - val chaptersIdsSet = chaptersIds?.toMutableSet() - val cover = loadCover(manga) - outState.value = DownloadState.Queued(startId, manga, cover) - withMangaLock(manga) { - semaphore.withPermit { - outState.value = DownloadState.Preparing(startId, manga, null) - val destination = localMangaRepository.getOutputDir(manga) - checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } - val tempFileName = "${manga.id}_$startId.tmp" - var output: LocalMangaOutput? = null - try { - if (manga.source == MangaSource.LOCAL) { - manga = localMangaRepository.getRemoteManga(manga) - ?: error("Cannot obtain remote manga instance") - } - val repo = mangaRepositoryFactory.create(manga.source) - outState.value = DownloadState.Preparing(startId, manga, cover) - val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga - output = LocalMangaOutput.getOrCreate(destination, data) - val coverUrl = data.largeCoverUrl.ifNullOrEmpty { data.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" - } - for ((chapterIndex, chapter) in chapters.withIndex()) { - val pages = runFailsafe(outState, pausingHandle) { - repo.getPages(chapter) - } - for ((pageIndex, page) in pages.withIndex()) { - runFailsafe(outState, pausingHandle) { - val url = repo.getPageUrl(page) - val file = cache.get(url) - ?: downloadFile(url, destination, tempFileName, repo.source) - output.addPage( - chapter = chapter, - file = file, - pageNumber = pageIndex, - ext = MimeTypeMap.getFileExtensionFromUrl(url), - ) - } - outState.value = DownloadState.Progress( - startId = startId, - manga = data, - cover = cover, - totalChapters = chapters.size, - currentChapter = chapterIndex, - totalPages = pages.size, - currentPage = pageIndex, - ) - - if (settings.isDownloadsSlowdownEnabled) { - delay(SLOWDOWN_DELAY) - } - } - if (output.flushChapter(chapter)) { - runCatchingCancellable { - localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga()) - }.onFailure(Throwable::printStackTraceDebug) - } - } - outState.value = DownloadState.PostProcessing(startId, data, cover) - output.mergeWithExisting() - output.finish() - val localManga = LocalMangaInput.of(output.rootFile).getManga() - localStorageChanges.emit(localManga) - outState.value = DownloadState.Done(startId, data, cover, localManga.manga) - } catch (e: CancellationException) { - outState.value = DownloadState.Cancelled(startId, manga, cover) - throw e - } catch (e: Throwable) { - e.printStackTraceDebug() - outState.value = DownloadState.Error(startId, manga, cover, e, false) - } finally { - withContext(NonCancellable) { - output?.closeQuietly() - output?.cleanup() - File(destination, tempFileName).deleteAwait() - } - } - } - } - } - - private suspend fun runFailsafe( - outState: MutableStateFlow, - pausingHandle: PausingHandle, - block: suspend () -> R, - ): R { - var countDown = MAX_FAILSAFE_ATTEMPTS - failsafe@ while (true) { - try { - return block() - } catch (e: IOException) { - if (countDown <= 0) { - val state = outState.value - outState.value = DownloadState.Error(state.startId, state.manga, state.cover, e, true) - countDown = MAX_FAILSAFE_ATTEMPTS - pausingHandle.pause() - pausingHandle.awaitResumed() - outState.value = state - } else { - countDown-- - delay(DOWNLOAD_ERROR_DELAY) - } - } - } - } - - private suspend fun downloadFile( - url: String, - destination: File, - tempFileName: String, - source: MangaSource, - ): File { - val request = Request.Builder() - .url(url) - .tag(MangaSource::class.java, source) - .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) - .get() - .build() - val call = okHttp.newCall(request) - val file = File(destination, tempFileName) - val response = call.clone().await() - file.outputStream().use { out -> - checkNotNull(response.body).byteStream().copyToSuspending(out) - } - return file - } - - private fun errorStateHandler(outState: MutableStateFlow) = - CoroutineExceptionHandler { _, throwable -> - throwable.printStackTraceDebug() - val prevValue = outState.value - outState.value = DownloadState.Error( - startId = prevValue.startId, - manga = prevValue.manga, - cover = prevValue.cover, - error = throwable, - canRetry = false, - ) - } - - private suspend fun loadCover(manga: Manga) = runCatchingCancellable { - imageLoader.execute( - ImageRequest.Builder(context) - .data(manga.coverUrl) - .allowHardware(false) - .tag(manga.source) - .size(coverWidth, coverHeight) - .scale(Scale.FILL) - .build(), - ).drawable - }.getOrNull() - - private suspend inline fun withMangaLock(manga: Manga, block: () -> T) = try { - localMangaRepository.lockManga(manga.id) - block() - } finally { - localMangaRepository.unlockManga(manga.id) - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt index 0b874f6df..fea793f6d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt @@ -1,234 +1,123 @@ package org.koitharu.kotatsu.download.domain -import android.graphics.drawable.Drawable +import androidx.work.Data +import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.parsers.model.Manga - -sealed interface DownloadState { - - val startId: Int - val manga: Manga - val cover: Drawable? - - override fun equals(other: Any?): Boolean - - override fun hashCode(): Int - - val isTerminal: Boolean - get() = this is Done || this is Cancelled || (this is Error && !canRetry) - - class Queued( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - ) : DownloadState { - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Queued - - if (startId != other.startId) return false - if (manga != other.manga) return false - if (cover != other.cover) return false - - return true - } - - override fun hashCode(): Int { - var result = startId - result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) - return result - } - } - - class Preparing( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - ) : DownloadState { - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Preparing - - if (startId != other.startId) return false - if (manga != other.manga) return false - if (cover != other.cover) return false - - return true - } - - override fun hashCode(): Int { - var result = startId - result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) - return result - } +import java.util.Date + +data class DownloadState( + 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, + val totalPages: Int = 0, + val currentPage: Int = 0, + val eta: Long = -1L, + val localManga: LocalManga? = null, + val downloadedChapters: LongArray = LongArray(0), + val timestamp: Long = System.currentTimeMillis(), +) { + + val max: Int = totalChapters * totalPages + + val progress: Int = totalPages * currentChapter + currentPage + 1 + + val percent: Float = if (max > 0) progress.toFloat() / max else PROGRESS_NONE + + val isFinalState: Boolean + get() = localManga != null || (error != null && !isPaused) + + val isParticularProgress: Boolean + get() = localManga == null && error == null && !isPaused && !isStopped && max > 0 && !isIndeterminate + + fun toWorkData() = Data.Builder() + .putLong(DATA_MANGA_ID, manga.id) + .putInt(DATA_MAX, max) + .putInt(DATA_PROGRESS, progress) + .putLong(DATA_ETA, eta) + .putLong(DATA_TIMESTAMP, timestamp) + .putString(DATA_ERROR, error) + .putLongArray(DATA_CHAPTERS, downloadedChapters) + .putBoolean(DATA_INDETERMINATE, isIndeterminate) + .putBoolean(DATA_PAUSED, isPaused) + .build() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DownloadState + + if (manga != other.manga) return false + if (isIndeterminate != other.isIndeterminate) return false + if (isPaused != other.isPaused) return false + if (isStopped != other.isStopped) return false + if (error != other.error) return false + if (totalChapters != other.totalChapters) return false + if (currentChapter != other.currentChapter) return false + if (totalPages != other.totalPages) return false + if (currentPage != other.currentPage) return false + if (eta != other.eta) return false + if (localManga != other.localManga) return false + if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false + if (timestamp != other.timestamp) return false + if (max != other.max) return false + if (progress != other.progress) return false + return percent == other.percent } - class Progress( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - val totalChapters: Int, - val currentChapter: Int, - val totalPages: Int, - val currentPage: Int, - ) : DownloadState { - - val max: Int = totalChapters * totalPages - - val progress: Int = totalPages * currentChapter + currentPage + 1 - - val percent: Float = progress.toFloat() / max - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Progress - - if (startId != other.startId) return false - if (manga != other.manga) return false - if (cover != other.cover) return false - if (totalChapters != other.totalChapters) return false - if (currentChapter != other.currentChapter) return false - if (totalPages != other.totalPages) return false - if (currentPage != other.currentPage) return false - - return true - } - - override fun hashCode(): Int { - var result = startId - result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) - result = 31 * result + totalChapters - result = 31 * result + currentChapter - result = 31 * result + totalPages - result = 31 * result + currentPage - return result - } + override fun hashCode(): Int { + var result = manga.hashCode() + result = 31 * result + isIndeterminate.hashCode() + result = 31 * result + isPaused.hashCode() + result = 31 * result + isStopped.hashCode() + result = 31 * result + (error?.hashCode() ?: 0) + result = 31 * result + totalChapters + result = 31 * result + currentChapter + result = 31 * result + totalPages + result = 31 * result + currentPage + result = 31 * result + eta.hashCode() + result = 31 * result + (localManga?.hashCode() ?: 0) + result = 31 * result + downloadedChapters.contentHashCode() + result = 31 * result + timestamp.hashCode() + result = 31 * result + max + result = 31 * result + progress + result = 31 * result + percent.hashCode() + return result } - class Done( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - val localManga: Manga, - ) : DownloadState { - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Done - - if (startId != other.startId) return false - if (manga != other.manga) return false - if (cover != other.cover) return false - if (localManga != other.localManga) return false - - return true - } - - override fun hashCode(): Int { - var result = startId - result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) - result = 31 * result + localManga.hashCode() - return result - } - } - - class Error( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - val error: Throwable, - val canRetry: Boolean, - ) : DownloadState { - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Error - - if (startId != other.startId) return false - if (manga != other.manga) return false - if (cover != other.cover) return false - if (error != other.error) return false - if (canRetry != other.canRetry) return false - - return true - } - - override fun hashCode(): Int { - var result = startId - result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) - result = 31 * result + error.hashCode() - result = 31 * result + canRetry.hashCode() - return result - } - } + companion object { - class Cancelled( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - ) : DownloadState { + private const val DATA_MANGA_ID = "manga_id" + private const val DATA_MAX = "max" + private const val DATA_PROGRESS = "progress" + private const val DATA_CHAPTERS = "chapter" + private const val DATA_ETA = "eta" + private const val DATA_TIMESTAMP = "timestamp" + private const val DATA_ERROR = "error" + private const val DATA_INDETERMINATE = "indeterminate" + private const val DATA_PAUSED = "paused" - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false + fun getMangaId(data: Data): Long = data.getLong(DATA_MANGA_ID, 0L) - other as Cancelled + fun isIndeterminate(data: Data): Boolean = data.getBoolean(DATA_INDETERMINATE, false) - if (startId != other.startId) return false - if (manga != other.manga) return false - if (cover != other.cover) return false - - return true - } - - override fun hashCode(): Int { - var result = startId - result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) - return result - } - } + fun isPaused(data: Data): Boolean = data.getBoolean(DATA_PAUSED, false) - class PostProcessing( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - ) : DownloadState { + fun getMax(data: Data): Int = data.getInt(DATA_MAX, 0) - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false + fun getError(data: Data): String? = data.getString(DATA_ERROR) - other as PostProcessing + fun getProgress(data: Data): Int = data.getInt(DATA_PROGRESS, 0) - if (startId != other.startId) return false - if (manga != other.manga) return false - if (cover != other.cover) return false + fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L) - return true - } + fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L)) - override fun hashCode(): Int { - var result = startId - result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) - return result - } + fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt deleted file mode 100644 index 2afba0b27..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt +++ /dev/null @@ -1,140 +0,0 @@ -package org.koitharu.kotatsu.download.ui - -import android.view.View -import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.databinding.ItemDownloadBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.domain.DownloadState -import org.koitharu.kotatsu.parsers.util.format -import org.koitharu.kotatsu.utils.ext.enqueueWith -import org.koitharu.kotatsu.utils.ext.getDisplayMessage -import org.koitharu.kotatsu.utils.ext.newImageRequest -import org.koitharu.kotatsu.utils.ext.onFirst -import org.koitharu.kotatsu.utils.ext.source - -fun downloadItemAD( - lifecycleOwner: LifecycleOwner, - coil: ImageLoader, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }, -) { - var job: Job? = null - val percentPattern = context.resources.getString(R.string.percent_string_pattern) - - val clickListener = View.OnClickListener { v -> - when (v.id) { - R.id.button_cancel -> item.cancel() - R.id.button_resume -> item.resume() - else -> context.startActivity( - DetailsActivity.newIntent(context, item.progressValue.manga), - ) - } - } - binding.buttonCancel.setOnClickListener(clickListener) - binding.buttonResume.setOnClickListener(clickListener) - itemView.setOnClickListener(clickListener) - - bind { - job?.cancel() - job = item.progressAsFlow().onFirst { state -> - binding.imageViewCover.newImageRequest(lifecycleOwner, state.manga.coverUrl)?.run { - placeholder(state.cover) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - source(state.manga.source) - allowRgb565(true) - enqueueWith(coil) - } - }.onEach { state -> - binding.textViewTitle.text = state.manga.title - when (state) { - is DownloadState.Cancelled -> { - binding.textViewStatus.setText(R.string.cancelling_) - binding.progressBar.isIndeterminate = true - binding.progressBar.isVisible = true - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = false - binding.buttonResume.isVisible = false - } - - is DownloadState.Done -> { - binding.textViewStatus.setText(R.string.download_complete) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = false - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = false - binding.buttonResume.isVisible = false - } - - is DownloadState.Error -> { - binding.textViewStatus.setText(R.string.error_occurred) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = false - binding.textViewPercent.isVisible = false - binding.textViewDetails.text = state.error.getDisplayMessage(context.resources) - binding.textViewDetails.isVisible = true - binding.buttonCancel.isVisible = state.canRetry - binding.buttonResume.isVisible = state.canRetry - } - - is DownloadState.PostProcessing -> { - binding.textViewStatus.setText(R.string.processing_) - binding.progressBar.isIndeterminate = true - binding.progressBar.isVisible = true - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = false - binding.buttonResume.isVisible = false - } - - is DownloadState.Preparing -> { - binding.textViewStatus.setText(R.string.preparing_) - binding.progressBar.isIndeterminate = true - binding.progressBar.isVisible = true - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = true - binding.buttonResume.isVisible = false - } - - is DownloadState.Progress -> { - binding.textViewStatus.setText(R.string.manga_downloading_) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = true - binding.progressBar.max = state.max - binding.progressBar.setProgressCompat(state.progress, true) - binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1)) - binding.textViewPercent.isVisible = true - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = true - binding.buttonResume.isVisible = false - } - - is DownloadState.Queued -> { - binding.textViewStatus.setText(R.string.queued) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = false - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = true - binding.buttonResume.isVisible = false - } - } - }.launchIn(lifecycleOwner.lifecycleScope) - } - - onViewRecycled { - job?.cancel() - job = null - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt deleted file mode 100644 index 7b0872910..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.koitharu.kotatsu.download.ui - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.base.ui.BaseActivity -import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration -import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding -import javax.inject.Inject - -@AndroidEntryPoint -class DownloadsActivity : BaseActivity() { - - @Inject - lateinit var coil: ImageLoader - - private lateinit var serviceConnection: DownloadsConnection - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val adapter = DownloadsAdapter(this, coil) - val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) - binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) - binding.recyclerView.setHasFixedSize(true) - binding.recyclerView.adapter = adapter - serviceConnection = DownloadsConnection(this, this) - serviceConnection.items.observe(this) { items -> - adapter.items = items - binding.textViewHolder.isVisible = items.isNullOrEmpty() - } - serviceConnection.bind() - } - - override fun onWindowInsetsChanged(insets: Insets) { - binding.recyclerView.updatePadding( - left = insets.left, - right = insets.right, - bottom = insets.bottom, - ) - binding.toolbar.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java) - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt deleted file mode 100644 index 5962220c3..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.koitharu.kotatsu.download.ui - -import androidx.lifecycle.LifecycleOwner -import androidx.recyclerview.widget.DiffUtil -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter -import org.koitharu.kotatsu.download.domain.DownloadState -import org.koitharu.kotatsu.utils.progress.PausingProgressJob - -typealias DownloadItem = PausingProgressJob - -class DownloadsAdapter( - lifecycleOwner: LifecycleOwner, - coil: ImageLoader, -) : AsyncListDifferDelegationAdapter(DiffCallback()) { - - init { - delegatesManager.addDelegate(downloadItemAD(lifecycleOwner, coil)) - setHasStableIds(true) - } - - override fun getItemId(position: Int): Long { - return items[position].progressValue.startId.toLong() - } - - private class DiffCallback : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldItem: DownloadItem, - newItem: DownloadItem, - ): Boolean { - return oldItem.progressValue.startId == newItem.progressValue.startId - } - - override fun areContentsTheSame( - oldItem: DownloadItem, - newItem: DownloadItem, - ): Boolean { - return oldItem.progressValue == newItem.progressValue && oldItem.isPaused == newItem.isPaused - } - - override fun getChangePayload(oldItem: DownloadItem, newItem: DownloadItem): Any { - return Unit - } - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsConnection.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsConnection.kt deleted file mode 100644 index f2577ec26..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsConnection.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.koitharu.kotatsu.download.ui - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.download.domain.DownloadState -import org.koitharu.kotatsu.download.ui.service.DownloadService -import org.koitharu.kotatsu.utils.asFlowLiveData -import org.koitharu.kotatsu.utils.progress.PausingProgressJob - -class DownloadsConnection( - private val context: Context, - private val lifecycleOwner: LifecycleOwner, -) : ServiceConnection { - - private var bindingObserver: BindingLifecycleObserver? = null - private var collectJob: Job? = null - private val itemsFlow = MutableStateFlow>>(emptyList()) - - val items - get() = itemsFlow.asFlowLiveData() - - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - collectJob?.cancel() - val binder = (service as? DownloadService.DownloadBinder) - collectJob = if (binder == null) { - null - } else { - lifecycleOwner.lifecycleScope.launch { - binder.downloads.collect { - itemsFlow.value = it - } - } - } - } - - override fun onServiceDisconnected(name: ComponentName?) { - collectJob?.cancel() - collectJob = null - itemsFlow.value = itemsFlow.value.filter { it.progressValue.isTerminal } - } - - fun bind() { - if (bindingObserver != null) { - return - } - bindingObserver = BindingLifecycleObserver().also { - lifecycleOwner.lifecycle.addObserver(it) - } - context.bindService(Intent(context, DownloadService::class.java), this, 0) - } - - fun unbind() { - bindingObserver?.let { - lifecycleOwner.lifecycle.removeObserver(it) - } - bindingObserver = null - context.unbindService(this) - } - - private inner class BindingLifecycleObserver : DefaultLifecycleObserver { - - override fun onDestroy(owner: LifecycleOwner) { - super.onDestroy(owner) - unbind() - } - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt new file mode 100644 index 000000000..7517cb0d1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt @@ -0,0 +1,140 @@ +package org.koitharu.kotatsu.download.ui.list + +import android.view.View +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import androidx.work.WorkInfo +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.databinding.ItemDownloadBinding +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.util.format +import org.koitharu.kotatsu.utils.ext.disposeImageRequest +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.source +import org.koitharu.kotatsu.utils.ext.textAndVisible + +fun downloadItemAD( + lifecycleOwner: LifecycleOwner, + coil: ImageLoader, + listener: DownloadItemListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }, +) { + + val percentPattern = context.resources.getString(R.string.percent_string_pattern) + + val clickListener = object : View.OnClickListener, View.OnLongClickListener { + override fun onClick(v: View) { + when (v.id) { + R.id.button_cancel -> listener.onCancelClick(item) + R.id.button_resume -> listener.onResumeClick(item) + R.id.button_pause -> listener.onPauseClick(item) + else -> listener.onItemClick(item, v) + } + } + + override fun onLongClick(v: View): Boolean { + return listener.onItemLongClick(item, v) + } + } + binding.buttonCancel.setOnClickListener(clickListener) + binding.buttonPause.setOnClickListener(clickListener) + binding.buttonResume.setOnClickListener(clickListener) + itemView.setOnClickListener(clickListener) + itemView.setOnLongClickListener(clickListener) + + bind { payloads -> + binding.textViewTitle.text = item.manga.title + binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply { + placeholder(R.drawable.ic_placeholder) + fallback(R.drawable.ic_placeholder) + error(R.drawable.ic_error_placeholder) + allowRgb565(true) + source(item.manga.source) + enqueueWith(coil) + } + when (item.workState) { + WorkInfo.State.ENQUEUED, + WorkInfo.State.BLOCKED -> { + binding.textViewStatus.setText(R.string.queued) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.progressBar.isEnabled = true + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = true + binding.buttonResume.isVisible = false + binding.buttonPause.isVisible = false + } + + WorkInfo.State.RUNNING -> { + binding.textViewStatus.setText( + if (item.isPaused) R.string.paused else R.string.manga_downloading_, + ) + binding.progressBar.isIndeterminate = item.isIndeterminate + binding.progressBar.isVisible = true + binding.progressBar.max = item.max + binding.progressBar.isEnabled = !item.isPaused + binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty()) + binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1)) + binding.textViewPercent.isVisible = true + binding.textViewDetails.textAndVisible = item.getEtaString() + binding.buttonCancel.isVisible = true + binding.buttonResume.isVisible = item.isPaused + binding.buttonPause.isVisible = item.canPause + } + + WorkInfo.State.SUCCEEDED -> { + binding.textViewStatus.setText(R.string.download_complete) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.progressBar.isEnabled = true + binding.textViewPercent.isVisible = false + if (item.totalChapters > 0) { + binding.textViewDetails.text = context.resources.getQuantityString( + R.plurals.chapters, + item.totalChapters, + item.totalChapters, + ) + binding.textViewDetails.isVisible = true + } else { + binding.textViewDetails.isVisible = false + } + binding.buttonCancel.isVisible = false + binding.buttonResume.isVisible = false + binding.buttonPause.isVisible = false + } + + WorkInfo.State.FAILED -> { + binding.textViewStatus.setText(R.string.error_occurred) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.progressBar.isEnabled = true + binding.textViewPercent.isVisible = false + binding.textViewDetails.textAndVisible = item.error + binding.buttonCancel.isVisible = false + binding.buttonResume.isVisible = false + binding.buttonPause.isVisible = false + } + + WorkInfo.State.CANCELLED -> { + binding.textViewStatus.setText(R.string.canceled) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.progressBar.isEnabled = true + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = false + binding.buttonResume.isVisible = false + binding.buttonPause.isVisible = false + } + } + } + + onViewRecycled { + binding.imageViewCover.disposeImageRequest() + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt new file mode 100644 index 000000000..c4dd45699 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.download.ui.list + +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener + +interface DownloadItemListener : OnListItemClickListener { + + fun onCancelClick(item: DownloadItemModel) + + fun onPauseClick(item: DownloadItemModel) + + fun onResumeClick(item: DownloadItemModel) +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt new file mode 100644 index 000000000..f8d95a66c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt @@ -0,0 +1,83 @@ +package org.koitharu.kotatsu.download.ui.list + +import android.text.format.DateUtils +import androidx.work.WorkInfo +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.Manga +import java.util.Date +import java.util.UUID + +class DownloadItemModel( + val id: UUID, + val workState: WorkInfo.State, + val isIndeterminate: Boolean, + val isPaused: Boolean, + val manga: Manga, + val error: String?, + val max: Int, + val totalChapters: Int, + val progress: Int, + val eta: Long, + val timestamp: Date, +) : ListModel, Comparable { + + val percent: Float + get() = if (max > 0) progress / max.toFloat() else 0f + + val hasEta: Boolean + get() = workState == WorkInfo.State.RUNNING && !isPaused && eta > 0L + + val canPause: Boolean + get() = workState == WorkInfo.State.RUNNING && !isPaused && error == null + + val canResume: Boolean + get() = workState == WorkInfo.State.RUNNING && isPaused + + fun getEtaString(): CharSequence? = if (hasEta) { + DateUtils.getRelativeTimeSpanString( + eta, + System.currentTimeMillis(), + DateUtils.SECOND_IN_MILLIS, + ) + } else { + null + } + + override fun compareTo(other: DownloadItemModel): Int { + return timestamp.compareTo(other.timestamp) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DownloadItemModel + + if (id != other.id) return false + if (workState != other.workState) return false + if (isIndeterminate != other.isIndeterminate) return false + if (isPaused != other.isPaused) return false + if (manga != other.manga) return false + if (error != other.error) return false + if (max != other.max) return false + if (totalChapters != other.totalChapters) return false + if (progress != other.progress) return false + if (eta != other.eta) return false + return timestamp == other.timestamp + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + workState.hashCode() + result = 31 * result + isIndeterminate.hashCode() + result = 31 * result + isPaused.hashCode() + result = 31 * result + manga.hashCode() + result = 31 * result + (error?.hashCode() ?: 0) + result = 31 * result + max + result = 31 * result + totalChapters + result = 31 * result + progress + result = 31 * result + eta.hashCode() + result = 31 * result + timestamp.hashCode() + return result + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt new file mode 100644 index 000000000..1fe368b37 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -0,0 +1,171 @@ +package org.koitharu.kotatsu.download.ui.list + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.viewModels +import androidx.annotation.Px +import androidx.appcompat.view.ActionMode +import androidx.core.graphics.Insets +import androidx.core.view.updatePadding +import androidx.lifecycle.Observer +import coil.ImageLoader +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.base.ui.list.ListSelectionController +import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration +import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.download.ui.worker.PausingReceiver +import javax.inject.Inject + +@AndroidEntryPoint +class DownloadsActivity : BaseActivity(), + DownloadItemListener, + ListSelectionController.Callback2 { + + @Inject + lateinit var coil: ImageLoader + + private val viewModel by viewModels() + private lateinit var selectionController: ListSelectionController + + @Px + private var listSpacing = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) + listSpacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + val downloadsAdapter = DownloadsAdapter(this, coil, this) + val decoration = SpacingItemDecoration(listSpacing) + selectionController = ListSelectionController( + activity = this, + decoration = DownloadsSelectionDecoration(this), + registryOwner = this, + callback = this, + ) + with(binding.recyclerView) { + setHasFixedSize(true) + addItemDecoration(decoration) + adapter = downloadsAdapter + selectionController.attachToRecyclerView(this) + } + addMenuProvider(DownloadsMenuProvider(this, viewModel)) + viewModel.items.observe(this) { + downloadsAdapter.items = it + } + val menuObserver = Observer { _ -> invalidateOptionsMenu() } + viewModel.hasActiveWorks.observe(this, menuObserver) + viewModel.hasPausedWorks.observe(this, menuObserver) + viewModel.hasCancellableWorks.observe(this, menuObserver) + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.recyclerView.updatePadding( + left = insets.left + listSpacing, + right = insets.right + listSpacing, + bottom = insets.bottom, + ) + binding.toolbar.updatePadding( + left = insets.left, + right = insets.right, + ) + } + + override fun onItemClick(item: DownloadItemModel, view: View) { + if (selectionController.onItemClick(item.id.mostSignificantBits)) { + return + } + startActivity(DetailsActivity.newIntent(view.context, item.manga)) + } + + override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean { + return selectionController.onItemLongClick(item.id.mostSignificantBits) + } + + override fun onCancelClick(item: DownloadItemModel) { + viewModel.cancel(item.id) + } + + override fun onPauseClick(item: DownloadItemModel) { + sendBroadcast(PausingReceiver.getPauseIntent(item.id)) + } + + override fun onResumeClick(item: DownloadItemModel) { + sendBroadcast(PausingReceiver.getResumeIntent(item.id)) + } + + override fun onSelectionChanged(controller: ListSelectionController, count: Int) { + binding.recyclerView.invalidateItemDecorations() + } + + override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_downloads, menu) + return true + } + + override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_resume -> { + viewModel.resume(controller.snapshot()) + mode.finish() + true + } + + R.id.action_pause -> { + viewModel.pause(controller.snapshot()) + mode.finish() + true + } + + R.id.action_cancel -> { + viewModel.cancel(controller.snapshot()) + mode.finish() + true + } + + R.id.action_remove -> { + viewModel.remove(controller.snapshot()) + mode.finish() + true + } + + R.id.action_select_all -> { + controller.addAll(viewModel.allIds()) + true + } + + else -> false + } + } + + override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { + val snapshot = viewModel.snapshot(controller.peekCheckedIds()) + var canPause = true + var canResume = true + var canCancel = true + var canRemove = true + for (item in snapshot) { + canPause = canPause and item.canPause + canResume = canResume and item.canResume + canCancel = canCancel and !item.workState.isFinished + canRemove = canRemove and item.workState.isFinished + } + menu.findItem(R.id.action_pause)?.isVisible = canPause + menu.findItem(R.id.action_resume)?.isVisible = canResume + menu.findItem(R.id.action_cancel)?.isVisible = canCancel + menu.findItem(R.id.action_remove)?.isVisible = canRemove + return super.onPrepareActionMode(controller, mode, menu) + } + + companion object { + + fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt new file mode 100644 index 000000000..2778efdc4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt @@ -0,0 +1,65 @@ +package org.koitharu.kotatsu.download.ui.list + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD +import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD +import org.koitharu.kotatsu.list.ui.adapter.relatedDateItemAD +import org.koitharu.kotatsu.list.ui.model.ListModel +import kotlin.jvm.internal.Intrinsics + +class DownloadsAdapter( + lifecycleOwner: LifecycleOwner, + coil: ImageLoader, + listener: DownloadItemListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager.addDelegate(ITEM_TYPE_DOWNLOAD, downloadItemAD(lifecycleOwner, coil, listener)) + .addDelegate(loadingStateAD()) + .addDelegate(emptyStateListAD(coil, lifecycleOwner, null)) + .addDelegate(relatedDateItemAD()) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when { + + oldItem is DownloadItemModel && newItem is DownloadItemModel -> { + oldItem.id == newItem.id + } + + oldItem is DateTimeAgo && newItem is DateTimeAgo -> { + oldItem == newItem + } + + else -> oldItem.javaClass == newItem.javaClass + } + + override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + + override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? { + return when (newItem) { + is DownloadItemModel -> { + oldItem as DownloadItemModel + if (oldItem.workState == newItem.workState) { + Unit + } else { + null + } + } + + else -> super.getChangePayload(oldItem, newItem) + } + } + } + + companion object { + const val ITEM_TYPE_DOWNLOAD = 0 + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt new file mode 100644 index 000000000..a864cf3ac --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt @@ -0,0 +1,36 @@ +package org.koitharu.kotatsu.download.ui.list + +import android.content.Context +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.core.view.MenuProvider +import org.koitharu.kotatsu.R + +class DownloadsMenuProvider( + private val context: Context, + private val viewModel: DownloadsViewModel, +) : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_downloads, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + when (menuItem.itemId) { + R.id.action_pause -> viewModel.pauseAll() + R.id.action_resume -> viewModel.resumeAll() + R.id.action_cancel_all -> viewModel.cancelAll() + R.id.action_remove_completed -> viewModel.removeCompleted() + else -> return false + } + return true + } + + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem(R.id.action_pause)?.isVisible = viewModel.hasActiveWorks.value == true + menu.findItem(R.id.action_resume)?.isVisible = viewModel.hasPausedWorks.value == true + menu.findItem(R.id.action_cancel_all)?.isVisible = viewModel.hasCancellableWorks.value == true + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsSelectionDecoration.kt new file mode 100644 index 000000000..0ca62de1b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsSelectionDecoration.kt @@ -0,0 +1,75 @@ +package org.koitharu.kotatsu.download.ui.list + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.view.View +import androidx.cardview.widget.CardView +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_ID +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration +import org.koitharu.kotatsu.utils.ext.getItem +import org.koitharu.kotatsu.utils.ext.getThemeColor +import com.google.android.material.R as materialR + +class DownloadsSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) + private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset) + private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size) + private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) + private val fillColor = ColorUtils.setAlphaComponent( + ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), + 0x74, + ) + private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) + + init { + hasBackground = false + hasForeground = true + isIncludeDecorAndMargins = false + + paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width) + checkIcon?.setTint(strokeColor) + } + + override fun getItemId(parent: RecyclerView, child: View): Long { + val holder = parent.getChildViewHolder(child) ?: return NO_ID + val item = holder.getItem(DownloadItemModel::class.java) ?: return NO_ID + return item.id.mostSignificantBits + } + + override fun onDrawForeground( + canvas: Canvas, + parent: RecyclerView, + child: View, + bounds: RectF, + state: RecyclerView.State, + ) { + val isCard = child is CardView + val radius = (child as? CardView)?.radius ?: defaultRadius + paint.color = fillColor + paint.style = Paint.Style.FILL + canvas.drawRoundRect(bounds, radius, radius, paint) + paint.color = strokeColor + paint.style = Paint.Style.STROKE + canvas.drawRoundRect(bounds, radius, radius, paint) + if (isCard) { + checkIcon?.run { + setBounds( + (bounds.right - iconSize - iconOffset).toInt(), + (bounds.top + iconOffset).toInt(), + (bounds.right - iconOffset).toInt(), + (bounds.top + iconOffset + iconSize).toInt(), + ) + draw(canvas) + } + } + } +} 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 new file mode 100644 index 000000000..f34651e8d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -0,0 +1,226 @@ +package org.koitharu.kotatsu.download.ui.list + +import androidx.collection.LongSparseArray +import androidx.collection.getOrElse +import androidx.collection.set +import androidx.lifecycle.viewModelScope +import androidx.work.Data +import androidx.work.WorkInfo +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaDataRepository +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.download.domain.DownloadState +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker +import org.koitharu.kotatsu.list.ui.model.EmptyState +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.utils.asFlowLiveData +import org.koitharu.kotatsu.utils.ext.daysDiff +import java.util.Date +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltViewModel +class DownloadsViewModel @Inject constructor( + private val workScheduler: DownloadWorker.Scheduler, + private val mangaDataRepository: MangaDataRepository, +) : BaseViewModel() { + + private val mangaCache = LongSparseArray() + private val cacheMutex = Mutex() + private val works = workScheduler.observeWorks() + .mapLatest { it.toDownloadsList() } + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + + val items = works.map { + it?.toUiList() ?: listOf(LoadingState) + }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + + val hasPausedWorks = works.map { + it?.any { x -> x.canResume } == true + }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) + + val hasActiveWorks = works.map { + it?.any { x -> x.canPause } == true + }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) + + val hasCancellableWorks = works.map { + it?.any { x -> !x.workState.isFinished } == true + }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) + + fun cancel(id: UUID) { + launchJob(Dispatchers.Default) { + workScheduler.cancel(id) + } + } + + fun cancel(ids: Set) { + launchJob(Dispatchers.Default) { + val snapshot = works.value ?: return@launchJob + for (work in snapshot) { + if (work.id.mostSignificantBits in ids) { + workScheduler.cancel(work.id) + } + } + } + } + + fun cancelAll() { + launchJob(Dispatchers.Default) { + workScheduler.cancelAll() + } + } + + fun pause(ids: Set) { + val snapshot = works.value ?: return + for (work in snapshot) { + if (work.id.mostSignificantBits in ids) { + workScheduler.pause(work.id) + } + } + } + + fun pauseAll() { + val snapshot = works.value ?: return + for (work in snapshot) { + if (work.canPause) { + workScheduler.pause(work.id) + } + } + } + + fun resumeAll() { + val snapshot = works.value ?: return + for (work in snapshot) { + if (work.workState == WorkInfo.State.RUNNING && work.isPaused) { + workScheduler.resume(work.id) + } + } + } + + fun resume(ids: Set) { + val snapshot = works.value ?: return + for (work in snapshot) { + if (work.id.mostSignificantBits in ids) { + workScheduler.resume(work.id) + } + } + } + + fun remove(ids: Set) { + launchJob(Dispatchers.Default) { + val snapshot = works.value ?: return@launchJob + for (work in snapshot) { + if (work.id.mostSignificantBits in ids) { + workScheduler.delete(work.id) + } + } + } + } + + fun removeCompleted() { + launchJob(Dispatchers.Default) { + workScheduler.removeCompleted() + } + } + + fun snapshot(ids: Set): Collection { + return works.value?.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids }.orEmpty() + } + + fun allIds(): Set = works.value?.mapToSet { + it.id.mostSignificantBits + } ?: emptySet() + + private suspend fun List.toDownloadsList(): List { + if (isEmpty()) { + return emptyList() + } + val list = mapNotNullTo(ArrayList(size)) { it.toUiModel() } + list.sortByDescending { it.timestamp } + return list + } + + private fun List.toUiList(): List { + if (isEmpty()) { + return emptyStateList() + } + val destination = ArrayList((size * 1.4).toInt()) + var prevDate: DateTimeAgo? = null + for (item in this) { + val date = timeAgo(item.timestamp) + if (prevDate != date) { + destination += date + } + prevDate = date + destination += item + } + return destination + } + + private suspend fun WorkInfo.toUiModel(): DownloadItemModel? { + val workData = if (outputData == Data.EMPTY) progress else outputData + val mangaId = DownloadState.getMangaId(workData) + if (mangaId == 0L) return null + val manga = getManga(mangaId) ?: return null + return DownloadItemModel( + id = id, + workState = state, + manga = manga, + error = DownloadState.getError(workData), + isIndeterminate = DownloadState.isIndeterminate(workData), + isPaused = DownloadState.isPaused(workData), + max = DownloadState.getMax(workData), + progress = DownloadState.getProgress(workData), + eta = DownloadState.getEta(workData), + timestamp = DownloadState.getTimestamp(workData), + totalChapters = DownloadState.getDownloadedChapters(workData).size, + ) + } + + private fun timeAgo(date: Date): DateTimeAgo { + val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L) + val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt() + val diffDays = -date.daysDiff(System.currentTimeMillis()) + return when { + diffMinutes < 3 -> DateTimeAgo.JustNow + diffDays < 1 -> DateTimeAgo.Today + diffDays == 1 -> DateTimeAgo.Yesterday + diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays) + else -> DateTimeAgo.Absolute(date) + } + } + + private fun emptyStateList() = listOf( + EmptyState( + icon = R.drawable.ic_empty_common, + textPrimary = R.string.text_downloads_holder, + textSecondary = 0, + actionStringRes = 0, + ), + ) + + private suspend fun getManga(mangaId: Long): Manga? { + mangaCache[mangaId]?.let { + return it + } + return cacheMutex.withLock { + mangaCache.getOrElse(mangaId) { + mangaDataRepository.findMangaById(mangaId)?.also { mangaCache[mangaId] = it } ?: return null + } + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt deleted file mode 100644 index 103f3621d..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt +++ /dev/null @@ -1,356 +0,0 @@ -package org.koitharu.kotatsu.download.ui.service - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.os.Build -import android.text.format.DateUtils -import android.util.SparseArray -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toBitmap -import androidx.core.text.HtmlCompat -import androidx.core.text.htmlEncode -import androidx.core.text.parseAsHtml -import androidx.core.util.forEach -import androidx.core.util.isNotEmpty -import androidx.core.util.size -import com.google.android.material.R as materialR -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.domain.DownloadState -import org.koitharu.kotatsu.download.ui.DownloadsActivity -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.ellipsize -import org.koitharu.kotatsu.parsers.util.format -import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.utils.ext.getDisplayMessage - -class DownloadNotification(private val context: Context) { - - private val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - private val states = SparseArray() - private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID) - - private val queueIntent = PendingIntentCompat.getActivity( - context, - REQUEST_QUEUE, - DownloadsActivity.newIntent(context), - 0, - false, - ) - - private val localListIntent = PendingIntentCompat.getActivity( - context, - REQUEST_LIST_LOCAL, - MangaListActivity.newIntent(context, MangaSource.LOCAL), - 0, - false, - ) - - init { - groupBuilder.setOnlyAlertOnce(true) - groupBuilder.setDefaults(0) - groupBuilder.color = ContextCompat.getColor(context, R.color.blue_primary) - groupBuilder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE - groupBuilder.setSilent(true) - groupBuilder.setGroup(GROUP_ID) - groupBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - groupBuilder.setGroupSummary(true) - groupBuilder.setContentTitle(context.getString(R.string.downloading_manga)) - } - - fun buildGroupNotification(): Notification { - val style = NotificationCompat.InboxStyle(groupBuilder) - var progress = 0f - var isAllDone = true - var isInProgress = false - groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - states.forEach { _, state -> - if (state.manga.isNsfw) { - groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE) - } - val summary = when (state) { - is DownloadState.Cancelled -> { - progress++ - context.getString(R.string.cancelling_) - } - - is DownloadState.Done -> { - progress++ - context.getString(R.string.download_complete) - } - - is DownloadState.Error -> { - isAllDone = false - context.getString(R.string.error) - } - - is DownloadState.PostProcessing -> { - progress++ - isInProgress = true - isAllDone = false - context.getString(R.string.processing_) - } - - is DownloadState.Preparing -> { - isAllDone = false - isInProgress = true - context.getString(R.string.preparing_) - } - - is DownloadState.Progress -> { - isAllDone = false - isInProgress = true - progress += state.percent - context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) - } - - is DownloadState.Queued -> { - isAllDone = false - isInProgress = true - context.getString(R.string.queued) - } - } - style.addLine( - context.getString( - R.string.download_summary_pattern, - state.manga.title.ellipsize(16).htmlEncode(), - summary.htmlEncode(), - ).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY), - ) - } - progress = if (isInProgress) { - progress / states.size.toFloat() - } else { - 1f - } - style.setBigContentTitle( - context.getString(if (isAllDone) R.string.download_complete else R.string.downloading_manga), - ) - groupBuilder.setContentText(context.resources.getQuantityString(R.plurals.items, states.size, states.size())) - groupBuilder.setNumber(states.size) - groupBuilder.setSmallIcon( - if (isInProgress) android.R.drawable.stat_sys_download else android.R.drawable.stat_sys_download_done, - ) - groupBuilder.setContentIntent(if (isAllDone) localListIntent else queueIntent) - groupBuilder.setAutoCancel(isAllDone) - when (progress) { - 1f -> groupBuilder.setProgress(0, 0, false) - 0f -> groupBuilder.setProgress(1, 0, true) - else -> groupBuilder.setProgress(100, (progress * 100f).toInt(), false) - } - return groupBuilder.build() - } - - fun detach() { - if (states.isNotEmpty()) { - val notification = buildGroupNotification() - manager.notify(ID_GROUP_DETACHED, notification) - } - manager.cancel(ID_GROUP) - } - - fun newItem(startId: Int) = Item(startId) - - inner class Item( - private val startId: Int, - ) { - - private val builder = NotificationCompat.Builder(context, CHANNEL_ID) - private val cancelAction = NotificationCompat.Action( - materialR.drawable.material_ic_clear_black_24dp, - context.getString(android.R.string.cancel), - PendingIntentCompat.getBroadcast( - context, - startId * 2, - DownloadService.getCancelIntent(startId), - PendingIntent.FLAG_CANCEL_CURRENT, - false, - ), - ) - private val retryAction = NotificationCompat.Action( - R.drawable.ic_restart_black, - context.getString(R.string.try_again), - PendingIntentCompat.getBroadcast( - context, - startId * 2 + 1, - DownloadService.getResumeIntent(startId), - PendingIntent.FLAG_CANCEL_CURRENT, - false, - ), - ) - - init { - builder.setOnlyAlertOnce(true) - builder.setDefaults(0) - builder.color = ContextCompat.getColor(context, R.color.blue_primary) - builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE - builder.setSilent(true) - builder.setGroup(GROUP_ID) - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - } - - fun notify(state: DownloadState, timeLeft: Long) { - builder.setContentTitle(state.manga.title) - builder.setContentText(context.getString(R.string.manga_downloading_)) - builder.setProgress(1, 0, true) - builder.setSmallIcon(android.R.drawable.stat_sys_download) - builder.setContentIntent(queueIntent) - builder.setStyle(null) - builder.setLargeIcon(state.cover?.toBitmap()) - builder.clearActions() - builder.setSubText(null) - builder.setShowWhen(false) - builder.setVisibility( - if (state.manga.isNsfw) { - NotificationCompat.VISIBILITY_PRIVATE - } else { - NotificationCompat.VISIBILITY_PUBLIC - }, - ) - when (state) { - is DownloadState.Cancelled -> { - builder.setProgress(1, 0, true) - builder.setContentText(context.getString(R.string.cancelling_)) - builder.setContentIntent(null) - builder.setStyle(null) - builder.setOngoing(true) - builder.priority = NotificationCompat.PRIORITY_DEFAULT - } - - is DownloadState.Done -> { - builder.setProgress(0, 0, false) - builder.setContentText(context.getString(R.string.download_complete)) - builder.setContentIntent(createMangaIntent(context, state.localManga)) - builder.setAutoCancel(true) - builder.setSmallIcon(android.R.drawable.stat_sys_download_done) - builder.setCategory(null) - builder.setStyle(null) - builder.setOngoing(false) - builder.setShowWhen(true) - builder.setWhen(System.currentTimeMillis()) - builder.priority = NotificationCompat.PRIORITY_DEFAULT - } - - is DownloadState.Error -> { - val message = state.error.getDisplayMessage(context.resources) - builder.setProgress(0, 0, false) - builder.setSmallIcon(android.R.drawable.stat_notify_error) - builder.setSubText(context.getString(R.string.error)) - builder.setContentText(message) - builder.setAutoCancel(!state.canRetry) - builder.setOngoing(state.canRetry) - builder.setCategory(NotificationCompat.CATEGORY_ERROR) - builder.setShowWhen(true) - builder.setWhen(System.currentTimeMillis()) - builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) - if (state.canRetry) { - builder.addAction(cancelAction) - builder.addAction(retryAction) - } - builder.priority = NotificationCompat.PRIORITY_DEFAULT - } - - is DownloadState.PostProcessing -> { - builder.setProgress(1, 0, true) - builder.setContentText(context.getString(R.string.processing_)) - builder.setStyle(null) - builder.setOngoing(true) - builder.priority = NotificationCompat.PRIORITY_DEFAULT - } - - is DownloadState.Queued -> { - builder.setProgress(0, 0, false) - builder.setContentText(context.getString(R.string.queued)) - builder.setStyle(null) - builder.setOngoing(true) - builder.addAction(cancelAction) - builder.priority = NotificationCompat.PRIORITY_LOW - } - - is DownloadState.Preparing -> { - builder.setProgress(1, 0, true) - builder.setContentText(context.getString(R.string.preparing_)) - builder.setStyle(null) - builder.setOngoing(true) - builder.addAction(cancelAction) - builder.priority = NotificationCompat.PRIORITY_DEFAULT - } - - is DownloadState.Progress -> { - builder.setProgress(state.max, state.progress, false) - val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) - if (timeLeft > 0L) { - val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS) - builder.setContentText(eta) - builder.setSubText(percent) - } else { - builder.setContentText(percent) - } - builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) - builder.setStyle(null) - builder.setOngoing(true) - builder.addAction(cancelAction) - builder.priority = NotificationCompat.PRIORITY_DEFAULT - } - } - val notification = builder.build() - states.append(startId, state) - updateGroupNotification() - manager.notify(TAG, startId, notification) - } - - fun dismiss() { - manager.cancel(TAG, startId) - states.remove(startId) - updateGroupNotification() - } - } - - private fun updateGroupNotification() { - val notification = buildGroupNotification() - manager.notify(ID_GROUP, notification) - } - - private fun createMangaIntent(context: Context, manga: Manga) = PendingIntentCompat.getActivity( - context, - manga.hashCode(), - DetailsActivity.newIntent(context, manga), - PendingIntent.FLAG_CANCEL_CURRENT, - false, - ) - - companion object { - - private const val TAG = "download" - private const val CHANNEL_ID = "download" - private const val GROUP_ID = "downloads" - private const val REQUEST_QUEUE = 6 - private const val REQUEST_LIST_LOCAL = 7 - const val ID_GROUP = 9999 - private const val ID_GROUP_DETACHED = 9998 - - fun createChannel(context: Context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val manager = NotificationManagerCompat.from(context) - if (manager.getNotificationChannel(CHANNEL_ID) == null) { - val channel = NotificationChannel( - CHANNEL_ID, - context.getString(R.string.downloads), - NotificationManager.IMPORTANCE_LOW, - ) - channel.enableVibration(false) - channel.enableLights(false) - channel.setSound(null, null) - manager.createNotificationChannel(channel) - } - } - } - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt deleted file mode 100644 index 8dd780d45..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ /dev/null @@ -1,262 +0,0 @@ -package org.koitharu.kotatsu.download.ui.service - -import android.app.DownloadManager.ACTION_DOWNLOAD_COMPLETE -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.Binder -import android.os.IBinder -import android.os.PowerManager -import android.view.View -import androidx.annotation.MainThread -import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.transformWhile -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.base.ui.BaseService -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.download.domain.DownloadManager -import org.koitharu.kotatsu.download.domain.DownloadState -import org.koitharu.kotatsu.download.ui.DownloadsActivity -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat -import org.koitharu.kotatsu.utils.ext.throttle -import org.koitharu.kotatsu.utils.progress.PausingProgressJob -import org.koitharu.kotatsu.utils.progress.ProgressJob -import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator -import java.util.concurrent.TimeUnit -import javax.inject.Inject -import kotlin.collections.set - -@AndroidEntryPoint -class DownloadService : BaseService() { - - private lateinit var downloadNotification: DownloadNotification - private lateinit var wakeLock: PowerManager.WakeLock - - @Inject - lateinit var downloadManager: DownloadManager - - private val jobs = LinkedHashMap>() - private val jobCount = MutableStateFlow(0) - private val controlReceiver = ControlReceiver() - - override fun onCreate() { - super.onCreate() - downloadNotification = DownloadNotification(this) - wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager) - .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") - wakeLock.acquire(TimeUnit.HOURS.toMillis(8)) - DownloadNotification.createChannel(this) - startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification()) - val intentFilter = IntentFilter() - intentFilter.addAction(ACTION_DOWNLOAD_CANCEL) - intentFilter.addAction(ACTION_DOWNLOAD_RESUME) - ContextCompat.registerReceiver(this, controlReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - val manga = intent?.getParcelableExtraCompat(EXTRA_MANGA)?.manga - val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS) - return if (manga != null) { - jobs[startId] = downloadManga(startId, manga, chapters) - jobCount.value = jobs.size - START_REDELIVER_INTENT - } else { - stopSelfIfIdle() - START_NOT_STICKY - } - } - - override fun onBind(intent: Intent): IBinder { - super.onBind(intent) - return DownloadBinder(this) - } - - override fun onDestroy() { - unregisterReceiver(controlReceiver) - if (wakeLock.isHeld) { - wakeLock.release() - } - super.onDestroy() - } - - private fun downloadManga( - startId: Int, - manga: Manga, - chaptersIds: LongArray?, - ): PausingProgressJob { - val job = downloadManager.downloadManga(manga, chaptersIds, startId) - listenJob(job) - return job - } - - private fun listenJob(job: ProgressJob) { - lifecycleScope.launch { - val startId = job.progressValue.startId - val notificationItem = downloadNotification.newItem(startId) - try { - val timeLeftEstimator = TimeLeftEstimator() - notificationItem.notify(job.progressValue, -1L) - job.progressAsFlow() - .onEach { state -> - if (state is DownloadState.Progress) { - timeLeftEstimator.tick(value = state.progress, total = state.max) - } else { - timeLeftEstimator.emptyTick() - } - } - .throttle { state -> if (state is DownloadState.Progress) 400L else 0L } - .whileActive() - .collect { state -> - val timeLeft = timeLeftEstimator.getEstimatedTimeLeft() - notificationItem.notify(state, timeLeft) - } - job.join() - } finally { - (job.progressValue as? DownloadState.Done)?.let { - sendBroadcast( - Intent(ACTION_DOWNLOAD_COMPLETE) - .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)), - ) - } - if (job.isCancelled) { - notificationItem.dismiss() - if (jobs.remove(startId) != null) { - jobCount.value = jobs.size - } - } else { - notificationItem.notify(job.progressValue, -1L) - } - } - }.invokeOnCompletion { - stopSelfIfIdle() - } - } - - private fun Flow.whileActive(): Flow = transformWhile { state -> - emit(state) - !state.isTerminal - } - - @MainThread - private fun stopSelfIfIdle() { - if (jobs.any { (_, job) -> job.isActive }) { - return - } - downloadNotification.detach() - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - stopSelf() - } - - inner class ControlReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent?) { - when (intent?.action) { - ACTION_DOWNLOAD_CANCEL -> { - val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0) - jobs[cancelId]?.cancel() - } - - ACTION_DOWNLOAD_RESUME -> { - val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0) - jobs[cancelId]?.resume() - } - } - } - } - - class DownloadBinder(service: DownloadService) : Binder(), DefaultLifecycleObserver { - - private var downloadsStateFlow = MutableStateFlow>>(emptyList()) - - init { - service.lifecycle.addObserver(this) - service.jobCount.onEach { - downloadsStateFlow.value = service.jobs.values.toList() - }.launchIn(service.lifecycleScope) - } - - override fun onDestroy(owner: LifecycleOwner) { - owner.lifecycle.removeObserver(this) - downloadsStateFlow.value = emptyList() - super.onDestroy(owner) - } - - val downloads - get() = downloadsStateFlow.asStateFlow() - } - - companion object { - - private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" - private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME" - - const val EXTRA_MANGA = "manga" - private const val EXTRA_CHAPTERS_IDS = "chapters_ids" - private const val EXTRA_CANCEL_ID = "cancel_id" - - fun start(view: View, manga: Manga, chaptersIds: Collection? = null) { - if (chaptersIds?.isEmpty() == true) { - return - } - val intent = Intent(view.context, DownloadService::class.java) - intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false)) - if (chaptersIds != null) { - intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray()) - } - ContextCompat.startForegroundService(view.context, intent) - showStartedSnackbar(view) - } - - fun start(view: View, manga: Collection) { - if (manga.isEmpty()) { - return - } - for (item in manga) { - val intent = Intent(view.context, DownloadService::class.java) - intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false)) - ContextCompat.startForegroundService(view.context, intent) - } - showStartedSnackbar(view) - } - - fun confirmAndStart(view: View, items: Set) { - MaterialAlertDialogBuilder(view.context) - .setTitle(R.string.save_manga) - .setMessage(R.string.batch_manga_save_confirm) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.save) { _, _ -> - start(view, items) - }.show() - } - - fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL) - .putExtra(EXTRA_CANCEL_ID, startId) - - fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME) - .putExtra(EXTRA_CANCEL_ID, startId) - - private fun showStartedSnackbar(view: View) { - Snackbar.make(view, R.string.download_started, Snackbar.LENGTH_LONG) - .setAction(R.string.details) { - it.context.startActivity(DownloadsActivity.newIntent(it.context)) - }.show() - } - } -} 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 new file mode 100644 index 000000000..a8c06e516 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -0,0 +1,260 @@ +package org.koitharu.kotatsu.download.ui.worker + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Build +import android.text.format.DateUtils +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.work.WorkManager +import coil.ImageLoader +import coil.request.ImageRequest +import coil.size.Scale +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.download.domain.DownloadState +import org.koitharu.kotatsu.download.ui.list.DownloadsActivity +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.format +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.search.ui.MangaListActivity +import org.koitharu.kotatsu.utils.ext.getDrawableOrThrow +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import java.util.UUID +import com.google.android.material.R as materialR + +private const val CHANNEL_ID = "download" +private const val GROUP_ID = "downloads" + +class DownloadNotificationFactory @AssistedInject constructor( + @ApplicationContext private val context: Context, + private val coil: ImageLoader, + @Assisted private val uuid: UUID, +) { + + private val covers = HashMap() + private val builder = NotificationCompat.Builder(context, CHANNEL_ID) + private val mutex = Mutex() + + private val coverWidth = context.resources.getDimensionPixelSize( + androidx.core.R.dimen.compat_notification_large_icon_max_width, + ) + private val coverHeight = context.resources.getDimensionPixelSize( + androidx.core.R.dimen.compat_notification_large_icon_max_height, + ) + private val queueIntent = PendingIntentCompat.getActivity( + context, + 0, + DownloadsActivity.newIntent(context), + 0, + false, + ) + + private val actionCancel by lazy { + NotificationCompat.Action( + materialR.drawable.material_ic_clear_black_24dp, + context.getString(android.R.string.cancel), + WorkManager.getInstance(context).createCancelPendingIntent(uuid), + ) + } + + private val actionPause by lazy { + NotificationCompat.Action( + R.drawable.ic_action_pause, + context.getString(R.string.pause), + PausingReceiver.createPausePendingIntent(context, uuid), + ) + } + + private val actionResume by lazy { + NotificationCompat.Action( + R.drawable.ic_action_resume, + context.getString(R.string.resume), + PausingReceiver.createResumePendingIntent(context, uuid), + ) + } + + init { + createChannel() + builder.setOnlyAlertOnce(true) + builder.setDefaults(0) + builder.color = ContextCompat.getColor(context, R.color.blue_primary) + builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE + builder.setSilent(true) + builder.setGroup(GROUP_ID) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + builder.priority = NotificationCompat.PRIORITY_DEFAULT + } + + suspend fun create(state: DownloadState?): Notification = mutex.withLock { + builder.setContentTitle(state?.manga?.title ?: context.getString(R.string.preparing_)) + builder.setContentText(context.getString(R.string.manga_downloading_)) + builder.setProgress(1, 0, true) + builder.setSmallIcon(android.R.drawable.stat_sys_download) + builder.setContentIntent(queueIntent) + builder.setStyle(null) + builder.setLargeIcon(if (state != null) getCover(state.manga)?.toBitmap() else null) + builder.clearActions() + builder.setSubText(null) + builder.setShowWhen(false) + builder.setVisibility( + if (state != null && state.manga.isNsfw) { + NotificationCompat.VISIBILITY_PRIVATE + } else { + NotificationCompat.VISIBILITY_PUBLIC + }, + ) + when { + state == null -> Unit + state.localManga != null -> { // downloaded, final state + builder.setProgress(0, 0, false) + builder.setContentText(context.getString(R.string.download_complete)) + builder.setContentIntent(createMangaIntent(context, state.localManga.manga)) + builder.setAutoCancel(true) + builder.setSmallIcon(android.R.drawable.stat_sys_download_done) + builder.setCategory(null) + builder.setStyle(null) + builder.setOngoing(false) + builder.setShowWhen(true) + 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 = 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) + builder.setSmallIcon(R.drawable.ic_stat_paused) + builder.addAction(actionCancel) + builder.addAction(actionResume) + } + + state.error != null -> { // error, final state + builder.setProgress(0, 0, false) + builder.setSmallIcon(android.R.drawable.stat_notify_error) + builder.setSubText(context.getString(R.string.error)) + builder.setContentText(state.error) + builder.setAutoCancel(true) + builder.setOngoing(false) + builder.setCategory(NotificationCompat.CATEGORY_ERROR) + builder.setShowWhen(true) + builder.setWhen(System.currentTimeMillis()) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error)) + } + + else -> { + builder.setProgress(state.max, state.progress, false) + 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, + System.currentTimeMillis(), + DateUtils.SECOND_IN_MILLIS, + ) + builder.setContentText(eta) + builder.setSubText(percent) + } else { + builder.setContentText(percent) + } + builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) + builder.setStyle(null) + builder.setOngoing(true) + builder.addAction(actionCancel) + builder.addAction(actionPause) + } + } + return builder.build() + } + + private fun createMangaIntent(context: Context, manga: Manga?) = PendingIntentCompat.getActivity( + context, + manga.hashCode(), + if (manga != null) { + DetailsActivity.newIntent(context, manga) + } else { + MangaListActivity.newIntent(context, MangaSource.LOCAL) + }, + PendingIntent.FLAG_CANCEL_CURRENT, + false, + ) + + private suspend fun getCover(manga: Manga) = covers[manga] ?: run { + runCatchingCancellable { + coil.execute( + ImageRequest.Builder(context) + .data(manga.coverUrl) + .allowHardware(false) + .tag(manga.source) + .size(coverWidth, coverHeight) + .scale(Scale.FILL) + .build(), + ).getDrawableOrThrow() + }.onSuccess { + covers[manga] = it + }.onFailure { + it.printStackTraceDebug() + }.getOrNull() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = NotificationManagerCompat.from(context) + if (manager.getNotificationChannel(CHANNEL_ID) == null) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.downloads), + NotificationManager.IMPORTANCE_LOW, + ) + channel.enableVibration(false) + channel.enableLights(false) + channel.setSound(null, null) + manager.createNotificationChannel(channel) + } + } + } + + @AssistedFactory + interface Factory { + + fun create(uuid: UUID): DownloadNotificationFactory + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt new file mode 100644 index 000000000..69453b711 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.download.ui.worker + +import android.view.View +import androidx.lifecycle.Observer +import com.google.android.material.snackbar.Snackbar +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.download.ui.list.DownloadsActivity +import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner +import org.koitharu.kotatsu.utils.ext.findActivity + +class DownloadStartedObserver( + private val snackbarHost: View, +) : Observer { + + override fun onChanged(value: Unit) { + val snackbar = Snackbar.make(snackbarHost, R.string.download_started, Snackbar.LENGTH_LONG) + (snackbarHost.context.findActivity() as? BottomNavOwner)?.let { + snackbar.anchorView = it.bottomNav + } + snackbar.setAction(R.string.details) { + it.context.startActivity(DownloadsActivity.newIntent(it.context)) + } + snackbar.show() + } +} 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 new file mode 100644 index 000000000..49c285fa1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -0,0 +1,454 @@ +package org.koitharu.kotatsu.download.ui.worker + +import android.app.NotificationManager +import android.content.Context +import android.webkit.MimeTypeMap +import androidx.core.content.ContextCompat +import androidx.hilt.work.HiltWorker +import androidx.lifecycle.asFlow +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import dagger.Reusable +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.internal.closeQuietly +import okio.IOException +import okio.buffer +import okio.sink +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaDataRepository +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.domain.DownloadState +import org.koitharu.kotatsu.local.data.LocalManga +import org.koitharu.kotatsu.local.data.LocalStorageChanges +import org.koitharu.kotatsu.local.data.PagesCache +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 +import org.koitharu.kotatsu.utils.Throttler +import org.koitharu.kotatsu.utils.WorkManagerHelper +import org.koitharu.kotatsu.utils.ext.deleteAwait +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import org.koitharu.kotatsu.utils.ext.runCatchingCancellable +import org.koitharu.kotatsu.utils.ext.writeAllCancellable +import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator +import java.io.File +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltWorker +class DownloadWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val okHttp: OkHttpClient, + private val cache: PagesCache, + private val localMangaRepository: LocalMangaRepository, + private val mangaDataRepository: MangaDataRepository, + private val settings: AppSettings, + private val mangaRepositoryFactory: MangaRepository.Factory, + @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, + notificationFactoryFactory: DownloadNotificationFactory.Factory, +) : CoroutineWorker(appContext, params) { + + private val notificationFactory = notificationFactoryFactory.create(params.id) + private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + @Volatile + private var lastPublishedState: DownloadState? = null + private val currentState: DownloadState + get() = checkNotNull(lastPublishedState) + + private val pausingHandle = PausingHandle() + private val timeLeftEstimator = TimeLeftEstimator() + private val notificationThrottler = Throttler(400) + private val pausingReceiver = PausingReceiver(params.id, pausingHandle) + + override suspend fun doWork(): Result { + setForeground(getForegroundInfo()) + val mangaId = inputData.getLong(MANGA_ID, 0L) + val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure() + val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } + val downloadedIds = getDoneChapters() + lastPublishedState = DownloadState(manga, isIndeterminate = true) + return try { + 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() + 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(lastPublishedState), + ) + + private suspend fun downloadMangaImpl( + includedIds: LongArray?, + excludedIds: LongArray, + ) { + var manga = currentState.manga + val chaptersToSkip = excludedIds.toMutableSet() + withMangaLock(manga) { + ContextCompat.registerReceiver( + applicationContext, + pausingReceiver, + PausingReceiver.createIntentFilter(id), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + val destination = localMangaRepository.getOutputDir(manga) + checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) } + val tempFileName = "${manga.id}_$id.tmp" + var output: LocalMangaOutput? = null + try { + if (manga.source == MangaSource.LOCAL) { + manga = localMangaRepository.getRemoteManga(manga) + ?: error("Cannot obtain remote manga instance") + } + val repo = mangaRepositoryFactory.create(manga.source) + 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 = 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) + } + for ((pageIndex, page) in pages.withIndex()) { + runFailsafe(pausingHandle) { + val url = repo.getPageUrl(page) + val file = cache.get(url) + ?: downloadFile(url, destination, tempFileName, repo.source) + output.addPage( + chapter = chapter, + file = file, + pageNumber = pageIndex, + ext = MimeTypeMap.getFileExtensionFromUrl(url), + ) + } + publishState( + currentState.copy( + totalChapters = chapters.size, + currentChapter = chapterIndex, + totalPages = pages.size, + currentPage = pageIndex, + isIndeterminate = false, + eta = timeLeftEstimator.getEta(), + ), + ) + + if (settings.isDownloadsSlowdownEnabled) { + delay(SLOWDOWN_DELAY) + } + } + if (output.flushChapter(chapter)) { + runCatchingCancellable { + 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() + output.finish() + val localManga = LocalMangaInput.of(output.rootFile).getManga() + localStorageChanges.emit(localManga) + publishState(currentState.copy(localManga = localManga, eta = -1L)) + } catch (e: Exception) { + if (e !is CancellationException) { + publishState(currentState.copy(error = e.getDisplayMessage(applicationContext.resources))) + } + throw e + } finally { + withContext(NonCancellable) { + applicationContext.unregisterReceiver(pausingReceiver) + output?.closeQuietly() + output?.cleanup() + File(destination, tempFileName).deleteAwait() + } + } + } + } + + private suspend fun runFailsafe( + pausingHandle: PausingHandle, + block: suspend () -> R, + ): R { + if (pausingHandle.isPaused) { + publishState(currentState.copy(isPaused = true, eta = -1L)) + pausingHandle.awaitResumed() + publishState(currentState.copy(isPaused = false)) + } + var countDown = MAX_FAILSAFE_ATTEMPTS + failsafe@ while (true) { + try { + return block() + } catch (e: IOException) { + if (countDown <= 0) { + publishState( + currentState.copy( + isPaused = true, + error = e.getDisplayMessage(applicationContext.resources), + eta = -1L, + ), + ) + countDown = MAX_FAILSAFE_ATTEMPTS + pausingHandle.pause() + pausingHandle.awaitResumed() + publishState(currentState.copy(isPaused = false, error = null)) + } else { + countDown-- + delay(DOWNLOAD_ERROR_DELAY) + } + } + } + } + + private suspend fun downloadFile( + url: String, + destination: File, + tempFileName: String, + source: MangaSource, + ): File { + val request = Request.Builder() + .url(url) + .tag(MangaSource::class.java, source) + .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") + .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) + .get() + .build() + val call = okHttp.newCall(request) + val file = File(destination, tempFileName) + val response = call.clone().await() + checkNotNull(response.body).use { body -> + file.sink(append = false).buffer().use { + it.writeAllCancellable(body.source()) + } + } + return file + } + + private suspend fun publishState(state: DownloadState) { + val previousState = currentState + lastPublishedState = state + if (previousState.isParticularProgress && state.isParticularProgress) { + timeLeftEstimator.tick(state.progress, state.max) + } else { + timeLeftEstimator.emptyTick() + notificationThrottler.reset() + } + val notification = notificationFactory.create(state) + if (state.isFinalState) { + notificationManager.notify(id.toString(), id.hashCode(), notification) + } else if (notificationThrottler.throttle()) { + notificationManager.notify(id.hashCode(), notification) + } else { + return + } + setProgress(state.toWorkData()) + } + + private suspend fun getDoneChapters(): LongArray { + val work = WorkManagerHelper(WorkManager.getInstance(applicationContext)).getWorkInfoById(id) + ?: return LongArray(0) + return DownloadState.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() + } finally { + localMangaRepository.unlockManga(manga.id) + } + + @Reusable + class Scheduler @Inject constructor( + @ApplicationContext private val context: Context, + private val dataRepository: MangaDataRepository, + private val settings: AppSettings, + ) { + + private val workManager: WorkManager + inline get() = WorkManager.getInstance(context) + + suspend fun schedule(manga: Manga, chaptersIds: Collection?) { + dataRepository.storeManga(manga) + val data = Data.Builder() + .putLong(MANGA_ID, manga.id) + if (!chaptersIds.isNullOrEmpty()) { + data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray()) + } + scheduleImpl(listOf(data.build())) + } + + suspend fun schedule(manga: Collection) { + val data = manga.map { + dataRepository.storeManga(it) + Data.Builder() + .putLong(MANGA_ID, it.id) + .build() + } + scheduleImpl(data) + } + + fun observeWorks(): Flow> = workManager + .getWorkInfosByTagLiveData(TAG) + .asFlow() + + suspend fun cancel(id: UUID) { + workManager.cancelWorkById(id).await() + } + + suspend fun cancelAll() { + workManager.cancelAllWorkByTag(TAG).await() + } + + fun pause(id: UUID) { + val intent = PausingReceiver.getPauseIntent(id) + context.sendBroadcast(intent) + } + + fun resume(id: UUID) { + val intent = PausingReceiver.getResumeIntent(id) + context.sendBroadcast(intent) + } + + suspend fun delete(id: UUID) { + WorkManagerHelper(workManager).deleteWork(id) + } + + suspend fun removeCompleted() { + val helper = WorkManagerHelper(workManager) + val finishedWorks = helper.getFinishedWorkInfosByTag(TAG) + helper.deleteWorks(finishedWorks.mapToSet { it.id }) + } + + suspend fun updateConstraints() { + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + .setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + .build() + val helper = WorkManagerHelper(workManager) + val works = helper.getWorkInfosByTag(TAG) + for (work in works) { + if (work.state.isFinished) { + continue + } + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setId(work.id) + .build() + helper.updateWork(request) + } + } + + private suspend fun scheduleImpl(data: Collection) { + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + .setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + .build() + val requests = data.map { inputData -> + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag(TAG) + .keepResultsForAtLeast(7, TimeUnit.DAYS) + .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS) + .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + } + workManager.enqueue(requests).await() + } + } + + private companion object { + + const val MAX_FAILSAFE_ATTEMPTS = 2 + const val DOWNLOAD_ERROR_DELAY = 500L + const val SLOWDOWN_DELAY = 100L + const val MANGA_ID = "manga_id" + const val CHAPTERS_IDS = "chapters" + const val TAG = "download" + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt similarity index 90% rename from app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt rename to app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt index 499f88f34..6a660c2ab 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.download.ui.service +package org.koitharu.kotatsu.download.ui.worker import androidx.annotation.AnyThread import kotlinx.coroutines.flow.MutableStateFlow @@ -27,4 +27,4 @@ class PausingHandle { fun resume() { paused.value = false } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt new file mode 100644 index 000000000..bad07e7df --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt @@ -0,0 +1,65 @@ +package org.koitharu.kotatsu.download.ui.worker + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.PatternMatcher +import androidx.core.app.PendingIntentCompat +import org.koitharu.kotatsu.utils.ext.toUUIDOrNull +import java.util.UUID + +class PausingReceiver( + private val id: UUID, + private val pausingHandle: PausingHandle, +) : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent?) { + val uuid = intent?.getStringExtra(EXTRA_UUID)?.toUUIDOrNull() + assert(uuid == id) + when (intent?.action) { + ACTION_RESUME -> pausingHandle.resume() + ACTION_PAUSE -> pausingHandle.pause() + } + } + + companion object { + + private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE" + private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME" + private const val EXTRA_UUID = "uuid" + private const val SCHEME = "workuid" + + fun createIntentFilter(id: UUID) = IntentFilter().apply { + addAction(ACTION_PAUSE) + addAction(ACTION_RESUME) + addDataScheme(SCHEME) + addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB) + } + + fun getPauseIntent(id: UUID) = Intent(ACTION_PAUSE) + .setData(Uri.parse("$SCHEME://$id")) + .putExtra(EXTRA_UUID, id.toString()) + + fun getResumeIntent(id: UUID) = Intent(ACTION_RESUME) + .setData(Uri.parse("$SCHEME://$id")) + .putExtra(EXTRA_UUID, id.toString()) + + fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( + context, + 0, + getPauseIntent(id), + 0, + false, + ) + + fun createResumePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( + context, + 0, + getResumeIntent(id), + 0, + false, + ) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index d540d1c17..b6007cd6e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -13,6 +13,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID @@ -37,7 +38,8 @@ class FavouritesListViewModel @Inject constructor( private val historyRepository: HistoryRepository, private val settings: AppSettings, private val tagHighlighter: MangaTagHighlighter, -) : MangaListViewModel(settings), ListExtraProvider { + downloadScheduler: DownloadWorker.Scheduler, +) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider { val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index ef8f0f720..a3d5663ff 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.history.domain.PROGRESS_NONE @@ -41,7 +42,8 @@ class HistoryListViewModel @Inject constructor( private val settings: AppSettings, private val trackingRepository: TrackingRepository, private val tagHighlighter: MangaTagHighlighter, -) : MangaListViewModel(settings) { + downloadScheduler: DownloadWorker.Scheduler, +) : MangaListViewModel(settings, downloadScheduler) { val isGroupingEnabled = MutableLiveData() diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 0cbb8cff7..2aad241b3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -34,7 +34,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID @@ -125,6 +125,7 @@ abstract class MangaListFragment : viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) + viewModel.onDownloadStarted.observe(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView)) } override fun onDestroyView() { @@ -299,7 +300,7 @@ abstract class MangaListFragment : } R.id.action_save -> { - DownloadService.confirmAndStart(binding.recyclerView, selectedItems) + viewModel.download(selectedItems) mode.finish() true } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index 03ec95536..6ff381fe4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -11,13 +11,16 @@ import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsLiveData +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.asFlowLiveData abstract class MangaListViewModel( private val settings: AppSettings, + private val downloadScheduler: DownloadWorker.Scheduler, ) : BaseViewModel() { abstract val content: LiveData> @@ -30,10 +33,18 @@ abstract class MangaListViewModel( key = AppSettings.KEY_GRID_SIZE, valueProducer = { gridSize / 100f }, ) + val onDownloadStarted = SingleLiveEvent() open fun onUpdateFilter(tags: Set) = Unit abstract fun onRefresh() abstract fun onRetry() + + fun download(items: Set) { + launchJob(Dispatchers.Default) { + downloadScheduler.schedule(items) + onDownloadStarted.emitCall(Unit) + } + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt index 1de0e9516..255bfdda6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -6,17 +6,19 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext +import okio.Source +import okio.buffer +import okio.sink import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.utils.FileSize -import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.takeIfReadable import org.koitharu.kotatsu.utils.ext.takeIfWriteable +import org.koitharu.kotatsu.utils.ext.writeAllCancellable import java.io.File -import java.io.InputStream import javax.inject.Inject import javax.inject.Singleton @@ -49,11 +51,11 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { } } - suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) { + suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) { val file = File(cacheDir.get().parentFile, url.longHashCode().toString()) try { - file.outputStream().use { out -> - inputStream.copyToSuspending(out) + file.sink(append = false).buffer().use { + it.writeAllCancellable(source) } lruCache.get().put(url, file) } finally { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt index 7b170eb19..70307defa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt @@ -7,16 +7,19 @@ import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import okio.buffer +import okio.sink +import okio.source import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.input.LocalMangaInput -import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.resolveName +import org.koitharu.kotatsu.utils.ext.writeAllCancellable import java.io.File import java.io.IOException import javax.inject.Inject @@ -30,17 +33,17 @@ class SingleMangaImporter @Inject constructor( private val contentResolver = context.contentResolver - suspend fun import(uri: Uri, progressState: MutableStateFlow?): LocalManga { + suspend fun import(uri: Uri): LocalManga { val result = if (isDirectory(uri)) { - importDirectory(uri, progressState) + importDirectory(uri) } else { - importFile(uri, progressState) + importFile(uri) } localStorageChanges.emit(result) return result } - private suspend fun importFile(uri: Uri, progressState: MutableStateFlow?): LocalManga { + private suspend fun importFile(uri: Uri): LocalManga = withContext(Dispatchers.IO) { val contentResolver = storageManager.contentResolver val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri") if (!CbzFilter.isFileSupported(name)) { @@ -50,14 +53,14 @@ class SingleMangaImporter @Inject constructor( runInterruptible { contentResolver.openInputStream(uri) }?.use { source -> - dest.outputStream().use { output -> - source.copyToSuspending(output, progressState = progressState) + dest.sink().buffer().use { output -> + output.writeAllCancellable(source.source()) } } ?: throw IOException("Cannot open input stream: $uri") - return LocalMangaInput.of(dest).getManga() + LocalMangaInput.of(dest).getManga() } - private suspend fun importDirectory(uri: Uri, progressState: MutableStateFlow?): LocalManga { + private suspend fun importDirectory(uri: Uri): LocalManga { val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) { "Provided uri $uri is not a tree" } @@ -80,9 +83,9 @@ class SingleMangaImporter @Inject constructor( docFile.copyTo(subDir) } } else { - inputStream().use { input -> - File(destDir, requireName()).outputStream().use { output -> - input.copyToSuspending(output) + inputStream().source().use { input -> + File(destDir, requireName()).sink().buffer().use { output -> + output.writeAllCancellable(input) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt index 1d008d6b0..eee8038df 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/ImportWorker.kt @@ -48,7 +48,7 @@ class ImportWorker @AssistedInject constructor( val uri = inputData.getString(DATA_URI)?.toUriOrNull() ?: return Result.failure() setForeground(getForegroundInfo()) val result = runCatchingCancellable { - importer.import(uri, null).manga + importer.import(uri).manga } val notification = buildNotification(result) notificationManager.notify(uri.hashCode(), notification) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 575a566ec..d8c7b4967 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -17,6 +17,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.list.domain.ListExtraProvider @@ -48,7 +49,8 @@ class LocalListViewModel @Inject constructor( private val settings: AppSettings, private val tagHighlighter: MangaTagHighlighter, @LocalStorageChanges private val localStorageChanges: SharedFlow, -) : MangaListViewModel(settings), ListExtraProvider { + downloadScheduler: DownloadWorker.Scheduler, +) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider { val onMangaRemoved = SingleLiveEvent() val sortOrder = MutableLiveData(settings.localListOrder) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt index c1403fd0d..35546a83a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient import okhttp3.Request +import okio.source import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository @@ -186,7 +187,7 @@ class PageLoader @Inject constructor( val entry = zip.getEntry(uri.fragment) zip.getInputStream(entry) }.use { - cache.put(pageUrl, it) + cache.put(pageUrl, it.source()) } } } else { @@ -204,8 +205,8 @@ class PageLoader @Inject constructor( val body = checkNotNull(response.body) { "Null response" } - body.withProgress(progress).byteStream().use { - cache.put(pageUrl, it) + body.withProgress(progress).use { + cache.put(pageUrl, it.source()) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index 4fdeeab85..9f89886e9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -12,11 +12,14 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrl import okio.IOException +import okio.buffer +import okio.sink +import okio.source import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.utils.ext.copyToSuspending +import org.koitharu.kotatsu.utils.ext.writeAllCancellable import java.io.File import javax.inject.Inject import kotlin.coroutines.Continuation @@ -49,10 +52,10 @@ class PageSaveHelper @Inject constructor( } } runInterruptible(Dispatchers.IO) { - contentResolver.openOutputStream(destination) + contentResolver.openOutputStream(destination)?.sink()?.buffer() }?.use { output -> - pageFile.inputStream().use { input -> - input.copyToSuspending(output) + pageFile.source().use { input -> + output.writeAllCancellable(input) } } ?: throw IOException("Output stream is null") return destination diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 3c4b6677d..0f2f18880 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.filter.FilterCoordinator import org.koitharu.kotatsu.list.ui.filter.FilterItem @@ -53,7 +54,8 @@ class RemoteListViewModel @Inject constructor( settings: AppSettings, dataRepository: MangaDataRepository, private val tagHighlighter: MangaTagHighlighter, -) : MangaListViewModel(settings), OnFilterChangedListener { + downloadScheduler: DownloadWorker.Scheduler, +) : MangaListViewModel(settings, downloadScheduler), OnFilterChangedListener { val source = savedStateHandle.require(RemoteListFragment.ARG_SOURCE) private val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt index 08e6db9c3..8ce2d3d90 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt @@ -12,6 +12,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel @@ -31,7 +32,8 @@ class SearchViewModel @Inject constructor( repositoryFactory: MangaRepository.Factory, settings: AppSettings, private val tagHighlighter: MangaTagHighlighter, -) : MangaListViewModel(settings) { + downloadScheduler: DownloadWorker.Scheduler, +) : MangaListViewModel(settings, downloadScheduler) { private val query = savedStateHandle.require(SearchFragment.ARG_QUERY) private val repository = repositoryFactory.create(savedStateHandle.require(SearchFragment.ARG_SOURCE)) diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt index e16fd5591..96ab9c1b2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt @@ -17,10 +17,11 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.list.ListSelectionController import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.list.ui.ItemSizeResolver import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration @@ -89,6 +90,8 @@ class MultiSearchActivity : viewModel.query.observe(this) { title = it } viewModel.list.observe(this) { adapter.items = it } + viewModel.onError.observe(this, SnackbarErrorObserver(binding.recyclerView, null)) + viewModel.onDownloadStarted.observe(this, DownloadStartedObserver(binding.recyclerView)) } override fun onWindowInsetsChanged(insets: Insets) { @@ -162,7 +165,7 @@ class MultiSearchActivity : } R.id.action_save -> { - DownloadService.confirmAndStart(binding.recyclerView, collectSelectedItems()) + viewModel.download(collectSelectedItems()) mode.finish() true } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt index d2dc9c5c9..72e6f777d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt @@ -20,6 +20,7 @@ import org.koitharu.kotatsu.core.exceptions.CompositeException import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter @@ -27,6 +28,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.ext.emitValue import org.koitharu.kotatsu.utils.ext.printStackTraceDebug @@ -41,12 +43,14 @@ class MultiSearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, + private val downloadScheduler: DownloadWorker.Scheduler, ) : BaseViewModel() { private var searchJob: Job? = null private val listData = MutableStateFlow>(emptyList()) private val loadingData = MutableStateFlow(false) private var listError = MutableStateFlow(null) + val onDownloadStarted = SingleLiveEvent() val query = MutableLiveData(savedStateHandle.get(MultiSearchActivity.EXTRA_QUERY).orEmpty()) val list: LiveData> = combine( @@ -109,6 +113,13 @@ class MultiSearchViewModel @Inject constructor( } } + fun download(items: Set) { + launchJob(Dispatchers.Default) { + downloadScheduler.schedule(items) + onDownloadStarted.emitCall(Unit) + } + } + private suspend fun searchImpl(q: String) = coroutineScope { val sources = settings.getMangaSources(includeHidden = false) val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt index 872caac79..34523393b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt @@ -7,17 +7,20 @@ import androidx.preference.ListPreference import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.utils.SliderPreference import org.koitharu.kotatsu.utils.ext.getStorageName +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import java.io.File @@ -35,16 +38,12 @@ class ContentSettingsFragment : @Inject lateinit var contentCache: ContentCache + @Inject + lateinit var downloadsScheduler: DownloadWorker.Scheduler + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_content) findPreference(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled - findPreference(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run { - summary = value.toString() - setOnPreferenceChangeListener { preference, newValue -> - preference.summary = newValue.toString() - true - } - } findPreference(AppSettings.KEY_DOH)?.run { entryValues = arrayOf( DoHProvider.NONE, @@ -87,6 +86,10 @@ class ContentSettingsFragment : bindRemoteSourcesSummary() } + AppSettings.KEY_DOWNLOADS_WIFI -> { + updateDownloadsConstraints() + } + AppSettings.KEY_SSL_BYPASS -> { Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show() } @@ -126,4 +129,20 @@ class ContentSettingsFragment : summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total) } } + + private fun updateDownloadsConstraints() { + val preference = findPreference(AppSettings.KEY_DOWNLOADS_WIFI) + viewLifecycleScope.launch { + try { + preference?.isEnabled = false + withContext(Dispatchers.Default) { + downloadsScheduler.updateConstraints() + } + } catch (e: Exception) { + e.printStackTraceDebug() + } finally { + preference?.isEnabled = true + } + } + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt index 3a5aa76a0..32449f70a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt @@ -16,7 +16,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.databinding.FragmentToolsBinding -import org.koitharu.kotatsu.download.ui.DownloadsActivity +import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.about.AppUpdateDialog diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt index deeaf2c5e..b07f64bab 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfFragment.kt @@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.FragmentShelfBinding import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.favourites.ui.FavouritesActivity import org.koitharu.kotatsu.history.ui.HistoryActivity import org.koitharu.kotatsu.list.ui.ItemSizeResolver @@ -84,6 +85,7 @@ class ShelfFragment : viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) + viewModel.onDownloadStarted.observe(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView)) } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfSelectionCallback.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfSelectionCallback.kt index 7e4919013..1ee3f798c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfSelectionCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfSelectionCallback.kt @@ -10,7 +10,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration -import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.parsers.model.Manga @@ -68,7 +67,7 @@ class ShelfSelectionCallback( } R.id.action_save -> { - DownloadService.confirmAndStart(recyclerView, collectSelectedItems(controller)) + viewModel.download(collectSelectedItems(controller)) mode.finish() true } diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt index d0ad7dd6b..1da0e3f87 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt @@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.MangaWithHistory @@ -46,11 +47,13 @@ class ShelfViewModel @Inject constructor( private val favouritesRepository: FavouritesRepository, private val trackingRepository: TrackingRepository, private val settings: AppSettings, + private val downloadScheduler: DownloadWorker.Scheduler, syncController: SyncController, networkState: NetworkState, ) : BaseViewModel(), ListExtraProvider { val onActionDone = SingleLiveEvent() + val onDownloadStarted = SingleLiveEvent() val content: LiveData> = combine( settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections }, @@ -144,6 +147,13 @@ class ShelfViewModel @Inject constructor( return result } + fun download(items: Set) { + launchJob(Dispatchers.Default) { + downloadScheduler.schedule(items) + onDownloadStarted.emitCall(Unit) + } + } + private suspend fun mapList( content: ShelfContent, isTrackerEnabled: Boolean, diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt index 1741936f2..bbb548e77 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.LoadingState @@ -24,7 +25,8 @@ class SuggestionsViewModel @Inject constructor( repository: SuggestionRepository, settings: AppSettings, private val tagHighlighter: MangaTagHighlighter, -) : MangaListViewModel(settings) { + downloadScheduler: DownloadWorker.Scheduler, +) : MangaListViewModel(settings, downloadScheduler) { override val content = combine( repository.observeAll(), diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt index dcdac1c83..1e8c7d745 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt @@ -22,12 +22,12 @@ import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_MANGA import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS import org.koitharu.kotatsu.core.db.TABLE_TAGS +import org.koitharu.kotatsu.core.network.GZipInterceptor import org.koitharu.kotatsu.parsers.util.json.mapJSONTo import org.koitharu.kotatsu.sync.data.SyncAuthApi import org.koitharu.kotatsu.sync.data.SyncAuthenticator import org.koitharu.kotatsu.sync.data.SyncInterceptor import org.koitharu.kotatsu.sync.data.SyncSettings -import org.koitharu.kotatsu.utils.GZipInterceptor import org.koitharu.kotatsu.utils.ext.parseJsonOrNull import org.koitharu.kotatsu.utils.ext.toContentValues import org.koitharu.kotatsu.utils.ext.toJson diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt index 1ebe7b8cd..bebfb7a58 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt @@ -10,6 +10,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.list.ui.MangaListViewModel @@ -32,7 +33,8 @@ class UpdatesViewModel @Inject constructor( private val settings: AppSettings, private val historyRepository: HistoryRepository, private val tagHighlighter: MangaTagHighlighter, -) : MangaListViewModel(settings) { + downloadScheduler: DownloadWorker.Scheduler, +) : MangaListViewModel(settings, downloadScheduler) { override val content = combine( repository.observeUpdatedManga(), diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/CancellableSource.kt b/app/src/main/java/org/koitharu/kotatsu/utils/CancellableSource.kt new file mode 100644 index 000000000..9830d86b6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/CancellableSource.kt @@ -0,0 +1,18 @@ +package org.koitharu.kotatsu.utils + +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import okio.Buffer +import okio.ForwardingSource +import okio.Source + +class CancellableSource( + private val job: Job?, + delegate: Source, +) : ForwardingSource(delegate) { + + override fun read(sink: Buffer, byteCount: Long): Long { + job?.ensureActive() + return super.read(sink, byteCount) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/GZipInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/utils/GZipInterceptor.kt deleted file mode 100644 index 5da93ae8a..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/GZipInterceptor.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.utils - -import okhttp3.Interceptor -import okhttp3.Response -import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING - -class GZipInterceptor : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - val newRequest = chain.request().newBuilder() - newRequest.addHeader(CONTENT_ENCODING, "gzip") - return chain.proceed(newRequest.build()) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/Throttler.kt b/app/src/main/java/org/koitharu/kotatsu/utils/Throttler.kt new file mode 100644 index 000000000..b026cf15e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/Throttler.kt @@ -0,0 +1,24 @@ +package org.koitharu.kotatsu.utils + +import android.os.SystemClock + +class Throttler( + private val timeoutMs: Long, +) { + + private var lastTick = 0L + + fun throttle(): Boolean { + val now = SystemClock.elapsedRealtime() + return if (lastTick + timeoutMs <= now) { + lastTick = now + true + } else { + false + } + } + + fun reset() { + lastTick = 0L + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt new file mode 100644 index 000000000..0b768d3f5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt @@ -0,0 +1,67 @@ +package org.koitharu.kotatsu.utils + +import android.annotation.SuppressLint +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import androidx.work.WorkRequest +import androidx.work.await +import androidx.work.impl.WorkManagerImpl +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +@SuppressLint("RestrictedApi") +class WorkManagerHelper( + workManager: WorkManager, +) { + + private val workManagerImpl = workManager as WorkManagerImpl + + suspend fun deleteWork(id: UUID) = suspendCoroutine { cont -> + workManagerImpl.workTaskExecutor.executeOnTaskThread { + try { + workManagerImpl.workDatabase.workSpecDao().delete(id.toString()) + cont.resume(Unit) + } catch (e: Exception) { + cont.resumeWithException(e) + } + } + } + + suspend fun deleteWorks(ids: Collection) = suspendCoroutine { cont -> + workManagerImpl.workTaskExecutor.executeOnTaskThread { + try { + val db = workManagerImpl.workDatabase + db.runInTransaction { + for (id in ids) { + db.workSpecDao().delete(id.toString()) + } + } + cont.resume(Unit) + } catch (e: Exception) { + cont.resumeWithException(e) + } + } + } + + suspend fun getWorkInfosByTag(tag: String): List { + return workManagerImpl.getWorkInfosByTag(tag).await() + } + + suspend fun getFinishedWorkInfosByTag(tag: String): List { + val query = WorkQuery.Builder.fromTags(listOf(tag)) + .addStates(listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.CANCELLED, WorkInfo.State.FAILED)) + .build() + 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() + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt index f8a91c25e..8837856bc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt @@ -37,11 +37,20 @@ fun ImageView.disposeImageRequest() { fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) -fun ImageResult.requireBitmap() = when (this) { - is SuccessResult -> drawable.toBitmap() +fun ImageResult.getDrawableOrThrow() = when (this) { + is SuccessResult -> drawable is ErrorResult -> throw throwable } +@Deprecated( + "", + ReplaceWith( + "getDrawableOrThrow().toBitmap()", + "androidx.core.graphics.drawable.toBitmap", + ), +) +fun ImageResult.requireBitmap() = getDrawableOrThrow().toBitmap() + fun ImageResult.toBitmapOrNull() = when (this) { is SuccessResult -> try { drawable.toBitmap() diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt index 1b05eddc3..82a3780ce 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt @@ -3,37 +3,23 @@ package org.koitharu.kotatsu.utils.ext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import okhttp3.ResponseBody +import okio.BufferedSink +import okio.Source +import org.koitharu.kotatsu.utils.CancellableSource import org.koitharu.kotatsu.utils.progress.ProgressResponseBody -import java.io.InputStream -import java.io.OutputStream -suspend fun InputStream.copyToSuspending( - out: OutputStream, - bufferSize: Int = DEFAULT_BUFFER_SIZE, - progressState: MutableStateFlow? = null, -): Long = withContext(Dispatchers.IO) { +fun ResponseBody.withProgress(progressState: MutableStateFlow): ResponseBody { + return ProgressResponseBody(this, progressState) +} + +suspend fun Source.cancellable(): Source { val job = currentCoroutineContext()[Job] - val total = available() - var bytesCopied: Long = 0 - val buffer = ByteArray(bufferSize) - var bytes = read(buffer) - while (bytes >= 0) { - out.write(buffer, 0, bytes) - bytesCopied += bytes - job?.ensureActive() - bytes = read(buffer) - job?.ensureActive() - if (progressState != null && total > 0) { - progressState.value = bytesCopied / total.toFloat() - } - } - bytesCopied + return CancellableSource(job, this) } -fun ResponseBody.withProgress(progressState: MutableStateFlow): ResponseBody { - return ProgressResponseBody(this, progressState) +suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) { + writeAll(source.cancellable()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt index badf5ae7c..a5054b5e0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.utils.ext +import java.util.UUID + inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String { return if (this.isNullOrEmpty()) defaultValue() else this } @@ -11,4 +13,11 @@ fun String.longHashCode(): Long { h = 31 * h + this[i].code } return h -} \ No newline at end of file +} + +fun String.toUUIDOrNull(): UUID? = try { + UUID.fromString(this) +} catch (e: IllegalArgumentException) { + e.printStackTraceDebug() + null +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/PausingProgressJob.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/PausingProgressJob.kt index e53806080..eba6501d9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/PausingProgressJob.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/PausingProgressJob.kt @@ -3,7 +3,7 @@ package org.koitharu.kotatsu.utils.progress import androidx.annotation.AnyThread import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow -import org.koitharu.kotatsu.download.ui.service.PausingHandle +import org.koitharu.kotatsu.download.ui.worker.PausingHandle class PausingProgressJob

( job: Job, @@ -23,4 +23,4 @@ class PausingProgressJob

( @AnyThread fun resume() = pausingHandle.resume() -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt index f998a5119..b454535f7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt @@ -42,9 +42,14 @@ class TimeLeftEstimator { return if (eta < tooLargeTime) eta else NO_TIME } + fun getEta(): Long { + val etl = getEstimatedTimeLeft() + return if (etl == NO_TIME) NO_TIME else System.currentTimeMillis() + etl + } + private class Tick( - val value: Int, - val total: Int, - val time: Long, + @JvmField val value: Int, + @JvmField val total: Int, + @JvmField val time: Long, ) -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml new file mode 100644 index 000000000..55d7a60db --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_stat_paused.png b/app/src/main/res/drawable-hdpi/ic_stat_paused.png new file mode 100644 index 000000000..e42a3d68c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_paused.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_paused.png b/app/src/main/res/drawable-mdpi/ic_stat_paused.png new file mode 100644 index 000000000..979fc6fc3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_paused.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_paused.png b/app/src/main/res/drawable-xhdpi/ic_stat_paused.png new file mode 100644 index 000000000..834a83c8d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_paused.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_paused.png b/app/src/main/res/drawable-xxhdpi/ic_stat_paused.png new file mode 100644 index 000000000..883a124a3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_paused.png differ diff --git a/app/src/main/res/drawable/ic_action_pause.xml b/app/src/main/res/drawable/ic_action_pause.xml new file mode 100644 index 000000000..8e5ee878f --- /dev/null +++ b/app/src/main/res/drawable/ic_action_pause.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_action_resume.xml b/app/src/main/res/drawable/ic_action_resume.xml new file mode 100644 index 000000000..876e2efff --- /dev/null +++ b/app/src/main/res/drawable/ic_action_resume.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout/activity_downloads.xml b/app/src/main/res/layout/activity_downloads.xml index 64c63cffe..4c1397a85 100644 --- a/app/src/main/res/layout/activity_downloads.xml +++ b/app/src/main/res/layout/activity_downloads.xml @@ -41,15 +41,4 @@ app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" tools:listitem="@layout/item_download" /> - - diff --git a/app/src/main/res/layout/item_download.xml b/app/src/main/res/layout/item_download.xml index c8d6176b0..21f6b6466 100644 --- a/app/src/main/res/layout/item_download.xml +++ b/app/src/main/res/layout/item_download.xml @@ -3,26 +3,25 @@ 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" + style="?materialCardViewFilledStyle" android:layout_width="match_parent" - android:layout_height="wrap_content" - app:cardCornerRadius="16dp"> + android:layout_height="wrap_content"> + android:paddingBottom="12dp"> + + @@ -87,39 +96,47 @@ android:ellipsize="end" android:maxLines="4" android:textAppearance="?attr/textAppearanceBodySmall" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@id/textView_percent" app:layout_constraintStart_toEndOf="@id/imageView_cover" app:layout_constraintTop_toBottomOf="@id/textView_status" tools:text="@tools:sample/lorem[3]" /> +