Fix manga downloading

pull/129/head
Koitharu 4 years ago
parent 8b5a985842
commit de46cfe7ee
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -65,7 +65,9 @@ android {
} }
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) 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-core:1.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'

@ -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,
)
}

@ -2,13 +2,22 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import android.util.Log
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
class ParcelableManga( class ParcelableManga(
val manga: Manga, val manga: Manga,
): Parcelable { ) : Parcelable {
constructor(parcel: Parcel) : this(parcel.readManga()) 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) { override fun writeToParcel(parcel: Parcel, flags: Int) {
manga.writeToParcel(parcel, flags) manga.writeToParcel(parcel, flags)
} }

@ -26,6 +26,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import kotlin.math.roundToInt
class ChaptersFragment : class ChaptersFragment :
BaseFragment<FragmentChaptersBinding>(), BaseFragment<FragmentChaptersBinding>(),
@ -245,7 +246,8 @@ class ChaptersFragment :
if (adapter.itemCount == 0) { if (adapter.itemCount == 0) {
val position = list.indexOfFirst { it.hasFlag(ChapterListItem.FLAG_CURRENT) } - 1 val position = list.indexOfFirst { it.hasFlag(ChapterListItem.FLAG_CURRENT) } - 1
if (position > 0) { 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 { } else {
adapter.items = list adapter.items = list
} }

@ -1,7 +1,9 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
@ -52,6 +54,13 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
parametersOf(MangaIntent(intent)) 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityDetailsBinding.inflate(layoutInflater)) setContentView(ActivityDetailsBinding.inflate(layoutInflater))
@ -71,6 +80,13 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(this, ::onError)
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
}
override fun onDestroy() {
unregisterReceiver(downloadReceiver)
super.onDestroy()
} }
private fun onMangaUpdated(manga: Manga) { private fun onMangaUpdated(manga: Manga) {

@ -7,7 +7,9 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
@ -61,7 +63,8 @@ class DetailsViewModel(
trackingRepository.getNewChaptersCount(mangaId) trackingRepository.getNewChaptersCount(mangaId)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val remoteManga = MutableStateFlow<Manga?>(null) // Remote manga for saved and saved for remote
private val relatedManga = MutableStateFlow<Manga?>(null)
private val chaptersQuery = MutableStateFlow("") private val chaptersQuery = MutableStateFlow("")
private val chaptersReversed = settings.observe() private val chaptersReversed = settings.observe()
@ -101,16 +104,16 @@ class DetailsViewModel(
val chapters = combine( val chapters = combine(
combine( combine(
mangaData.map { it?.chapters.orEmpty() }, mangaData.map { it?.chapters.orEmpty() },
remoteManga, relatedManga,
history.map { it?.chapterId }, history.map { it?.chapterId },
newChapters, newChapters,
selectedBranch selectedBranch
) { chapters, sourceManga, currentId, newCount, branch -> ) { chapters, related, currentId, newCount, branch ->
val sourceChapters = sourceManga?.chapters val relatedChapters = related?.chapters
if (sourceManga?.source != MangaSource.LOCAL && !sourceChapters.isNullOrEmpty()) { if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) {
mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch) mapChaptersWithSource(chapters, relatedChapters, currentId, newCount, branch)
} else { } else {
mapChapters(chapters, sourceChapters, currentId, newCount, branch) mapChapters(chapters, relatedChapters, currentId, newCount, branch)
} }
}, },
chaptersReversed, chaptersReversed,
@ -151,13 +154,35 @@ class DetailsViewModel(
} }
fun getRemoteManga(): Manga? { fun getRemoteManga(): Manga? {
return remoteManga.value return relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
} }
fun performChapterSearch(query: String?) { fun performChapterSearch(query: String?) {
chaptersQuery.value = query?.trim().orEmpty() 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) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
var manga = mangaDataRepository.resolveIntent(intent) var manga = mangaDataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga") ?: throw MangaNotFoundException("Cannot find manga")
@ -171,7 +196,7 @@ class DetailsViewModel(
predictBranch(manga.chapters) predictBranch(manga.chapters)
} }
mangaData.value = manga mangaData.value = manga
remoteManga.value = runCatching { relatedManga.value = runCatching {
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
MangaRepository(m.source).getDetails(m) MangaRepository(m.source).getDetails(m)

@ -8,9 +8,8 @@ import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale import coil.size.Scale
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.flow.flow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okio.IOException 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.deleteAwait
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.waitForNetwork import org.koitharu.kotatsu.utils.ext.waitForNetwork
import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.io.File import java.io.File
private const val MAX_DOWNLOAD_ATTEMPTS = 3 private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val MAX_PARALLEL_DOWNLOADS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L private const val DOWNLOAD_ERROR_DELAY = 500L
private const val TEMP_PAGE_FILE = "page.tmp" private const val TEMP_PAGE_FILE = "page.tmp"
class DownloadManager( class DownloadManager(
private val coroutineScope: CoroutineScope,
private val context: Context, private val context: Context,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
@ -49,9 +51,29 @@ class DownloadManager(
private val coverHeight = context.resources.getDimensionPixelSize( private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height androidx.core.R.dimen.compat_notification_large_icon_max_height
) )
private val semaphore = Semaphore(MAX_PARALLEL_DOWNLOADS)
fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Flow<State> = flow {
emit(State.Preparing(startId, manga, null)) fun downloadManga(
manga: Manga,
chaptersIds: Set<Long>?,
startId: Int,
): ProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>(
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<Long>?,
outState: MutableStateFlow<DownloadState>,
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 var cover: Drawable? = null
val destination = localMangaRepository.getOutputDir() val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
@ -68,7 +90,7 @@ class DownloadManager(
.build() .build()
).drawable ).drawable
}.getOrNull() }.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 val data = if (manga.chapters == null) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data) output = MangaZip.findInDir(destination, data)
output.prepare(data) output.prepare(data)
@ -97,45 +119,43 @@ class DownloadManager(
MimeTypeMap.getFileExtensionFromUrl(url) MimeTypeMap.getFileExtensionFromUrl(url)
) )
} catch (e: IOException) { } catch (e: IOException) {
emit(State.WaitingForNetwork(startId, manga, cover)) outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
connectivityManager.waitForNetwork() connectivityManager.waitForNetwork()
continue@failsafe continue@failsafe
} }
} while (false) } while (false)
emit( outState.value = DownloadState.Progress(
State.Progress( startId, data, cover,
startId, manga, cover,
totalChapters = chapters.size, totalChapters = chapters.size,
currentChapter = chapterIndex, currentChapter = chapterIndex,
totalPages = pages.size, totalPages = pages.size,
currentPage = pageIndex, currentPage = pageIndex,
) )
)
} }
} }
} }
emit(State.PostProcessing(startId, manga, cover)) outState.value = DownloadState.PostProcessing(startId, data, cover)
if (!output.compress()) { if (!output.compress()) {
throw RuntimeException("Cannot create target file") throw RuntimeException("Cannot create target file")
} }
val localManga = localMangaRepository.getFromFile(output.file) val localManga = localMangaRepository.getFromFile(output.file)
emit(State.Done(startId, manga, cover, localManga)) outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (_: CancellationException) { } catch (_: CancellationException) {
emit(State.Cancelling(startId, manga, cover)) outState.value = DownloadState.Cancelled(startId, manga, cover)
} catch (e: Throwable) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
} }
emit(State.Error(startId, manga, cover, e)) outState.value = DownloadState.Error(startId, manga, cover, e)
} finally { } finally {
withContext(NonCancellable) { withContext(NonCancellable) {
output?.cleanup() output?.cleanup()
File(destination, TEMP_PAGE_FILE).deleteAwait() 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 { private suspend fun downloadFile(url: String, referer: String, destination: File): File {
@ -168,71 +188,13 @@ class DownloadManager(
} }
} }
sealed interface State { private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = CoroutineExceptionHandler { _, throwable ->
val prevValue = outState.value
val startId: Int outState.value = DownloadState.Error(
val manga: Manga startId = prevValue.startId,
val cover: Drawable? manga = prevValue.manga,
cover = prevValue.cover,
data class Queued( error = throwable,
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
} }
} }

@ -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
}
}
}

