From f05bb20428ba5f5369030b9bf928eeed1e3bea2e Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 1 May 2023 15:38:40 +0300 Subject: [PATCH 01/76] Use WorkManager for downloads --- .../kotatsu/details/ui/ChaptersFragment.kt | 7 +- .../kotatsu/details/ui/DetailsActivity.kt | 3 +- .../kotatsu/details/ui/DetailsMenuProvider.kt | 7 +- .../kotatsu/details/ui/DetailsViewModel.kt | 13 + .../download/domain/DownloadManager.kt | 33 +- .../kotatsu/download/domain/DownloadState.kt | 87 +++-- .../kotatsu/download/domain/DownloadState2.kt | 48 +++ .../kotatsu/download/ui/DownloadsActivity.kt | 9 - .../download/ui/DownloadsConnection.kt | 76 ---- .../ui/service/DownloadNotification.kt | 356 ------------------ .../download/ui/service/DownloadService.kt | 262 ------------- .../ui/worker/DownloadNotificationFactory.kt | 220 +++++++++++ .../ui/worker/DownloadStartedObserver.kt | 25 ++ .../download/ui/worker/DownloadWorker.kt | 313 +++++++++++++++ .../kotatsu/download/ui/worker/Throttler.kt | 20 + .../ui/list/FavouritesListViewModel.kt | 4 +- .../history/ui/HistoryListViewModel.kt | 4 +- .../kotatsu/list/ui/MangaListFragment.kt | 5 +- .../kotatsu/list/ui/MangaListViewModel.kt | 11 + .../kotatsu/local/ui/LocalListViewModel.kt | 4 +- .../remotelist/ui/RemoteListViewModel.kt | 4 +- .../kotatsu/search/ui/SearchViewModel.kt | 4 +- .../search/ui/multi/MultiSearchActivity.kt | 7 +- .../search/ui/multi/MultiSearchViewModel.kt | 11 + .../kotatsu/shelf/ui/ShelfFragment.kt | 2 + .../shelf/ui/ShelfSelectionCallback.kt | 3 +- .../kotatsu/shelf/ui/ShelfViewModel.kt | 10 + .../suggestions/ui/SuggestionsViewModel.kt | 4 +- .../tracker/ui/updates/UpdatesViewModel.kt | 4 +- .../org/koitharu/kotatsu/utils/ext/CoilExt.kt | 13 +- 30 files changed, 778 insertions(+), 791 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsConnection.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/worker/Throttler.kt 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..e3b8a4318 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,6 @@ 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.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.reader.ui.ReaderActivity @@ -236,7 +235,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 index 3d5b3c46d..cca3912a2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -45,6 +45,7 @@ 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 java.util.UUID import javax.inject.Inject private const val MAX_FAILSAFE_ATTEMPTS = 2 @@ -76,10 +77,10 @@ class DownloadManager @Inject constructor( fun downloadManga( manga: Manga, chaptersIds: LongArray?, - startId: Int, + startId: UUID, ): PausingProgressJob { val stateFlow = MutableStateFlow( - DownloadState.Queued(startId = startId, manga = manga, cover = null), + DownloadState.Queued(uuid = startId, manga = manga), ) val pausingHandle = PausingHandle() val job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(stateFlow)) { @@ -88,7 +89,7 @@ class DownloadManager @Inject constructor( } 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) + stateFlow.value = DownloadState.Cancelled(startId, state.manga) } throw e } @@ -101,16 +102,15 @@ class DownloadManager @Inject constructor( chaptersIds: LongArray?, outState: MutableStateFlow, pausingHandle: PausingHandle, - startId: Int, + startId: UUID, ) { @Suppress("NAME_SHADOWING") var manga = manga val chaptersIdsSet = chaptersIds?.toMutableSet() - val cover = loadCover(manga) - outState.value = DownloadState.Queued(startId, manga, cover) + outState.value = DownloadState.Queued(startId, manga) withMangaLock(manga) { semaphore.withPermit { - outState.value = DownloadState.Preparing(startId, manga, null) + outState.value = DownloadState.Preparing(startId, manga) val destination = localMangaRepository.getOutputDir(manga) checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } val tempFileName = "${manga.id}_$startId.tmp" @@ -121,7 +121,7 @@ class DownloadManager @Inject constructor( ?: error("Cannot obtain remote manga instance") } val repo = mangaRepositoryFactory.create(manga.source) - outState.value = DownloadState.Preparing(startId, manga, cover) + outState.value = DownloadState.Preparing(startId, manga) val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga output = LocalMangaOutput.getOrCreate(destination, data) val coverUrl = data.largeCoverUrl ?: data.coverUrl @@ -156,13 +156,13 @@ class DownloadManager @Inject constructor( ) } outState.value = DownloadState.Progress( - startId = startId, + uuid = startId, manga = data, - cover = cover, totalChapters = chapters.size, currentChapter = chapterIndex, totalPages = pages.size, currentPage = pageIndex, + timeLeft = 0L, ) if (settings.isDownloadsSlowdownEnabled) { @@ -175,18 +175,18 @@ class DownloadManager @Inject constructor( }.onFailure(Throwable::printStackTraceDebug) } } - outState.value = DownloadState.PostProcessing(startId, data, cover) + outState.value = DownloadState.PostProcessing(startId, data) output.mergeWithExisting() output.finish() val localManga = LocalMangaInput.of(output.rootFile).getManga() localStorageChanges.emit(localManga) - outState.value = DownloadState.Done(startId, data, cover, localManga.manga) + outState.value = DownloadState.Done(startId, data, localManga.manga) } catch (e: CancellationException) { - outState.value = DownloadState.Cancelled(startId, manga, cover) + outState.value = DownloadState.Cancelled(startId, manga) throw e } catch (e: Throwable) { e.printStackTraceDebug() - outState.value = DownloadState.Error(startId, manga, cover, e, false) + outState.value = DownloadState.Error(startId, manga, e, false) } finally { withContext(NonCancellable) { output?.closeQuietly() @@ -210,7 +210,7 @@ class DownloadManager @Inject constructor( } catch (e: IOException) { if (countDown <= 0) { val state = outState.value - outState.value = DownloadState.Error(state.startId, state.manga, state.cover, e, true) + outState.value = DownloadState.Error(state.uuid, state.manga, e, true) countDown = MAX_FAILSAFE_ATTEMPTS pausingHandle.pause() pausingHandle.awaitResumed() @@ -249,9 +249,8 @@ class DownloadManager @Inject constructor( throwable.printStackTraceDebug() val prevValue = outState.value outState.value = DownloadState.Error( - startId = prevValue.startId, + uuid = prevValue.uuid, manga = prevValue.manga, - cover = prevValue.cover, error = throwable, canRetry = false, ) 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..0d5e1fa37 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,13 +1,25 @@ package org.koitharu.kotatsu.download.domain import android.graphics.drawable.Drawable +import androidx.work.Data import org.koitharu.kotatsu.parsers.model.Manga +import java.util.UUID sealed interface DownloadState { - val startId: Int + val uuid: UUID val manga: Manga - val cover: Drawable? + + @Deprecated("") + val cover: Drawable? get() = null + + @Deprecated("") + val startId: Int get() = uuid.hashCode() + + fun toWorkData(): Data = Data.Builder() + .putString(DATA_UUID, uuid.toString()) + .putLong(DATA_MANGA_ID, manga.id) + .build() override fun equals(other: Any?): Boolean @@ -17,9 +29,8 @@ sealed interface DownloadState { get() = this is Done || this is Cancelled || (this is Error && !canRetry) class Queued( - override val startId: Int, + override val uuid: UUID, override val manga: Manga, - override val cover: Drawable?, ) : DownloadState { override fun equals(other: Any?): Boolean { @@ -28,25 +39,22 @@ sealed interface DownloadState { other as Queued - if (startId != other.startId) return false + if (uuid != other.uuid) return false if (manga != other.manga) return false - if (cover != other.cover) return false return true } override fun hashCode(): Int { - var result = startId + var result = uuid.hashCode() result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) return result } } class Preparing( - override val startId: Int, + override val uuid: UUID, override val manga: Manga, - override val cover: Drawable?, ) : DownloadState { override fun equals(other: Any?): Boolean { @@ -55,29 +63,27 @@ sealed interface DownloadState { other as Preparing - if (startId != other.startId) return false + if (uuid != other.uuid) return false if (manga != other.manga) return false - if (cover != other.cover) return false return true } override fun hashCode(): Int { - var result = startId + var result = uuid.hashCode() result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) return result } } class Progress( - override val startId: Int, + override val uuid: UUID, override val manga: Manga, - override val cover: Drawable?, val totalChapters: Int, val currentChapter: Int, val totalPages: Int, val currentPage: Int, + val timeLeft: Long, ) : DownloadState { val max: Int = totalChapters * totalPages @@ -92,9 +98,8 @@ sealed interface DownloadState { other as Progress - if (startId != other.startId) return false + if (uuid != other.uuid) 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 @@ -104,9 +109,8 @@ sealed interface DownloadState { } override fun hashCode(): Int { - var result = startId + var result = uuid.hashCode() result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) result = 31 * result + totalChapters result = 31 * result + currentChapter result = 31 * result + totalPages @@ -116,9 +120,8 @@ sealed interface DownloadState { } class Done( - override val startId: Int, + override val uuid: UUID, override val manga: Manga, - override val cover: Drawable?, val localManga: Manga, ) : DownloadState { @@ -128,27 +131,25 @@ sealed interface DownloadState { other as Done - if (startId != other.startId) return false + if (uuid != other.uuid) 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 + var result = uuid.hashCode() 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 uuid: UUID, override val manga: Manga, - override val cover: Drawable?, + val error: Throwable, val canRetry: Boolean, ) : DownloadState { @@ -159,9 +160,8 @@ sealed interface DownloadState { other as Error - if (startId != other.startId) return false + if (uuid != other.uuid) 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 @@ -169,9 +169,8 @@ sealed interface DownloadState { } override fun hashCode(): Int { - var result = startId + var result = uuid.hashCode() result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) result = 31 * result + error.hashCode() result = 31 * result + canRetry.hashCode() return result @@ -179,9 +178,8 @@ sealed interface DownloadState { } class Cancelled( - override val startId: Int, + override val uuid: UUID, override val manga: Manga, - override val cover: Drawable?, ) : DownloadState { override fun equals(other: Any?): Boolean { @@ -190,25 +188,22 @@ sealed interface DownloadState { other as Cancelled - if (startId != other.startId) return false + if (uuid != other.uuid) return false if (manga != other.manga) return false - if (cover != other.cover) return false return true } override fun hashCode(): Int { - var result = startId + var result = uuid.hashCode() result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) return result } } class PostProcessing( - override val startId: Int, + override val uuid: UUID, override val manga: Manga, - override val cover: Drawable?, ) : DownloadState { override fun equals(other: Any?): Boolean { @@ -217,18 +212,22 @@ sealed interface DownloadState { other as PostProcessing - if (startId != other.startId) return false + if (uuid != other.uuid) return false if (manga != other.manga) return false - if (cover != other.cover) return false return true } override fun hashCode(): Int { - var result = startId + var result = uuid.hashCode() result = 31 * result + manga.hashCode() - result = 31 * result + (cover?.hashCode() ?: 0) return result } } + + companion object { + + private const val DATA_UUID = "id" + private const val DATA_MANGA_ID = "manga_id" + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt new file mode 100644 index 000000000..0aa537acc --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.download.domain + +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 +import java.util.UUID + +data class DownloadState2( + val id: UUID, + val manga: Manga, + val state: State, + val error: Throwable? = null, + val totalChapters: Int = 0, + val currentChapter: Int = 0, + val totalPages: Int = 0, + val currentPage: Int = 0, + val timeLeft: Long = -1L, + val localManga: LocalManga? = null, +) { + + val isTerminal: Boolean + get() = state == State.FAILED || state == State.CANCELLED || state == State.DONE + + val max: Int = totalChapters * totalPages + + val progress: Int = totalPages * currentChapter + currentPage + 1 + + val percent: Float = if (max > 0) progress.toFloat() / max else PROGRESS_NONE + + fun toWorkData() = Data.Builder() + .putString(DATA_UUID, id.toString()) + .putLong(DATA_MANGA_ID, manga.id) + .putString(DATA_STATE, state.name) + .build() + + enum class State { + + PREPARING, PROGRESS, PAUSED, FAILED, CANCELLED, DONE + } + + companion object { + + private const val DATA_UUID = "uuid" + private const val DATA_MANGA_ID = "manga_id" + private const val DATA_STATE = "state" + } +} 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 index 7b0872910..e95a20f27 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt @@ -4,7 +4,6 @@ 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 @@ -20,8 +19,6 @@ 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)) @@ -31,12 +28,6 @@ class DownloadsActivity : BaseActivity() { 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) { 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/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..b610d2427 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -0,0 +1,220 @@ +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.Reusable +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.DownloadState2 +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.format +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.search.ui.MangaListActivity +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.getDrawableOrThrow +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import java.util.UUID +import javax.inject.Inject +import com.google.android.material.R as materialR + +private const val CHANNEL_ID = "download" +private const val GROUP_ID = "downloads" + +@Reusable +class DownloadNotificationFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val coil: ImageLoader, +) { + + 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, + ) + + 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) + } + + suspend fun create(state: DownloadState2?): 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?.state) { + null -> Unit + DownloadState2.State.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 + } + + DownloadState2.State.DONE -> { + 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()) + builder.priority = NotificationCompat.PRIORITY_DEFAULT + } + + DownloadState2.State.FAILED -> { + val message = state.error?.getDisplayMessage(context.resources) + ?: context.getString(R.string.error_occurred) + 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(true) + builder.setOngoing(false) + builder.setCategory(NotificationCompat.CATEGORY_ERROR) + builder.setShowWhen(true) + builder.setWhen(System.currentTimeMillis()) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) + builder.priority = NotificationCompat.PRIORITY_DEFAULT + } + + DownloadState2.State.PREPARING -> { + builder.setProgress(1, 0, true) + builder.setContentText(context.getString(R.string.preparing_)) + builder.setStyle(null) + builder.setOngoing(true) + builder.addAction(createCancelAction(state.id)) + builder.priority = NotificationCompat.PRIORITY_DEFAULT + } + + DownloadState2.State.PROGRESS -> { + builder.setProgress(state.max, state.progress, false) + val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) + if (state.timeLeft > 0L) { + val eta = DateUtils.getRelativeTimeSpanString(state.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(createCancelAction(state.id)) + builder.priority = NotificationCompat.PRIORITY_DEFAULT + } + + DownloadState2.State.PAUSED -> TODO() + } + 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 fun createCancelAction(uuid: UUID) = NotificationCompat.Action( + materialR.drawable.material_ic_clear_black_24dp, + context.getString(android.R.string.cancel), + WorkManager.getInstance(context).createCancelPendingIntent(uuid), + ) + + 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) + } + } + } +} 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..4918d6e9f --- /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.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..140d1e54b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -0,0 +1,313 @@ +package org.koitharu.kotatsu.download.ui.worker + +import android.app.NotificationManager +import android.content.Context +import android.webkit.MimeTypeMap +import androidx.hilt.work.HiltWorker +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.Operation +import androidx.work.OutOfQuotaPolicy +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.MutableSharedFlow +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.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.DownloadState2 +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.printStackTraceDebug +import org.koitharu.kotatsu.utils.ext.runCatchingCancellable +import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator +import java.io.File +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, + private val notificationFactory: DownloadNotificationFactory, +) : CoroutineWorker(appContext, params) { + + private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + @Volatile + private lateinit var currentState: DownloadState2 + + private val timeLeftEstimator = TimeLeftEstimator() + private val notificationThrottler = Throttler(400) + + 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() } + currentState = DownloadState2(id, manga, DownloadState2.State.PREPARING) + val pausingHandle = PausingHandle() + downloadMangaImpl(chaptersIds, pausingHandle) + val outputData = currentState.toWorkData() + return when (currentState.state) { + DownloadState2.State.CANCELLED, + DownloadState2.State.DONE -> Result.success(outputData) + + DownloadState2.State.FAILED -> Result.failure(outputData) + else -> Result.retry() + } + } + + override suspend fun getForegroundInfo() = ForegroundInfo( + id.hashCode(), + notificationFactory.create(null), + ) + + private suspend fun downloadMangaImpl( + chaptersIds: LongArray?, + pausingHandle: PausingHandle, + ) { + var manga = currentState.manga + val chaptersIdsSet = chaptersIds?.toMutableSet() + withMangaLock(manga) { + 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 data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga + output = LocalMangaOutput.getOrCreate(destination, data) + val coverUrl = data.largeCoverUrl ?: data.coverUrl + 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(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( + state = DownloadState2.State.PROGRESS, + totalChapters = chapters.size, + currentChapter = chapterIndex, + totalPages = pages.size, + currentPage = pageIndex, + timeLeft = timeLeftEstimator.getEstimatedTimeLeft(), + ), + ) + + if (settings.isDownloadsSlowdownEnabled) { + delay(SLOWDOWN_DELAY) + } + } + if (output.flushChapter(chapter)) { + runCatchingCancellable { + localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga()) + }.onFailure(Throwable::printStackTraceDebug) + } + } + publishState(currentState.copy(state = DownloadState2.State.PROGRESS)) + output.mergeWithExisting() + output.finish() + val localManga = LocalMangaInput.of(output.rootFile).getManga() + localStorageChanges.emit(localManga) + publishState(currentState.copy(state = DownloadState2.State.DONE, localManga = localManga)) + } catch (e: CancellationException) { + publishState(currentState.copy(state = DownloadState2.State.CANCELLED)) + throw e + } catch (e: Throwable) { + e.printStackTraceDebug() + publishState(currentState.copy(state = DownloadState2.State.FAILED, error = e)) + } finally { + withContext(NonCancellable) { + output?.closeQuietly() + output?.cleanup() + File(destination, tempFileName).deleteAwait() + } + } + } + } + + private suspend fun runFailsafe( + pausingHandle: PausingHandle, + block: suspend () -> R, + ): R { + var countDown = MAX_FAILSAFE_ATTEMPTS + failsafe@ while (true) { + try { + return block() + } catch (e: IOException) { + if (countDown <= 0) { + publishState(currentState.copy(state = DownloadState2.State.PAUSED, error = e)) + countDown = MAX_FAILSAFE_ATTEMPTS + pausingHandle.pause() + pausingHandle.awaitResumed() + publishState(currentState.copy(state = DownloadState2.State.PROGRESS, 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) + .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 suspend fun publishState(state: DownloadState2) { + currentState = state + if (state.state == DownloadState2.State.PROGRESS && state.max > 0) { + timeLeftEstimator.tick(state.progress, state.max) + } else { + timeLeftEstimator.emptyTick() + notificationThrottler.reset() + } + val notification = notificationFactory.create(state) + if (state.isTerminal) { + notificationManager.notify(state.id.toString(), id.hashCode(), notification) + } else if (notificationThrottler.throttle()) { + notificationManager.notify(id.hashCode(), notification) + } + setProgress(state.toWorkData()) + } + + 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, + ) { + + 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())).await() + } + + suspend fun schedule(manga: Collection) { + val data = manga.map { + dataRepository.storeManga(it) + Data.Builder() + .putLong(MANGA_ID, it.id) + .build() + } + scheduleImpl(data).await() + } + + private fun scheduleImpl(data: Collection): Operation { + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val requests = data.map { inputData -> + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag(TAG) + .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + } + return WorkManager.getInstance(context).enqueue(requests) + } + } + + 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/worker/Throttler.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/Throttler.kt new file mode 100644 index 000000000..1e25674b1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/Throttler.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.download.ui.worker + +import android.os.SystemClock + +class Throttler( + private val timeoutMs: Long, +) { + + private var lastTick = 0L + + fun throttle(): Boolean { + val prevValue = lastTick + lastTick = SystemClock.elapsedRealtime() + return lastTick > prevValue + timeoutMs + } + + fun reset() { + lastTick = 0L + } +} 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/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/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/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/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/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() From 41ac50c76a57e54c1f9a26df7e8c35705427e255 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 5 May 2023 11:08:21 +0300 Subject: [PATCH 02/76] Manage download states --- app/build.gradle | 3 +- app/src/main/AndroidManifest.xml | 2 +- .../kotatsu/download/domain/DownloadState2.kt | 44 ++++-- .../kotatsu/download/ui/DownloadItemAD.kt | 140 ------------------ .../kotatsu/download/ui/DownloadsAdapter.kt | 46 ------ .../download/ui/list/DownloadItemAD.kt | 113 ++++++++++++++ .../download/ui/list/DownloadItemListener.kt | 14 ++ .../download/ui/list/DownloadItemModel.kt | 25 ++++ .../ui/{ => list}/DownloadsActivity.kt | 36 ++++- .../download/ui/list/DownloadsAdapter.kt | 61 ++++++++ .../download/ui/list/DownloadsViewModel.kt | 108 ++++++++++++++ .../download/ui/service/PausingHandle.kt | 2 +- .../ui/worker/DownloadNotificationFactory.kt | 113 ++++++++------ .../ui/worker/DownloadStartedObserver.kt | 2 +- .../download/ui/worker/DownloadWorker.kt | 94 ++++++++---- .../download/ui/worker/PausingReceiver.kt | 66 +++++++++ .../kotatsu/download/ui/worker/Throttler.kt | 10 +- .../kotatsu/settings/tools/ToolsFragment.kt | 2 +- .../koitharu/kotatsu/utils/ext/StringExt.kt | 11 +- .../utils/progress/TimeLeftEstimator.kt | 13 +- .../drawable-anydpi-v24/ic_stat_paused.xml | 15 ++ .../main/res/drawable-hdpi/ic_stat_paused.png | Bin 0 -> 166 bytes .../main/res/drawable-mdpi/ic_stat_paused.png | Bin 0 -> 131 bytes .../res/drawable-xhdpi/ic_stat_paused.png | Bin 0 -> 203 bytes .../res/drawable-xxhdpi/ic_stat_paused.png | Bin 0 -> 223 bytes app/src/main/res/drawable/ic_action_pause.xml | 11 ++ .../main/res/drawable/ic_action_resume.xml | 11 ++ .../main/res/layout/activity_downloads.xml | 11 -- app/src/main/res/layout/item_download.xml | 23 ++- app/src/main/res/values/strings.xml | 3 + 30 files changed, 670 insertions(+), 309 deletions(-) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt rename app/src/main/java/org/koitharu/kotatsu/download/ui/{ => list}/DownloadsActivity.kt (59%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt create mode 100644 app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml create mode 100644 app/src/main/res/drawable-hdpi/ic_stat_paused.png create mode 100644 app/src/main/res/drawable-mdpi/ic_stat_paused.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_stat_paused.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_stat_paused.png create mode 100644 app/src/main/res/drawable/ic_action_pause.xml create mode 100644 app/src/main/res/drawable/ic_action_resume.xml diff --git a/app/build.gradle b/app/build.gradle index 37a94ac4b..95ce28b69 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,11 +98,12 @@ 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.8.0' //noinspection LifecycleAnnotationProcessorWithJava8 kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1' + implementation 'androidx.work:work-runtime-ktx:2.8.1' + implementation 'com.google.guava:guava:31.1-android' implementation 'androidx.room:room-runtime:2.5.1' implementation 'androidx.room:room-ktx:2.5.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f70692fb5..41b72ab7b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -128,7 +128,7 @@ android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity" android:windowSoftInputMode="adjustResize" /> diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt index 0aa537acc..80269df26 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState2.kt @@ -4,45 +4,57 @@ 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 -import java.util.UUID +import java.util.Date data class DownloadState2( - val id: UUID, val manga: Manga, - val state: State, + val isIndeterminate: Boolean, + val isPaused: Boolean = false, val error: Throwable? = null, val totalChapters: Int = 0, val currentChapter: Int = 0, val totalPages: Int = 0, val currentPage: Int = 0, - val timeLeft: Long = -1L, + val eta: Long = -1L, val localManga: LocalManga? = null, + val timestamp: Long = System.currentTimeMillis(), ) { - val isTerminal: Boolean - get() = state == State.FAILED || state == State.CANCELLED || state == State.DONE - 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) + fun toWorkData() = Data.Builder() - .putString(DATA_UUID, id.toString()) .putLong(DATA_MANGA_ID, manga.id) - .putString(DATA_STATE, state.name) + .putInt(DATA_MAX, max) + .putInt(DATA_PROGRESS, progress) + .putLong(DATA_ETA, eta) + .putLong(DATA_TIMESTAMP, timestamp) + .putString(DATA_ERROR, error?.toString()) .build() - enum class State { - - PREPARING, PROGRESS, PAUSED, FAILED, CANCELLED, DONE - } - companion object { - private const val DATA_UUID = "uuid" private const val DATA_MANGA_ID = "manga_id" - private const val DATA_STATE = "state" + private const val DATA_MAX = "max" + private const val DATA_PROGRESS = "progress" + private const val DATA_ETA = "eta" + private const val DATA_TIMESTAMP = "timestamp" + private const val DATA_ERROR = "error" + + fun getMangaId(data: Data): Long = data.getLong(DATA_MANGA_ID, 0L) + + fun getMax(data: Data) = data.getInt(DATA_MAX, 0) + + fun getProgress(data: Data) = data.getInt(DATA_PROGRESS, 0) + + fun getEta(data: Data) = data.getLong(DATA_ETA, -1L) + + fun getTimestamp(data: Data) = Date(data.getLong(DATA_TIMESTAMP, 0L)) } } 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/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/list/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt new file mode 100644 index 000000000..a372ebee7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt @@ -0,0 +1,113 @@ +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.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.source + +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 = View.OnClickListener { v -> + 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) + } + } + binding.buttonCancel.setOnClickListener(clickListener) + binding.buttonPause.setOnClickListener(clickListener) + binding.buttonResume.setOnClickListener(clickListener) + itemView.setOnClickListener(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.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = true + binding.buttonResume.isVisible = false + } + + WorkInfo.State.RUNNING -> { + binding.textViewStatus.setText(R.string.manga_downloading_) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = true + binding.progressBar.max = item.max + binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty()) + binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1)) + binding.textViewPercent.isVisible = true + binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = true + binding.buttonResume.isVisible = false + } + + WorkInfo.State.SUCCEEDED -> { + 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 + } + + WorkInfo.State.FAILED -> { + binding.textViewStatus.setText(R.string.error_occurred) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.text = item.error?.getDisplayMessage(context.resources) + binding.textViewDetails.isVisible = true + binding.buttonCancel.isVisible = false + binding.buttonResume.isVisible = true + } + + WorkInfo.State.CANCELLED -> { + binding.textViewStatus.setText(R.string.canceled) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = false + binding.buttonResume.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..290af949a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt @@ -0,0 +1,14 @@ +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) + + fun onRetryClick(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..6f90fd0f9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.download.ui.list + +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 + +data class DownloadItemModel( + val id: UUID, + val workState: WorkInfo.State, + val manga: Manga, + val error: Throwable?, + val max: Int, + val progress: Int, + val eta: Long, + val createdAt: Date, +) : ListModel { + + val percent: Float + get() = if (max > 0) progress / max.toFloat() else 0f + + val hasEta: Boolean + get() = eta > 0L +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt similarity index 59% rename from app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt rename to app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt index e95a20f27..8ae920f04 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -1,8 +1,10 @@ -package org.koitharu.kotatsu.download.ui +package org.koitharu.kotatsu.download.ui.list import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.View +import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.core.view.updatePadding import coil.ImageLoader @@ -11,23 +13,31 @@ 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 org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.download.ui.worker.PausingReceiver import javax.inject.Inject @AndroidEntryPoint -class DownloadsActivity : BaseActivity() { +class DownloadsActivity : BaseActivity(), DownloadItemListener { @Inject lateinit var coil: ImageLoader + private val viewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) supportActionBar?.setDisplayHomeAsUpEnabled(true) - val adapter = DownloadsAdapter(this, coil) + val adapter = DownloadsAdapter(this, coil, this) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.adapter = adapter + + viewModel.items.observe(this) { + adapter.items = it + } } override fun onWindowInsetsChanged(insets: Insets) { @@ -42,6 +52,26 @@ class DownloadsActivity : BaseActivity() { ) } + override fun onItemClick(item: DownloadItemModel, view: View) { + startActivity(DetailsActivity.newIntent(view.context, item.manga)) + } + + 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 onRetryClick(item: DownloadItemModel) { + // TODO + } + 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..e133d90a5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt @@ -0,0 +1,61 @@ +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(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) + } + } + } +} 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..d904458f0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -0,0 +1,108 @@ +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.mapLatest +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.DownloadState2 +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.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() + + val items = workScheduler.observeWorks() + .mapLatest { list -> + list.mapList() + }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + + private suspend fun List.mapList(): List { + val destination = ArrayList((size * 1.4).toInt()) + var prevDate: DateTimeAgo? = null + for (item in this) { + val model = item.toUiModel() ?: continue + val date = timeAgo(model.createdAt) + if (prevDate != date) { + destination += date + } + prevDate = date + destination += model + } + if (destination.isEmpty()) { + destination.add( + EmptyState( + icon = R.drawable.ic_empty_common, + textPrimary = R.string.text_downloads_holder, + textSecondary = 0, + actionStringRes = 0, + ), + ) + } + return destination + } + + private suspend fun WorkInfo.toUiModel(): DownloadItemModel? { + val workData = if (progress != Data.EMPTY) progress else outputData + val mangaId = DownloadState2.getMangaId(workData) + if (mangaId == 0L) return null + val manga = mangaCache.getOrElse(mangaId) { + mangaDataRepository.findMangaById(mangaId)?.also { mangaCache[mangaId] = it } ?: return null + } + return DownloadItemModel( + id = id, + workState = state, + manga = manga, + error = null, + max = DownloadState2.getMax(workData), + progress = DownloadState2.getProgress(workData), + eta = DownloadState2.getEta(workData), + createdAt = DownloadState2.getTimestamp(workData), + ) + } + + fun cancel(id: UUID) { + launchJob(Dispatchers.Default) { + workScheduler.cancel(id) + } + } + + fun restart(id: UUID) { + // TODO + } + + 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) + } + } +} 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/service/PausingHandle.kt index 499f88f34..791201668 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt @@ -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/DownloadNotificationFactory.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt index b610d2427..933c31b4d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -17,14 +17,16 @@ import androidx.work.WorkManager import coil.ImageLoader import coil.request.ImageRequest import coil.size.Scale -import dagger.Reusable +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.DownloadState2 -import org.koitharu.kotatsu.download.ui.DownloadsActivity +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 @@ -34,16 +36,15 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDrawableOrThrow import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import java.util.UUID -import javax.inject.Inject import com.google.android.material.R as materialR private const val CHANNEL_ID = "download" private const val GROUP_ID = "downloads" -@Reusable -class DownloadNotificationFactory @Inject constructor( +class DownloadNotificationFactory @AssistedInject constructor( @ApplicationContext private val context: Context, private val coil: ImageLoader, + @Assisted private val uuid: UUID, ) { private val covers = HashMap() @@ -64,6 +65,30 @@ class DownloadNotificationFactory @Inject constructor( 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) @@ -73,6 +98,7 @@ class DownloadNotificationFactory @Inject constructor( builder.setSilent(true) builder.setGroup(GROUP_ID) builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + builder.priority = NotificationCompat.PRIORITY_DEFAULT } suspend fun create(state: DownloadState2?): Notification = mutex.withLock { @@ -93,21 +119,12 @@ class DownloadNotificationFactory @Inject constructor( NotificationCompat.VISIBILITY_PUBLIC }, ) - when (state?.state) { - null -> Unit - DownloadState2.State.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 - } - - DownloadState2.State.DONE -> { + 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.setContentIntent(createMangaIntent(context, state.localManga.manga)) builder.setAutoCancel(true) builder.setSmallIcon(android.R.drawable.stat_sys_download_done) builder.setCategory(null) @@ -115,12 +132,26 @@ class DownloadNotificationFactory @Inject constructor( builder.setOngoing(false) builder.setShowWhen(true) builder.setWhen(System.currentTimeMillis()) - builder.priority = NotificationCompat.PRIORITY_DEFAULT } - DownloadState2.State.FAILED -> { - val message = state.error?.getDisplayMessage(context.resources) - ?: context.getString(R.string.error_occurred) + state.isPaused -> { // paused (with error or manually) + builder.setProgress(state.max, state.progress, false) + val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) + builder.setContentText(percent) + builder.setContentText( + state.error?.getDisplayMessage(context.resources) + ?: context.getString(R.string.paused), + ) + 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 + 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)) @@ -131,23 +162,17 @@ class DownloadNotificationFactory @Inject constructor( builder.setShowWhen(true) builder.setWhen(System.currentTimeMillis()) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) - builder.priority = NotificationCompat.PRIORITY_DEFAULT } - DownloadState2.State.PREPARING -> { - builder.setProgress(1, 0, true) - builder.setContentText(context.getString(R.string.preparing_)) - builder.setStyle(null) - builder.setOngoing(true) - builder.addAction(createCancelAction(state.id)) - builder.priority = NotificationCompat.PRIORITY_DEFAULT - } - - DownloadState2.State.PROGRESS -> { + else -> { builder.setProgress(state.max, state.progress, false) val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) - if (state.timeLeft > 0L) { - val eta = DateUtils.getRelativeTimeSpanString(state.timeLeft, 0L, DateUtils.SECOND_IN_MILLIS) + if (state.eta > 0L) { + val eta = DateUtils.getRelativeTimeSpanString( + state.eta, + System.currentTimeMillis(), + DateUtils.SECOND_IN_MILLIS, + ) builder.setContentText(eta) builder.setSubText(percent) } else { @@ -156,11 +181,9 @@ class DownloadNotificationFactory @Inject constructor( builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) builder.setOngoing(true) - builder.addAction(createCancelAction(state.id)) - builder.priority = NotificationCompat.PRIORITY_DEFAULT + builder.addAction(actionCancel) + builder.addAction(actionPause) } - - DownloadState2.State.PAUSED -> TODO() } return builder.build() } @@ -177,12 +200,6 @@ class DownloadNotificationFactory @Inject constructor( false, ) - private fun createCancelAction(uuid: UUID) = NotificationCompat.Action( - materialR.drawable.material_ic_clear_black_24dp, - context.getString(android.R.string.cancel), - WorkManager.getInstance(context).createCancelPendingIntent(uuid), - ) - private suspend fun getCover(manga: Manga) = covers[manga] ?: run { runCatchingCancellable { coil.execute( @@ -217,4 +234,10 @@ class DownloadNotificationFactory @Inject constructor( } } } + + @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 index 4918d6e9f..69453b711 100644 --- 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 @@ -4,7 +4,7 @@ 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.DownloadsActivity +import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.utils.ext.findActivity diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index 140d1e54b..0014cfef4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -3,7 +3,9 @@ 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.Constraints import androidx.work.CoroutineWorker import androidx.work.Data @@ -12,6 +14,7 @@ import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Operation import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.await @@ -22,6 +25,7 @@ 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 @@ -50,6 +54,8 @@ import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.runCatchingCancellable 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 @@ -63,32 +69,38 @@ class DownloadWorker @AssistedInject constructor( private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, - private val notificationFactory: DownloadNotificationFactory, + 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 lateinit var currentState: DownloadState2 + 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() } - currentState = DownloadState2(id, manga, DownloadState2.State.PREPARING) - val pausingHandle = PausingHandle() - downloadMangaImpl(chaptersIds, pausingHandle) - val outputData = currentState.toWorkData() - return when (currentState.state) { - DownloadState2.State.CANCELLED, - DownloadState2.State.DONE -> Result.success(outputData) - - DownloadState2.State.FAILED -> Result.failure(outputData) - else -> Result.retry() + currentState = DownloadState2(manga, isIndeterminate = true) + return try { + downloadMangaImpl(chaptersIds) + Result.success(currentState.toWorkData()) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + e.printStackTraceDebug() + Result.retry() + } catch (e: Exception) { + e.printStackTraceDebug() + currentState = currentState.copy(error = e) + Result.failure(currentState.toWorkData()) } } @@ -97,13 +109,16 @@ class DownloadWorker @AssistedInject constructor( notificationFactory.create(null), ) - private suspend fun downloadMangaImpl( - chaptersIds: LongArray?, - pausingHandle: PausingHandle, - ) { + private suspend fun downloadMangaImpl(chaptersIds: LongArray?) { var manga = currentState.manga val chaptersIdsSet = chaptersIds?.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" @@ -149,12 +164,11 @@ class DownloadWorker @AssistedInject constructor( } publishState( currentState.copy( - state = DownloadState2.State.PROGRESS, totalChapters = chapters.size, currentChapter = chapterIndex, totalPages = pages.size, currentPage = pageIndex, - timeLeft = timeLeftEstimator.getEstimatedTimeLeft(), + eta = timeLeftEstimator.getEta(), ), ) @@ -168,20 +182,20 @@ class DownloadWorker @AssistedInject constructor( }.onFailure(Throwable::printStackTraceDebug) } } - publishState(currentState.copy(state = DownloadState2.State.PROGRESS)) + publishState(currentState.copy(isIndeterminate = true)) output.mergeWithExisting() output.finish() val localManga = LocalMangaInput.of(output.rootFile).getManga() localStorageChanges.emit(localManga) - publishState(currentState.copy(state = DownloadState2.State.DONE, localManga = localManga)) - } catch (e: CancellationException) { - publishState(currentState.copy(state = DownloadState2.State.CANCELLED)) + publishState(currentState.copy(localManga = localManga)) + } catch (e: Exception) { + if (e !is CancellationException) { + publishState(currentState.copy(error = e)) + } throw e - } catch (e: Throwable) { - e.printStackTraceDebug() - publishState(currentState.copy(state = DownloadState2.State.FAILED, error = e)) } finally { withContext(NonCancellable) { + applicationContext.unregisterReceiver(pausingReceiver) output?.closeQuietly() output?.cleanup() File(destination, tempFileName).deleteAwait() @@ -194,17 +208,22 @@ class DownloadWorker @AssistedInject constructor( pausingHandle: PausingHandle, block: suspend () -> R, ): R { + if (pausingHandle.isPaused) { + publishState(currentState.copy(isPaused = true)) + 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(state = DownloadState2.State.PAUSED, error = e)) + publishState(currentState.copy(isPaused = true, error = e)) countDown = MAX_FAILSAFE_ATTEMPTS pausingHandle.pause() pausingHandle.awaitResumed() - publishState(currentState.copy(state = DownloadState2.State.PROGRESS, error = null)) + publishState(currentState.copy(isPaused = false, error = null)) } else { countDown-- delay(DOWNLOAD_ERROR_DELAY) @@ -222,6 +241,7 @@ class DownloadWorker @AssistedInject constructor( 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() @@ -236,15 +256,15 @@ class DownloadWorker @AssistedInject constructor( private suspend fun publishState(state: DownloadState2) { currentState = state - if (state.state == DownloadState2.State.PROGRESS && state.max > 0) { + if (!state.isPaused && state.max > 0) { timeLeftEstimator.tick(state.progress, state.max) } else { timeLeftEstimator.emptyTick() notificationThrottler.reset() } val notification = notificationFactory.create(state) - if (state.isTerminal) { - notificationManager.notify(state.id.toString(), id.hashCode(), notification) + if (state.isFinalState) { + notificationManager.notify(id.toString(), id.hashCode(), notification) } else if (notificationThrottler.throttle()) { notificationManager.notify(id.hashCode(), notification) } @@ -264,6 +284,9 @@ class DownloadWorker @AssistedInject constructor( private val dataRepository: MangaDataRepository, ) { + private val workManager: WorkManager + inline get() = WorkManager.getInstance(context) + suspend fun schedule(manga: Manga, chaptersIds: Collection?) { dataRepository.storeManga(manga) val data = Data.Builder() @@ -284,6 +307,14 @@ class DownloadWorker @AssistedInject constructor( scheduleImpl(data).await() } + fun observeWorks(): Flow> = workManager + .getWorkInfosByTagLiveData(TAG) + .asFlow() + + suspend fun cancel(id: UUID) { + workManager.cancelWorkById(id).await() + } + private fun scheduleImpl(data: Collection): Operation { val constraints = Constraints.Builder() .setRequiresStorageNotLow(true) @@ -293,11 +324,12 @@ class DownloadWorker @AssistedInject constructor( OneTimeWorkRequestBuilder() .setConstraints(constraints) .addTag(TAG) + .keepResultsForAtLeast(3, TimeUnit.DAYS) .setInputData(inputData) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) .build() } - return WorkManager.getInstance(context).enqueue(requests) + return workManager.enqueue(requests) } } 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..5f01711f5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt @@ -0,0 +1,66 @@ +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.download.ui.service.PausingHandle +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/download/ui/worker/Throttler.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/Throttler.kt index 1e25674b1..37f8fd6fd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/Throttler.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/Throttler.kt @@ -9,9 +9,13 @@ class Throttler( private var lastTick = 0L fun throttle(): Boolean { - val prevValue = lastTick - lastTick = SystemClock.elapsedRealtime() - return lastTick > prevValue + timeoutMs + val now = SystemClock.elapsedRealtime() + return if (lastTick + timeoutMs <= now) { + lastTick = now + true + } else { + false + } } fun reset() { 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 798fa7c33..cad841abc 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/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/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..0e71a1771 --- /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 0000000000000000000000000000000000000000..dade9e39a808ae49fa5964755434087df2e9b800 GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBay?xfLn`9lo-^cXFc4w67?P!2 z^*^~%C|}@mny19Z4YSzy|K<+vvRkn=uOK@w=yxRB?srATp*QEwzEZbNX6YTDrv(QT zk^~GpBwTou*aVw99FHKl6Yn?s?NByf`S(q8Z28WM(){O@&e@&pSN8;3%;4$j=d#Wz Gp$PyMB|2;X literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a40b746f13f996db5cdac51f64570114733b59a9 GIT binary patch literal 131 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjUY;(FAr*0N&mQDtP!M2t{GEUL zkY7ti!bPSjXUw@X-)e_mecf>Pke}7=+`cvHW!%;$8#hjtkwBw%%G+czq<-7XS6=uw Q1!yXRr>mdKI;Vst05&BpB>(^b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0668229665a330cd9c7966f5ad2ca788121b3137 GIT binary patch literal 203 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtNuDl_Ar*0N&u`>C?7(ou!2kIh z`-{89n*>(Yz6pJ?EA83p;&adcEZn=LeYfpg-apsm%pBe>wasa`zx0A{m&j3A@$kSIZ<~heJG5mK3=RNY$KdJe=d#Wzp$P!u C7FKcq literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6ceff5260ca0a11aea9432fbb9f72043af25cafe GIT binary patch literal 223 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!%RF5iLn`9lUO33vV8Fm~P$cbt zu4dim$R0bDGa?1-)@%$p@BbV#d~a8pe`c}vv%A-yHM=sgHa0mpE>H*%(BKeZ;i4(` f(7r~72jzN8x44{Wn$-$)Fav|9tDnm{r-UW|PXImR literal 0 HcmV?d00001 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 7369b59a4..39ea0c7ed 100644 --- a/app/src/main/res/layout/item_download.xml +++ b/app/src/main/res/layout/item_download.xml @@ -93,13 +93,28 @@ tools:text="@tools:sample/lorem[3]" />