From de46cfe7eee74bea04dd175a9360e7cb4190700a Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 31 Mar 2022 08:23:29 +0300 Subject: [PATCH] Fix manga downloading --- app/build.gradle | 4 +- .../org/koitharu/kotatsu/core/model/Manga.kt | 25 ++ .../core/model/parcelable/ParcelableManga.kt | 11 +- .../kotatsu/details/ui/ChaptersFragment.kt | 4 +- .../kotatsu/details/ui/DetailsActivity.kt | 16 ++ .../kotatsu/details/ui/DetailsViewModel.kt | 43 ++- .../download/domain/DownloadManager.kt | 136 ++++------ .../kotatsu/download/domain/DownloadState.kt | 251 ++++++++++++++++++ .../kotatsu/download/domain/WakeLockNode.kt | 25 ++ .../kotatsu/download/ui/DownloadItemAD.kt | 20 +- .../kotatsu/download/ui/DownloadsAdapter.kt | 14 +- .../ui/service/DownloadNotification.kt | 40 ++- .../download/ui/service/DownloadService.kt | 124 +++++---- .../service/ForegroundNotificationSwitcher.kt | 71 +++++ .../kotatsu/list/ui/MangaListFragment.kt | 4 + .../local/domain/LocalMangaRepository.kt | 10 +- .../kotatsu/reader/ui/ChaptersBottomSheet.kt | 4 +- .../adapter/SearchSuggestionsMangaListAD.kt | 2 +- .../kotatsu/tracker/work/TrackWorker.kt | 11 +- .../utils/RecyclerViewScrollCallback.kt | 9 +- .../org/koitharu/kotatsu/utils/ext/FlowExt.kt | 19 +- 21 files changed, 644 insertions(+), 199 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt diff --git a/app/build.gradle b/app/build.gradle index 426cd1586..6515eeac4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -65,7 +65,9 @@ android { } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) - implementation 'com.github.nv95:kotatsu-parsers:3ea7e92e64' + implementation('com.github.nv95:kotatsu-parsers:3ea7e92e64') { + exclude group: 'org.json', module: 'json' + } implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0' diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt new file mode 100644 index 000000000..70955b232 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.core.model + +import org.koitharu.kotatsu.parsers.model.Manga + +fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) { + this +} else { + Manga( + id = id, + title = title, + altTitle = altTitle, + url = url, + publicUrl = publicUrl, + rating = rating, + isNsfw = isNsfw, + coverUrl = coverUrl, + tags = tags, + state = state, + author = author, + largeCoverUrl = largeCoverUrl, + description = description, + chapters = null, + source = source, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt index 08eaf40cc..44c0ae8f8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt @@ -2,13 +2,22 @@ package org.koitharu.kotatsu.core.model.parcelable import android.os.Parcel import android.os.Parcelable +import android.util.Log +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.parsers.model.Manga class ParcelableManga( val manga: Manga, -): Parcelable { +) : Parcelable { + constructor(parcel: Parcel) : this(parcel.readManga()) + init { + if (BuildConfig.DEBUG && manga.chapters != null) { + Log.w("ParcelableManga", "Passing manga with chapters as Parcelable is dangerous!") + } + } + override fun writeToParcel(parcel: Parcel, flags: Int) { manga.writeToParcel(parcel, flags) } 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 16cc0e797..4cee51d52 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 @@ -26,6 +26,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback +import kotlin.math.roundToInt class ChaptersFragment : BaseFragment(), @@ -245,7 +246,8 @@ class ChaptersFragment : if (adapter.itemCount == 0) { val position = list.indexOfFirst { it.hasFlag(ChapterListItem.FLAG_CURRENT) } - 1 if (position > 0) { - adapter.setItems(list, RecyclerViewScrollCallback(binding.recyclerViewChapters, position)) + val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt() + adapter.setItems(list, RecyclerViewScrollCallback(binding.recyclerViewChapters, position, offset)) } else { adapter.items = list } 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 ef3188532..b06ea2511 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 @@ -1,7 +1,9 @@ package org.koitharu.kotatsu.details.ui +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.net.Uri import android.os.Bundle import android.view.Menu @@ -52,6 +54,13 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato parametersOf(MangaIntent(intent)) } + private val downloadReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val downloadedManga = DownloadService.getDownloadedManga(intent) ?: return + viewModel.onDownloadComplete(downloadedManga) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityDetailsBinding.inflate(layoutInflater)) @@ -71,6 +80,13 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onError.observe(this, ::onError) + + registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) + } + + override fun onDestroy() { + unregisterReceiver(downloadReceiver) + super.onDestroy() } private fun onMangaUpdated(manga: Manga) { 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 62855297a..53546ea6a 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 @@ -7,7 +7,9 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.ui.BaseViewModel @@ -61,7 +63,8 @@ class DetailsViewModel( trackingRepository.getNewChaptersCount(mangaId) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) - private val remoteManga = MutableStateFlow(null) + // Remote manga for saved and saved for remote + private val relatedManga = MutableStateFlow(null) private val chaptersQuery = MutableStateFlow("") private val chaptersReversed = settings.observe() @@ -101,16 +104,16 @@ class DetailsViewModel( val chapters = combine( combine( mangaData.map { it?.chapters.orEmpty() }, - remoteManga, + relatedManga, history.map { it?.chapterId }, newChapters, selectedBranch - ) { chapters, sourceManga, currentId, newCount, branch -> - val sourceChapters = sourceManga?.chapters - if (sourceManga?.source != MangaSource.LOCAL && !sourceChapters.isNullOrEmpty()) { - mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch) + ) { chapters, related, currentId, newCount, branch -> + val relatedChapters = related?.chapters + if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) { + mapChaptersWithSource(chapters, relatedChapters, currentId, newCount, branch) } else { - mapChapters(chapters, sourceChapters, currentId, newCount, branch) + mapChapters(chapters, relatedChapters, currentId, newCount, branch) } }, chaptersReversed, @@ -151,13 +154,35 @@ class DetailsViewModel( } fun getRemoteManga(): Manga? { - return remoteManga.value + return relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL } } fun performChapterSearch(query: String?) { chaptersQuery.value = query?.trim().orEmpty() } + fun onDownloadComplete(downloadedManga: Manga) { + val currentManga = mangaData.value ?: return + if (currentManga.id != downloadedManga.id) { + return + } + if (currentManga.source == MangaSource.LOCAL) { + reload() + } else { + viewModelScope.launch(Dispatchers.Default) { + runCatching { + localMangaRepository.getDetails(downloadedManga) + }.onSuccess { + relatedManga.value = it + }.onFailure { + if (BuildConfig.DEBUG) { + it.printStackTrace() + } + } + } + } + } + private fun doLoad() = launchLoadingJob(Dispatchers.Default) { var manga = mangaDataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga") @@ -171,7 +196,7 @@ class DetailsViewModel( predictBranch(manga.chapters) } mangaData.value = manga - remoteManga.value = runCatching { + relatedManga.value = runCatching { if (manga.source == MangaSource.LOCAL) { val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null MangaRepository(m.source).getDetails(m) 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 3572ebf2b..e1933a683 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 @@ -8,9 +8,8 @@ import coil.ImageLoader import coil.request.ImageRequest import coil.size.Scale import kotlinx.coroutines.* -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Semaphore import okhttp3.OkHttpClient import okhttp3.Request import okio.IOException @@ -26,13 +25,16 @@ import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.waitForNetwork +import org.koitharu.kotatsu.utils.progress.ProgressJob import java.io.File private const val MAX_DOWNLOAD_ATTEMPTS = 3 +private const val MAX_PARALLEL_DOWNLOADS = 2 private const val DOWNLOAD_ERROR_DELAY = 500L private const val TEMP_PAGE_FILE = "page.tmp" class DownloadManager( + private val coroutineScope: CoroutineScope, private val context: Context, private val imageLoader: ImageLoader, private val okHttp: OkHttpClient, @@ -49,9 +51,29 @@ class DownloadManager( private val coverHeight = context.resources.getDimensionPixelSize( androidx.core.R.dimen.compat_notification_large_icon_max_height ) + private val semaphore = Semaphore(MAX_PARALLEL_DOWNLOADS) - fun downloadManga(manga: Manga, chaptersIds: Set?, startId: Int): Flow = flow { - emit(State.Preparing(startId, manga, null)) + fun downloadManga( + manga: Manga, + chaptersIds: Set?, + startId: Int, + ): ProgressJob { + val stateFlow = MutableStateFlow( + DownloadState.Queued(startId = startId, manga = manga, cover = null) + ) + val job = downloadMangaImpl(manga, chaptersIds, stateFlow, startId) + return ProgressJob(job, stateFlow) + } + + private fun downloadMangaImpl( + manga: Manga, + chaptersIds: Set?, + outState: MutableStateFlow, + startId: Int, + ): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) { + semaphore.acquire() + coroutineContext[WakeLockNode]?.acquire() + outState.value = DownloadState.Preparing(startId, manga, null) var cover: Drawable? = null val destination = localMangaRepository.getOutputDir() checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } @@ -68,7 +90,7 @@ class DownloadManager( .build() ).drawable }.getOrNull() - emit(State.Preparing(startId, manga, cover)) + outState.value = DownloadState.Preparing(startId, manga, cover) val data = if (manga.chapters == null) repo.getDetails(manga) else manga output = MangaZip.findInDir(destination, data) output.prepare(data) @@ -97,45 +119,43 @@ class DownloadManager( MimeTypeMap.getFileExtensionFromUrl(url) ) } catch (e: IOException) { - emit(State.WaitingForNetwork(startId, manga, cover)) + outState.value = DownloadState.WaitingForNetwork(startId, data, cover) connectivityManager.waitForNetwork() continue@failsafe } } while (false) - emit( - State.Progress( - startId, manga, cover, - totalChapters = chapters.size, - currentChapter = chapterIndex, - totalPages = pages.size, - currentPage = pageIndex, - ) + outState.value = DownloadState.Progress( + startId, data, cover, + totalChapters = chapters.size, + currentChapter = chapterIndex, + totalPages = pages.size, + currentPage = pageIndex, ) } } } - emit(State.PostProcessing(startId, manga, cover)) + outState.value = DownloadState.PostProcessing(startId, data, cover) if (!output.compress()) { throw RuntimeException("Cannot create target file") } val localManga = localMangaRepository.getFromFile(output.file) - emit(State.Done(startId, manga, cover, localManga)) + outState.value = DownloadState.Done(startId, data, cover, localManga) } catch (_: CancellationException) { - emit(State.Cancelling(startId, manga, cover)) + outState.value = DownloadState.Cancelled(startId, manga, cover) } catch (e: Throwable) { if (BuildConfig.DEBUG) { e.printStackTrace() } - emit(State.Error(startId, manga, cover, e)) + outState.value = DownloadState.Error(startId, manga, cover, e) } finally { withContext(NonCancellable) { output?.cleanup() File(destination, TEMP_PAGE_FILE).deleteAwait() } + coroutineContext[WakeLockNode]?.release() + semaphore.release() } - }.catch { e -> - emit(State.Error(startId, manga, null, e)) } private suspend fun downloadFile(url: String, referer: String, destination: File): File { @@ -168,71 +188,13 @@ class DownloadManager( } } - sealed interface State { - - val startId: Int - val manga: Manga - val cover: Drawable? - - data class Queued( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - ) : State - - data class Preparing( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - ) : State - - data class Progress( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - val totalChapters: Int, - val currentChapter: Int, - val totalPages: Int, - val currentPage: Int, - ) : State { - - val max: Int = totalChapters * totalPages - - val progress: Int = totalPages * currentChapter + currentPage + 1 - - val percent: Float = progress.toFloat() / max - } - - data class WaitingForNetwork( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - ) : State - - data class Done( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - val localManga: Manga, - ) : State - - data class Error( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - val error: Throwable, - ) : State - - data class Cancelling( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - ) : State - - data class PostProcessing( - override val startId: Int, - override val manga: Manga, - override val cover: Drawable?, - ) : State + private fun errorStateHandler(outState: MutableStateFlow) = CoroutineExceptionHandler { _, throwable -> + val prevValue = outState.value + outState.value = DownloadState.Error( + startId = prevValue.startId, + manga = prevValue.manga, + cover = prevValue.cover, + error = throwable, + ) } } \ No newline at end of file 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 new file mode 100644 index 000000000..a0a78ac7a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt @@ -0,0 +1,251 @@ +package org.koitharu.kotatsu.download.domain + +import android.graphics.drawable.Drawable +import org.koitharu.kotatsu.parsers.model.Manga + +sealed interface DownloadState { + + val startId: Int + val manga: Manga + val cover: Drawable? + + class Queued( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Queued + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + return result + } + } + + class Preparing( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Preparing + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + return result + } + } + + class Progress( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + val totalChapters: Int, + val currentChapter: Int, + val totalPages: Int, + val currentPage: Int, + ) : DownloadState { + + val max: Int = totalChapters * totalPages + + val progress: Int = totalPages * currentChapter + currentPage + 1 + + val percent: Float = progress.toFloat() / max + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Progress + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + if (totalChapters != other.totalChapters) return false + if (currentChapter != other.currentChapter) return false + if (totalPages != other.totalPages) return false + if (currentPage != other.currentPage) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + result = 31 * result + totalChapters + result = 31 * result + currentChapter + result = 31 * result + totalPages + result = 31 * result + currentPage + return result + } + } + + class WaitingForNetwork( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WaitingForNetwork + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + return result + } + } + + class Done( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + val localManga: Manga, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Done + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + if (localManga != other.localManga) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + result = 31 * result + localManga.hashCode() + return result + } + } + + class Error( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + val error: Throwable, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Error + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + if (error != other.error) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + result = 31 * result + error.hashCode() + return result + } + } + + class Cancelled( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Cancelled + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + return result + } + } + + class PostProcessing( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PostProcessing + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + return result + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt new file mode 100644 index 000000000..8bbfc2f2d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.download.domain + +import android.os.PowerManager +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +class WakeLockNode( + private val wakeLock: PowerManager.WakeLock, + private val timeout: Long, +) : AbstractCoroutineContextElement(Key) { + + init { + wakeLock.setReferenceCounted(true) + } + + fun acquire() { + wakeLock.acquire(timeout) + } + + fun release() { + wakeLock.release() + } + + companion object Key : CoroutineContext.Key +} \ No newline at end of file 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 index 30bb351c3..c16896205 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt @@ -9,14 +9,14 @@ 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.download.domain.DownloadManager +import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.progress.ProgressJob fun downloadItemAD( scope: CoroutineScope, coil: ImageLoader, -) = adapterDelegateViewBinding, ProgressJob, ItemDownloadBinding>( +) = adapterDelegateViewBinding, ProgressJob, ItemDownloadBinding>( { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) } ) { @@ -36,21 +36,21 @@ fun downloadItemAD( }.onEach { state -> binding.textViewTitle.text = state.manga.title when (state) { - is DownloadManager.State.Cancelling -> { + 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 } - is DownloadManager.State.Done -> { + 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 } - is DownloadManager.State.Error -> { + is DownloadState.Error -> { binding.textViewStatus.setText(R.string.error_occurred) binding.progressBar.isIndeterminate = false binding.progressBar.isVisible = false @@ -58,21 +58,21 @@ fun downloadItemAD( binding.textViewDetails.text = state.error.getDisplayMessage(context.resources) binding.textViewDetails.isVisible = true } - is DownloadManager.State.PostProcessing -> { + 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 } - is DownloadManager.State.Preparing -> { + 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 } - is DownloadManager.State.Progress -> { + is DownloadState.Progress -> { binding.textViewStatus.setText(R.string.manga_downloading_) binding.progressBar.isIndeterminate = false binding.progressBar.isVisible = true @@ -82,14 +82,14 @@ fun downloadItemAD( binding.textViewPercent.isVisible = true binding.textViewDetails.isVisible = false } - is DownloadManager.State.Queued -> { + 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 } - is DownloadManager.State.WaitingForNetwork -> { + is DownloadState.WaitingForNetwork -> { binding.textViewStatus.setText(R.string.waiting_for_network) binding.progressBar.isIndeterminate = false binding.progressBar.isVisible = false 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 index 37df8b9d8..0c3629d1b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt @@ -4,13 +4,13 @@ import androidx.recyclerview.widget.DiffUtil import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import kotlinx.coroutines.CoroutineScope -import org.koitharu.kotatsu.download.domain.DownloadManager +import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.utils.progress.ProgressJob class DownloadsAdapter( scope: CoroutineScope, coil: ImageLoader, -) : AsyncListDifferDelegationAdapter>(DiffCallback()) { +) : AsyncListDifferDelegationAdapter>(DiffCallback()) { init { delegatesManager.addDelegate(downloadItemAD(scope, coil)) @@ -21,18 +21,18 @@ class DownloadsAdapter( return items[position].progressValue.startId.toLong() } - private class DiffCallback : DiffUtil.ItemCallback>() { + private class DiffCallback : DiffUtil.ItemCallback>() { override fun areItemsTheSame( - oldItem: ProgressJob, - newItem: ProgressJob, + oldItem: ProgressJob, + newItem: ProgressJob, ): Boolean { return oldItem.progressValue.startId == newItem.progressValue.startId } override fun areContentsTheSame( - oldItem: ProgressJob, - newItem: ProgressJob, + oldItem: ProgressJob, + newItem: ProgressJob, ): Boolean { return oldItem.progressValue == newItem.progressValue } 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 index d51dbba30..aef7a1667 100644 --- 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 @@ -13,7 +13,7 @@ import androidx.core.graphics.drawable.toBitmap import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.CrashActivity import org.koitharu.kotatsu.details.ui.DetailsActivity -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.PendingIntentCompat @@ -21,10 +21,7 @@ import org.koitharu.kotatsu.utils.ext.format import org.koitharu.kotatsu.utils.ext.getDisplayMessage import com.google.android.material.R as materialR -class DownloadNotification( - private val context: Context, - startId: Int, -) { +class DownloadNotification(private val context: Context, startId: Int) { private val builder = NotificationCompat.Builder(context, CHANNEL_ID) private val cancelAction = NotificationCompat.Action( @@ -48,9 +45,11 @@ class DownloadNotification( builder.setOnlyAlertOnce(true) builder.setDefaults(0) builder.color = ContextCompat.getColor(context, R.color.blue_primary) + builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE + builder.setSilent(true) } - fun create(state: DownloadManager.State): Notification { + fun create(state: DownloadState): Notification { builder.setContentTitle(state.manga.title) builder.setContentText(context.getString(R.string.manga_downloading_)) builder.setProgress(1, 0, true) @@ -60,13 +59,14 @@ class DownloadNotification( builder.setLargeIcon(state.cover?.toBitmap()) builder.clearActions() when (state) { - is DownloadManager.State.Cancelling -> { + is DownloadState.Cancelled -> { builder.setProgress(1, 0, true) builder.setContentText(context.getString(R.string.cancelling_)) builder.setContentIntent(null) builder.setStyle(null) + builder.setOngoing(true) } - is DownloadManager.State.Done -> { + is DownloadState.Done -> { builder.setProgress(0, 0, false) builder.setContentText(context.getString(R.string.download_complete)) builder.setContentIntent(createMangaIntent(context, state.localManga)) @@ -74,14 +74,16 @@ class DownloadNotification( builder.setSmallIcon(android.R.drawable.stat_sys_download_done) builder.setCategory(null) builder.setStyle(null) + builder.setOngoing(false) } - is DownloadManager.State.Error -> { + 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(true) + builder.setOngoing(false) builder.setContentIntent( PendingIntent.getActivity( context, @@ -93,29 +95,39 @@ class DownloadNotification( builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) } - is DownloadManager.State.PostProcessing -> { + is DownloadState.PostProcessing -> { builder.setProgress(1, 0, true) builder.setContentText(context.getString(R.string.processing_)) builder.setStyle(null) + builder.setOngoing(true) } - is DownloadManager.State.Queued, - is DownloadManager.State.Preparing -> { + is DownloadState.Queued -> { + builder.setProgress(0, 0, false) + builder.setContentText(context.getString(R.string.queued)) + builder.setStyle(null) + builder.setOngoing(true) + builder.addAction(cancelAction) + } + is DownloadState.Preparing -> { builder.setProgress(1, 0, true) builder.setContentText(context.getString(R.string.preparing_)) builder.setStyle(null) + builder.setOngoing(true) builder.addAction(cancelAction) } - is DownloadManager.State.Progress -> { + is DownloadState.Progress -> { builder.setProgress(state.max, state.progress, false) builder.setContentText((state.percent * 100).format() + "%") builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) + builder.setOngoing(true) builder.addAction(cancelAction) } - is DownloadManager.State.WaitingForNetwork -> { + is DownloadState.WaitingForNetwork -> { builder.setProgress(0, 0, false) builder.setContentText(context.getString(R.string.waiting_for_network)) builder.setStyle(null) + builder.setOngoing(true) builder.addAction(cancelAction) } } 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 index 79c2295cf..8dc7a738f 100644 --- 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 @@ -8,20 +8,14 @@ import android.os.Binder import android.os.IBinder import android.os.PowerManager import android.widget.Toast -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.isActive +import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext +import kotlinx.coroutines.plus import org.koin.android.ext.android.get import org.koin.core.context.GlobalContext import org.koitharu.kotatsu.BuildConfig @@ -29,10 +23,14 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseService import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.model.withoutChapters import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.download.domain.DownloadManager +import org.koitharu.kotatsu.download.domain.DownloadState +import org.koitharu.kotatsu.download.domain.WakeLockNode import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.ext.connectivityManager +import org.koitharu.kotatsu.utils.ext.throttle import org.koitharu.kotatsu.utils.ext.toArraySet import org.koitharu.kotatsu.utils.progress.ProgressJob import java.util.concurrent.TimeUnit @@ -40,22 +38,27 @@ import kotlin.collections.set class DownloadService : BaseService() { - private lateinit var notificationManager: NotificationManagerCompat - private lateinit var wakeLock: PowerManager.WakeLock private lateinit var downloadManager: DownloadManager + private lateinit var notificationSwitcher: ForegroundNotificationSwitcher - private val jobs = LinkedHashMap>() + private val jobs = LinkedHashMap>() private val jobCount = MutableStateFlow(0) - private val mutex = Mutex() private val controlReceiver = ControlReceiver() private var binder: DownloadBinder? = null override fun onCreate() { super.onCreate() - notificationManager = NotificationManagerCompat.from(this) - wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) + notificationSwitcher = ForegroundNotificationSwitcher(this) + val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") - downloadManager = DownloadManager(this, get(), get(), get(), get()) + downloadManager = DownloadManager( + coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)), + context = this, + imageLoader = get(), + okHttp = get(), + cache = get(), + localMangaRepository = get(), + ) DownloadNotification.createChannel(this) registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL)) } @@ -95,48 +98,50 @@ class DownloadService : BaseService() { startId: Int, manga: Manga, chaptersIds: Set?, - ): ProgressJob { - val initialState = DownloadManager.State.Queued(startId, manga, null) - val stateFlow = MutableStateFlow(initialState) - val job = lifecycleScope.launch { - mutex.withLock { - wakeLock.acquire(TimeUnit.HOURS.toMillis(1)) - val notification = DownloadNotification(this@DownloadService, startId) - startForeground(startId, notification.create(initialState)) - try { - withContext(Dispatchers.Default) { - downloadManager.downloadManga(manga, chaptersIds, startId) - .distinctUntilChanged() - .collect { state -> - stateFlow.value = state - notificationManager.notify(startId, notification.create(state)) - } - } - if (stateFlow.value is DownloadManager.State.Done) { - sendBroadcast( - Intent(ACTION_DOWNLOAD_COMPLETE) - .putExtra(EXTRA_MANGA, ParcelableManga(manga)) - ) - } - } finally { - ServiceCompat.stopForeground( - this@DownloadService, - if (isActive) { - ServiceCompat.STOP_FOREGROUND_DETACH - } else { - ServiceCompat.STOP_FOREGROUND_REMOVE - } - ) - if (wakeLock.isHeld) { - wakeLock.release() - } - stopSelf(startId) - } - } - } - return ProgressJob(job, stateFlow) + ): ProgressJob { + val job = downloadManager.downloadManga(manga, chaptersIds, startId) + listenJob(job) + return job } + private fun listenJob(job: ProgressJob) { + lifecycleScope.launch { + val startId = job.progressValue.startId + val notification = DownloadNotification(this@DownloadService, startId) + notificationSwitcher.notify(startId, notification.create(job.progressValue)) + job.progressAsFlow() + .throttle { state -> if (state is DownloadState.Progress) 400L else 0L } + .whileActive() + .collect { state -> + notificationSwitcher.notify(startId, notification.create(state)) + } + job.join() + (job.progressValue as? DownloadState.Done)?.let { + sendBroadcast( + Intent(ACTION_DOWNLOAD_COMPLETE) + .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga.withoutChapters())) + ) + } + notificationSwitcher.detach( + startId, + if (job.isCancelled) { + null + } else { + notification.create(job.progressValue) + } + ) + stopSelf(startId) + } + } + + private fun Flow.whileActive(): Flow = transformWhile { state -> + emit(state) + !state.isTerminal + } + + private val DownloadState.isTerminal: Boolean + get() = this is DownloadState.Done || this is DownloadState.Error || this is DownloadState.Cancelled + inner class ControlReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { @@ -152,7 +157,7 @@ class DownloadService : BaseService() { class DownloadBinder(private val service: DownloadService) : Binder() { - val downloads: Flow>> + val downloads: Flow>> get() = service.jobCount.mapLatest { service.jobs.values } } @@ -185,6 +190,13 @@ class DownloadService : BaseService() { fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL) .putExtra(EXTRA_CANCEL_ID, startId) + fun getDownloadedManga(intent: Intent?): Manga? { + if (intent?.action == ACTION_DOWNLOAD_COMPLETE) { + return intent.getParcelableExtra(EXTRA_MANGA)?.manga + } + return null + } + private fun confirmDataTransfer(context: Context, callback: () -> Unit) { val settings = GlobalContext.get().get() if (context.connectivityManager.isActiveNetworkMetered && settings.isTrafficWarningEnabled) { diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt new file mode 100644 index 000000000..2202ab606 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt @@ -0,0 +1,71 @@ +package org.koitharu.kotatsu.download.ui.service + +import android.app.Notification +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.SparseArray +import androidx.core.app.ServiceCompat +import androidx.core.util.isEmpty +import androidx.core.util.size + +private const val DEFAULT_DELAY = 500L + +class ForegroundNotificationSwitcher( + private val service: Service, +) { + + private val notificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notifications = SparseArray() + private val handler = Handler(Looper.getMainLooper()) + + @Synchronized + fun notify(startId: Int, notification: Notification) { + if (notifications.isEmpty()) { + handler.postDelayed(StartForegroundRunnable(startId, notification), DEFAULT_DELAY) + } + notificationManager.notify(startId, notification) + notifications[startId] = notification + } + + @Synchronized + fun detach(startId: Int, notification: Notification?) { + notifications.remove(startId) + if (notifications.isEmpty()) { + ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_DETACH) + } + val nextIndex = notifications.size - 1 + if (nextIndex >= 0) { + val nextStartId = notifications.keyAt(nextIndex) + val nextNotification = notifications.valueAt(nextIndex) + service.startForeground(nextStartId, nextNotification) + } + handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY) + } + + private inner class StartForegroundRunnable( + private val startId: Int, + private val notification: Notification, + ) : Runnable { + + override fun run() { + service.startForeground(startId, notification) + } + } + + private inner class NotifyRunnable( + private val startId: Int, + private val notification: Notification?, + ) : Runnable { + + override fun run() { + if (notification != null) { + notificationManager.notify(startId, notification) + } else { + notificationManager.cancel(startId) + } + } + } +} \ No newline at end of file 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 4c27b1890..cc6b8a2e0 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 @@ -186,6 +186,10 @@ abstract class MangaListFragment : headerHeight + resources.resolveDp(-72), headerHeight + resources.resolveDp(10), ) + } else { + binding.recyclerView.updatePadding( + bottom = insets.bottom, + ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 0f8cf8b07..d857fcbe4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -46,9 +46,13 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() } } - override suspend fun getDetails(manga: Manga) = if (manga.chapters == null) { - getFromFile(Uri.parse(manga.url).toFile()) - } else manga + override suspend fun getDetails(manga: Manga) = when { + manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) { + "Manga is not local or saved" + } + manga.chapters == null -> getFromFile(Uri.parse(manga.url).toFile()) + else -> manga + } override suspend fun getPages(chapter: MangaChapter): List { return runInterruptible(Dispatchers.IO){ diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt index 49151127e..99f173aeb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt @@ -19,6 +19,7 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.utils.BottomSheetToolbarController import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback import org.koitharu.kotatsu.utils.ext.withArgs +import kotlin.math.roundToInt class ChaptersBottomSheet : BaseBottomSheet(), OnListItemClickListener { @@ -54,7 +55,8 @@ class ChaptersBottomSheet : BaseBottomSheet(), OnListItemC binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter -> if (currentPosition >= 0) { val targetPosition = (currentPosition - 1).coerceAtLeast(0) - adapter.setItems(items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition)) + val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt() + adapter.setItems(items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition, offset)) } else { adapter.items = items } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt index 61700002a..fe5f3a177 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt @@ -37,7 +37,7 @@ fun searchSuggestionMangaListAD( right = recyclerView.paddingRight - spacing, ) recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) - val scrollResetCallback = RecyclerViewScrollCallback(recyclerView, 0) + val scrollResetCallback = RecyclerViewScrollCallback(recyclerView, 0, 0) bind { adapter.setItems(item.items, scrollResetCallback) diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 885fc830f..04ad1695b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -65,7 +65,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : setProgress(workData.build()) val chapters = details?.chapters ?: continue when { - track.knownChaptersCount == -1 -> { //first check + track.knownChaptersCount == -1 -> { // first check repository.storeTrackResult( mangaId = track.manga.id, knownChaptersCount = chapters.size, @@ -74,7 +74,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : newChapters = emptyList() ) } - track.knownChaptersCount == 0 && track.lastChapterId == 0L -> { //manga was empty on last check + track.knownChaptersCount == 0 && track.lastChapterId == 0L -> { // manga was empty on last check repository.storeTrackResult( mangaId = track.manga.id, knownChaptersCount = track.knownChaptersCount, @@ -82,7 +82,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : previousTrackChapterId = track.lastNotifiedChapterId, newChapters = chapters ) - showNotification(track.manga, chapters) + showNotification(details, chapters) } chapters.size == track.knownChaptersCount -> { if (chapters.lastOrNull()?.id == track.lastChapterId) { @@ -110,7 +110,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : newChapters = newChapters ) showNotification( - track.manga, + details, newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId } ) } @@ -224,6 +224,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : .setSilent(true) .setProgress(0, 0, true) .setSmallIcon(android.R.drawable.stat_notify_sync) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED) .setOngoing(true) .build() @@ -300,4 +301,4 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/RecyclerViewScrollCallback.kt b/app/src/main/java/org/koitharu/kotatsu/utils/RecyclerViewScrollCallback.kt index 984867ae4..075126db2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/RecyclerViewScrollCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/RecyclerViewScrollCallback.kt @@ -1,10 +1,15 @@ package org.koitharu.kotatsu.utils +import androidx.annotation.Px import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import java.lang.ref.WeakReference -class RecyclerViewScrollCallback(recyclerView: RecyclerView, private val position: Int) : Runnable { +class RecyclerViewScrollCallback( + recyclerView: RecyclerView, + private val position: Int, + @Px private val offset: Int, +) : Runnable { private val recyclerViewRef = WeakReference(recyclerView) @@ -13,7 +18,7 @@ class RecyclerViewScrollCallback(recyclerView: RecyclerView, private val positio val lm = rv.layoutManager ?: return rv.stopScroll() if (lm is LinearLayoutManager) { - lm.scrollToPositionWithOffset(position, 0) + lm.scrollToPositionWithOffset(position, offset) } else { lm.scrollToPosition(position) } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt index 714c31672..ac4dc90b9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt @@ -1,9 +1,11 @@ package org.koitharu.kotatsu.utils.ext +import android.os.SystemClock +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.flow.transformLatest fun Flow.onFirst(action: suspend (T) -> Unit): Flow { var isFirstCall = true @@ -17,4 +19,19 @@ fun Flow.onFirst(action: suspend (T) -> Unit): Flow { inline fun Flow>.mapItems(crossinline transform: (T) -> R): Flow> { return map { list -> list.map(transform) } +} + +fun Flow.throttle(timeoutMillis: (T) -> Long): Flow { + var lastEmittedAt = 0L + return transformLatest { value -> + val delay = timeoutMillis(value) + val now = SystemClock.elapsedRealtime() + if (delay > 0L) { + if (lastEmittedAt + delay < now) { + delay(lastEmittedAt + delay - now) + } + } + emit(value) + lastEmittedAt = now + } } \ No newline at end of file