@ -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<WakeLockNode>
}

@ -9,14 +9,14 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding 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.ext.*
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob
fun downloadItemAD( fun downloadItemAD(
scope: CoroutineScope, scope: CoroutineScope,
coil: ImageLoader, coil: ImageLoader,
) = adapterDelegateViewBinding<ProgressJob<DownloadManager.State>, ProgressJob<DownloadManager.State>, ItemDownloadBinding>( ) = adapterDelegateViewBinding<ProgressJob<DownloadState>, ProgressJob<DownloadState>, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
) { ) {
@ -36,21 +36,21 @@ fun downloadItemAD(
}.onEach { state -> }.onEach { state ->
binding.textViewTitle.text = state.manga.title binding.textViewTitle.text = state.manga.title
when (state) { when (state) {
is DownloadManager.State.Cancelling -> { is DownloadState.Cancelled -> {
binding.textViewStatus.setText(R.string.cancelling_) binding.textViewStatus.setText(R.string.cancelling_)
binding.progressBar.isIndeterminate = true binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
} }
is DownloadManager.State.Done -> { is DownloadState.Done -> {
binding.textViewStatus.setText(R.string.download_complete) binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.isIndeterminate = false binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
} }
is DownloadManager.State.Error -> { is DownloadState.Error -> {
binding.textViewStatus.setText(R.string.error_occurred) binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.isIndeterminate = false binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
@ -58,21 +58,21 @@ fun downloadItemAD(
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources) binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
binding.textViewDetails.isVisible = true binding.textViewDetails.isVisible = true
} }
is DownloadManager.State.PostProcessing -> { is DownloadState.PostProcessing -> {
binding.textViewStatus.setText(R.string.processing_) binding.textViewStatus.setText(R.string.processing_)
binding.progressBar.isIndeterminate = true binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
} }
is DownloadManager.State.Preparing -> { is DownloadState.Preparing -> {
binding.textViewStatus.setText(R.string.preparing_) binding.textViewStatus.setText(R.string.preparing_)
binding.progressBar.isIndeterminate = true binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
} }
is DownloadManager.State.Progress -> { is DownloadState.Progress -> {
binding.textViewStatus.setText(R.string.manga_downloading_) binding.textViewStatus.setText(R.string.manga_downloading_)
binding.progressBar.isIndeterminate = false binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = true binding.progressBar.isVisible = true
@ -82,14 +82,14 @@ fun downloadItemAD(
binding.textViewPercent.isVisible = true binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
} }
is DownloadManager.State.Queued -> { is DownloadState.Queued -> {
binding.textViewStatus.setText(R.string.queued) binding.textViewStatus.setText(R.string.queued)
binding.progressBar.isIndeterminate = false binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
} }
is DownloadManager.State.WaitingForNetwork -> { is DownloadState.WaitingForNetwork -> {
binding.textViewStatus.setText(R.string.waiting_for_network) binding.textViewStatus.setText(R.string.waiting_for_network)
binding.progressBar.isIndeterminate = false binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false binding.progressBar.isVisible = false

@ -4,13 +4,13 @@ import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope 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 import org.koitharu.kotatsu.utils.progress.ProgressJob
class DownloadsAdapter( class DownloadsAdapter(
scope: CoroutineScope, scope: CoroutineScope,
coil: ImageLoader, coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadManager.State>>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadState>>(DiffCallback()) {
init { init {
delegatesManager.addDelegate(downloadItemAD(scope, coil)) delegatesManager.addDelegate(downloadItemAD(scope, coil))
@ -21,18 +21,18 @@ class DownloadsAdapter(
return items[position].progressValue.startId.toLong() return items[position].progressValue.startId.toLong()
} }
private class DiffCallback : DiffUtil.ItemCallback<ProgressJob<DownloadManager.State>>() { private class DiffCallback : DiffUtil.ItemCallback<ProgressJob<DownloadState>>() {
override fun areItemsTheSame( override fun areItemsTheSame(
oldItem: ProgressJob<DownloadManager.State>, oldItem: ProgressJob<DownloadState>,
newItem: ProgressJob<DownloadManager.State>, newItem: ProgressJob<DownloadState>,
): Boolean { ): Boolean {
return oldItem.progressValue.startId == newItem.progressValue.startId return oldItem.progressValue.startId == newItem.progressValue.startId
} }
override fun areContentsTheSame( override fun areContentsTheSame(
oldItem: ProgressJob<DownloadManager.State>, oldItem: ProgressJob<DownloadState>,
newItem: ProgressJob<DownloadManager.State>, newItem: ProgressJob<DownloadState>,
): Boolean { ): Boolean {
return oldItem.progressValue == newItem.progressValue return oldItem.progressValue == newItem.progressValue
} }

@ -13,7 +13,7 @@ import androidx.core.graphics.drawable.toBitmap
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.CrashActivity import org.koitharu.kotatsu.core.ui.CrashActivity
import org.koitharu.kotatsu.details.ui.DetailsActivity 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.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.PendingIntentCompat 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 org.koitharu.kotatsu.utils.ext.getDisplayMessage
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class DownloadNotification( class DownloadNotification(private val context: Context, startId: Int) {
private val context: Context,
startId: Int,
) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID) private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val cancelAction = NotificationCompat.Action( private val cancelAction = NotificationCompat.Action(
@ -48,9 +45,11 @@ class DownloadNotification(
builder.setOnlyAlertOnce(true) builder.setOnlyAlertOnce(true)
builder.setDefaults(0) builder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary) 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.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_)) builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
@ -60,13 +59,14 @@ class DownloadNotification(
builder.setLargeIcon(state.cover?.toBitmap()) builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions() builder.clearActions()
when (state) { when (state) {
is DownloadManager.State.Cancelling -> { is DownloadState.Cancelled -> {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.cancelling_)) builder.setContentText(context.getString(R.string.cancelling_))
builder.setContentIntent(null) builder.setContentIntent(null)
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true)
} }
is DownloadManager.State.Done -> { is DownloadState.Done -> {
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete)) builder.setContentText(context.getString(R.string.download_complete))
builder.setContentIntent(createMangaIntent(context, state.localManga)) builder.setContentIntent(createMangaIntent(context, state.localManga))
@ -74,14 +74,16 @@ class DownloadNotification(
builder.setSmallIcon(android.R.drawable.stat_sys_download_done) builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null) builder.setCategory(null)
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(false)
} }
is DownloadManager.State.Error -> { is DownloadState.Error -> {
val message = state.error.getDisplayMessage(context.resources) val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error) builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error)) builder.setSubText(context.getString(R.string.error))
builder.setContentText(message) builder.setContentText(message)
builder.setAutoCancel(true) builder.setAutoCancel(true)
builder.setOngoing(false)
builder.setContentIntent( builder.setContentIntent(
PendingIntent.getActivity( PendingIntent.getActivity(
context, context,
@ -93,29 +95,39 @@ class DownloadNotification(
builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
} }
is DownloadManager.State.PostProcessing -> { is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_)) builder.setContentText(context.getString(R.string.processing_))
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true)
} }
is DownloadManager.State.Queued, is DownloadState.Queued -> {
is DownloadManager.State.Preparing -> { 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.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.preparing_)) builder.setContentText(context.getString(R.string.preparing_))
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction) builder.addAction(cancelAction)
} }
is DownloadManager.State.Progress -> { is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false) builder.setProgress(state.max, state.progress, false)
builder.setContentText((state.percent * 100).format() + "%") builder.setContentText((state.percent * 100).format() + "%")
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction) builder.addAction(cancelAction)
} }
is DownloadManager.State.WaitingForNetwork -> { is DownloadState.WaitingForNetwork -> {
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.waiting_for_network)) builder.setContentText(context.getString(R.string.waiting_for_network))
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction) builder.addAction(cancelAction)
} }
} }

@ -8,20 +8,14 @@ import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import android.widget.Toast import android.widget.Toast
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import org.koitharu.kotatsu.BuildConfig 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.BaseService
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga 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.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadManager 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.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.connectivityManager 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.ext.toArraySet
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -40,22 +38,27 @@ import kotlin.collections.set
class DownloadService : BaseService() { class DownloadService : BaseService() {
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var downloadManager: DownloadManager private lateinit var downloadManager: DownloadManager
private lateinit var notificationSwitcher: ForegroundNotificationSwitcher
private val jobs = LinkedHashMap<Int, ProgressJob<DownloadManager.State>>() private val jobs = LinkedHashMap<Int, ProgressJob<DownloadState>>()
private val jobCount = MutableStateFlow(0) private val jobCount = MutableStateFlow(0)
private val mutex = Mutex()
private val controlReceiver = ControlReceiver() private val controlReceiver = ControlReceiver()
private var binder: DownloadBinder? = null private var binder: DownloadBinder? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
notificationManager = NotificationManagerCompat.from(this) notificationSwitcher = ForegroundNotificationSwitcher(this)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") .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) DownloadNotification.createChannel(this)
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL)) registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
} }
@ -95,47 +98,49 @@ class DownloadService : BaseService() {
startId: Int, startId: Int,
manga: Manga, manga: Manga,
chaptersIds: Set<Long>?, chaptersIds: Set<Long>?,
): ProgressJob<DownloadManager.State> { ): ProgressJob<DownloadState> {
val initialState = DownloadManager.State.Queued(startId, manga, null) val job = downloadManager.downloadManga(manga, chaptersIds, startId)
val stateFlow = MutableStateFlow<DownloadManager.State>(initialState) listenJob(job)
val job = lifecycleScope.launch { return job
mutex.withLock { }
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch {
val startId = job.progressValue.startId
val notification = DownloadNotification(this@DownloadService, startId) val notification = DownloadNotification(this@DownloadService, startId)
startForeground(startId, notification.create(initialState)) notificationSwitcher.notify(startId, notification.create(job.progressValue))
try { job.progressAsFlow()
withContext(Dispatchers.Default) { .throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
downloadManager.downloadManga(manga, chaptersIds, startId) .whileActive()
.distinctUntilChanged()
.collect { state -> .collect { state ->
stateFlow.value = state notificationSwitcher.notify(startId, notification.create(state))
notificationManager.notify(startId, notification.create(state))
} }
} job.join()
if (stateFlow.value is DownloadManager.State.Done) { (job.progressValue as? DownloadState.Done)?.let {
sendBroadcast( sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE) Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(manga)) .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga.withoutChapters()))
) )
} }
} finally { notificationSwitcher.detach(
ServiceCompat.stopForeground( startId,
this@DownloadService, if (job.isCancelled) {
if (isActive) { null
ServiceCompat.STOP_FOREGROUND_DETACH
} else { } else {
ServiceCompat.STOP_FOREGROUND_REMOVE notification.create(job.progressValue)
} }
) )
if (wakeLock.isHeld) {
wakeLock.release()
}
stopSelf(startId) stopSelf(startId)
} }
} }
private fun Flow<DownloadState>.whileActive(): Flow<DownloadState> = transformWhile { state ->
emit(state)
!state.isTerminal
} }
return ProgressJob(job, stateFlow)
} private val DownloadState.isTerminal: Boolean
get() = this is DownloadState.Done || this is DownloadState.Error || this is DownloadState.Cancelled
inner class ControlReceiver : BroadcastReceiver() { inner class ControlReceiver : BroadcastReceiver() {
@ -152,7 +157,7 @@ class DownloadService : BaseService() {
class DownloadBinder(private val service: DownloadService) : Binder() { class DownloadBinder(private val service: DownloadService) : Binder() {
val downloads: Flow<Collection<ProgressJob<DownloadManager.State>>> val downloads: Flow<Collection<ProgressJob<DownloadState>>>
get() = service.jobCount.mapLatest { service.jobs.values } get() = service.jobCount.mapLatest { service.jobs.values }
} }
@ -185,6 +190,13 @@ class DownloadService : BaseService() {
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL) fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(EXTRA_CANCEL_ID, startId) .putExtra(EXTRA_CANCEL_ID, startId)
fun getDownloadedManga(intent: Intent?): Manga? {
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
return intent.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
}
return null
}
private fun confirmDataTransfer(context: Context, callback: () -> Unit) { private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
val settings = GlobalContext.get().get<AppSettings>() val settings = GlobalContext.get().get<AppSettings>()
if (context.connectivityManager.isActiveNetworkMetered && settings.isTrafficWarningEnabled) { if (context.connectivityManager.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {

@ -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<Notification>()
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)
}
}
}
}

@ -186,6 +186,10 @@ abstract class MangaListFragment :
headerHeight + resources.resolveDp(-72), headerHeight + resources.resolveDp(-72),
headerHeight + resources.resolveDp(10), headerHeight + resources.resolveDp(10),
) )
} else {
binding.recyclerView.updatePadding(
bottom = insets.bottom,
)
} }
} }

@ -46,9 +46,13 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() } return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() }
} }
override suspend fun getDetails(manga: Manga) = if (manga.chapters == null) { override suspend fun getDetails(manga: Manga) = when {
getFromFile(Uri.parse(manga.url).toFile()) manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) {
} else 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<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
return runInterruptible(Dispatchers.IO){ return runInterruptible(Dispatchers.IO){

@ -19,6 +19,7 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.utils.BottomSheetToolbarController import org.koitharu.kotatsu.utils.BottomSheetToolbarController
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
import kotlin.math.roundToInt
class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> { class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
@ -54,7 +55,8 @@ class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemC
binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter -> binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter ->
if (currentPosition >= 0) { if (currentPosition >= 0) {
val targetPosition = (currentPosition - 1).coerceAtLeast(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 { } else {
adapter.items = items adapter.items = items
} }

@ -37,7 +37,7 @@ fun searchSuggestionMangaListAD(
right = recyclerView.paddingRight - spacing, right = recyclerView.paddingRight - spacing,
) )
recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
val scrollResetCallback = RecyclerViewScrollCallback(recyclerView, 0) val scrollResetCallback = RecyclerViewScrollCallback(recyclerView, 0, 0)
bind { bind {
adapter.setItems(item.items, scrollResetCallback) adapter.setItems(item.items, scrollResetCallback)

@ -65,7 +65,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
setProgress(workData.build()) setProgress(workData.build())
val chapters = details?.chapters ?: continue val chapters = details?.chapters ?: continue
when { when {
track.knownChaptersCount == -1 -> { //first check track.knownChaptersCount == -1 -> { // first check
repository.storeTrackResult( repository.storeTrackResult(
mangaId = track.manga.id, mangaId = track.manga.id,
knownChaptersCount = chapters.size, knownChaptersCount = chapters.size,
@ -74,7 +74,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
newChapters = emptyList() 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( repository.storeTrackResult(
mangaId = track.manga.id, mangaId = track.manga.id,
knownChaptersCount = track.knownChaptersCount, knownChaptersCount = track.knownChaptersCount,
@ -82,7 +82,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
previousTrackChapterId = track.lastNotifiedChapterId, previousTrackChapterId = track.lastNotifiedChapterId,
newChapters = chapters newChapters = chapters
) )
showNotification(track.manga, chapters) showNotification(details, chapters)
} }
chapters.size == track.knownChaptersCount -> { chapters.size == track.knownChaptersCount -> {
if (chapters.lastOrNull()?.id == track.lastChapterId) { if (chapters.lastOrNull()?.id == track.lastChapterId) {
@ -110,7 +110,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
newChapters = newChapters newChapters = newChapters
) )
showNotification( showNotification(
track.manga, details,
newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId } newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId }
) )
} }
@ -224,6 +224,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
.setSilent(true) .setSilent(true)
.setProgress(0, 0, true) .setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync) .setSmallIcon(android.R.drawable.stat_notify_sync)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.setOngoing(true) .setOngoing(true)
.build() .build()

@ -1,10 +1,15 @@
package org.koitharu.kotatsu.utils package org.koitharu.kotatsu.utils
import androidx.annotation.Px
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import java.lang.ref.WeakReference 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) private val recyclerViewRef = WeakReference(recyclerView)
@ -13,7 +18,7 @@ class RecyclerViewScrollCallback(recyclerView: RecyclerView, private val positio
val lm = rv.layoutManager ?: return val lm = rv.layoutManager ?: return
rv.stopScroll() rv.stopScroll()
if (lm is LinearLayoutManager) { if (lm is LinearLayoutManager) {
lm.scrollToPositionWithOffset(position, 0) lm.scrollToPositionWithOffset(position, offset)
} else { } else {
lm.scrollToPosition(position) lm.scrollToPosition(position)
} }

@ -1,9 +1,11 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.os.SystemClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> { fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
var isFirstCall = true var isFirstCall = true
@ -18,3 +20,18 @@ fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> { inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> {
return map { list -> list.map(transform) } return map { list -> list.map(transform) }
} }
fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
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
}
}
Loading…
Cancel
Save