Merge branch 'master' into devel

pull/211/head
Koitharu 4 years ago
commit 68e8ccf6bd
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -135,6 +135,7 @@
<service <service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService" android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:stopWithTask="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" /> <service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service android:name="org.koitharu.kotatsu.local.ui.ImportService" /> <service android:name="org.koitharu.kotatsu.local.ui.ImportService" />

@ -4,6 +4,7 @@ import android.content.Context
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -12,8 +13,10 @@ import java.io.File
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException import okio.IOException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
@ -63,25 +66,34 @@ class DownloadManager @AssistedInject constructor(
DownloadState.Queued(startId = startId, manga = manga, cover = null), DownloadState.Queued(startId = startId, manga = manga, cover = null),
) )
val pausingHandle = PausingHandle() val pausingHandle = PausingHandle()
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId) val job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(stateFlow)) {
try {
downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
} catch (e: CancellationException) { // handle cancellation if not handled already
val state = stateFlow.value
if (state !is DownloadState.Cancelled) {
stateFlow.value = DownloadState.Cancelled(startId, state.manga, state.cover)
}
throw e
}
}
return PausingProgressJob(job, stateFlow, pausingHandle) return PausingProgressJob(job, stateFlow, pausingHandle)
} }
private fun downloadMangaImpl( private suspend fun downloadMangaImpl(
manga: Manga, manga: Manga,
chaptersIds: LongArray?, chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>, outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle, pausingHandle: PausingHandle,
startId: Int, startId: Int,
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) { ) {
@Suppress("NAME_SHADOWING") @Suppress("NAME_SHADOWING")
var manga = manga var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet() val chaptersIdsSet = chaptersIds?.toMutableSet()
val cover = loadCover(manga) val cover = loadCover(manga)
outState.value = DownloadState.Queued(startId, manga, cover) outState.value = DownloadState.Queued(startId, manga, cover)
localMangaRepository.lockManga(manga.id) withMangaLock(manga) {
semaphore.acquire() semaphore.withPermit {
coroutineContext[WakeLockNode]?.acquire()
outState.value = DownloadState.Preparing(startId, manga, null) outState.value = DownloadState.Preparing(startId, manga, 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) }
@ -89,7 +101,8 @@ class DownloadManager @AssistedInject constructor(
var output: CbzMangaOutput? = null var output: CbzMangaOutput? = null
try { try {
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance") manga = localMangaRepository.getRemoteManga(manga)
?: error("Cannot obtain remote manga instance")
} }
val repo = mangaRepositoryFactory.create(manga.source) val repo = mangaRepositoryFactory.create(manga.source)
outState.value = DownloadState.Preparing(startId, manga, cover) outState.value = DownloadState.Preparing(startId, manga, cover)
@ -153,11 +166,11 @@ class DownloadManager @AssistedInject constructor(
outState.value = DownloadState.Error(startId, manga, cover, e, false) outState.value = DownloadState.Error(startId, manga, cover, e, false)
} finally { } finally {
withContext(NonCancellable) { withContext(NonCancellable) {
output?.closeQuietly()
output?.cleanup() output?.cleanup()
File(destination, tempFileName).deleteAwait() File(destination, tempFileName).deleteAwait()
coroutineContext[WakeLockNode]?.release() }
semaphore.release() }
localMangaRepository.unlockManga(manga.id)
} }
} }
} }
@ -207,6 +220,7 @@ class DownloadManager @AssistedInject constructor(
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
CoroutineExceptionHandler { _, throwable -> CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
val prevValue = outState.value val prevValue = outState.value
outState.value = DownloadState.Error( outState.value = DownloadState.Error(
startId = prevValue.startId, startId = prevValue.startId,
@ -223,10 +237,18 @@ class DownloadManager @AssistedInject constructor(
.data(manga.coverUrl) .data(manga.coverUrl)
.referer(manga.publicUrl) .referer(manga.publicUrl)
.size(coverWidth, coverHeight) .size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build(), .build(),
).drawable ).drawable
}.getOrNull() }.getOrNull()
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {

@ -1,25 +0,0 @@
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>
}

@ -1,25 +1,27 @@
package org.koitharu.kotatsu.download.ui package org.koitharu.kotatsu.download.ui
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle import android.os.Bundle
import android.os.IBinder
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.bindServiceWithLifecycle
@AndroidEntryPoint @AndroidEntryPoint
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() { class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@ -36,26 +38,61 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
bindServiceWithLifecycle( val connection = DownloadServiceConnection(adapter)
owner = this, bindService(Intent(this, DownloadService::class.java), connection, 0)
service = Intent(this, DownloadService::class.java), lifecycle.addObserver(connection)
flags = 0,
).service.flatMapLatest { binder ->
(binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null)
}.onEach {
adapter.items = it?.toList().orEmpty()
binding.textViewHolder.isVisible = it.isNullOrEmpty()
}.launchIn(lifecycleScope)
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.root.updatePadding( binding.recyclerView.updatePadding(
left = insets.left, left = insets.left,
right = insets.right, right = insets.right,
)
binding.recyclerView.updatePadding(
bottom = insets.bottom, bottom = insets.bottom,
) )
binding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
)
}
private inner class DownloadServiceConnection(
private val adapter: DownloadsAdapter,
) : ServiceConnection, DefaultLifecycleObserver {
private var collectJob: Job? = null
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
collectJob?.cancel()
val binder = (service as? DownloadService.DownloadBinder)
collectJob = if (binder == null) {
null
} else {
lifecycleScope.launch {
binder.downloads.collect {
setItems(it)
}
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
collectJob?.cancel()
collectJob = null
setItems(null)
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
collectJob?.cancel()
collectJob = null
owner.lifecycle.removeObserver(this)
unbindService(this)
}
private fun setItems(items: Collection<DownloadItem>?) {
adapter.items = items?.toList().orEmpty()
binding.textViewHolder.isVisible = items.isNullOrEmpty()
}
} }
companion object { companion object {

@ -7,21 +7,152 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.text.format.DateUtils import android.text.format.DateUtils
import android.util.SparseArray
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.parseAsHtml
import androidx.core.util.forEach
import androidx.core.util.isNotEmpty
import androidx.core.util.size
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState 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.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DownloadNotification(private val context: Context, startId: Int) { class DownloadNotification(private val context: Context) {
private val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val states = SparseArray<DownloadState>()
private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
private val queueIntent = PendingIntent.getActivity(
context,
REQUEST_QUEUE,
DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE,
)
private val localListIntent = PendingIntent.getActivity(
context,
REQUEST_LIST_LOCAL,
MangaListActivity.newIntent(context, MangaSource.LOCAL),
PendingIntentCompat.FLAG_IMMUTABLE,
)
init {
groupBuilder.setOnlyAlertOnce(true)
groupBuilder.setDefaults(0)
groupBuilder.color = ContextCompat.getColor(context, R.color.blue_primary)
groupBuilder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
groupBuilder.setSilent(true)
groupBuilder.setGroup(GROUP_ID)
groupBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
groupBuilder.setGroupSummary(true)
groupBuilder.setContentTitle(context.getString(R.string.downloading_manga))
}
fun buildGroupNotification(): Notification {
val style = NotificationCompat.InboxStyle(groupBuilder)
var progress = 0f
var isAllDone = true
var isInProgress = false
groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
states.forEach { _, state ->
if (state.manga.isNsfw) {
groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
}
val summary = when (state) {
is DownloadState.Cancelled -> {
progress++
context.getString(R.string.cancelling_)
}
is DownloadState.Done -> {
progress++
context.getString(R.string.download_complete)
}
is DownloadState.Error -> {
isAllDone = false
context.getString(R.string.error)
}
is DownloadState.PostProcessing -> {
progress++
isInProgress = true
isAllDone = false
context.getString(R.string.processing_)
}
is DownloadState.Preparing -> {
isAllDone = false
isInProgress = true
context.getString(R.string.preparing_)
}
is DownloadState.Progress -> {
isAllDone = false
isInProgress = true
progress += state.percent
context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
}
is DownloadState.Queued -> {
isAllDone = false
isInProgress = true
context.getString(R.string.queued)
}
}
style.addLine(
context.getString(
R.string.download_summary_pattern,
state.manga.title.ellipsize(16).htmlEncode(),
summary.htmlEncode(),
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY),
)
}
progress = if (isInProgress) {
progress / states.size.toFloat()
} else {
1f
}
style.setBigContentTitle(
context.getString(if (isAllDone) R.string.download_complete else R.string.downloading_manga),
)
groupBuilder.setContentText(context.resources.getQuantityString(R.plurals.items, states.size, states.size()))
groupBuilder.setNumber(states.size)
groupBuilder.setSmallIcon(
if (isInProgress) android.R.drawable.stat_sys_download else android.R.drawable.stat_sys_download_done,
)
groupBuilder.setContentIntent(if (isAllDone) localListIntent else queueIntent)
groupBuilder.setAutoCancel(isAllDone)
when (progress) {
1f -> groupBuilder.setProgress(0, 0, false)
0f -> groupBuilder.setProgress(1, 0, true)
else -> groupBuilder.setProgress(100, (progress * 100f).toInt(), false)
}
return groupBuilder.build()
}
fun detach() {
if (states.isNotEmpty()) {
val notification = buildGroupNotification()
manager.notify(ID_GROUP_DETACHED, notification)
}
manager.cancel(ID_GROUP)
}
fun newItem(startId: Int) = Item(startId)
inner class Item(
private val startId: Int,
) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID) private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val cancelAction = NotificationCompat.Action( private val cancelAction = NotificationCompat.Action(
@ -44,12 +175,6 @@ class DownloadNotification(private val context: Context, startId: Int) {
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
), ),
) )
private val listIntent = PendingIntent.getActivity(
context,
REQUEST_LIST,
DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE,
)
init { init {
builder.setOnlyAlertOnce(true) builder.setOnlyAlertOnce(true)
@ -57,17 +182,21 @@ class DownloadNotification(private val context: Context, startId: Int) {
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.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true) builder.setSilent(true)
builder.setGroup(GROUP_ID)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
} }
fun create(state: DownloadState, timeLeft: Long): Notification { fun notify(state: DownloadState, timeLeft: Long) {
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)
builder.setSmallIcon(android.R.drawable.stat_sys_download) builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(listIntent) builder.setContentIntent(queueIntent)
builder.setStyle(null) builder.setStyle(null)
builder.setLargeIcon(state.cover?.toBitmap()) builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions() builder.clearActions()
builder.setSubText(null)
builder.setShowWhen(false)
builder.setVisibility( builder.setVisibility(
if (state.manga.isNsfw) { if (state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE NotificationCompat.VISIBILITY_PRIVATE
@ -82,6 +211,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setContentIntent(null) builder.setContentIntent(null)
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true) builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Done -> { is DownloadState.Done -> {
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
@ -92,6 +222,9 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setCategory(null) builder.setCategory(null)
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(false) builder.setOngoing(false)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Error -> { is DownloadState.Error -> {
val message = state.error.getDisplayMessage(context.resources) val message = state.error.getDisplayMessage(context.resources)
@ -102,17 +235,21 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setAutoCancel(!state.canRetry) builder.setAutoCancel(!state.canRetry)
builder.setOngoing(state.canRetry) builder.setOngoing(state.canRetry)
builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
if (state.canRetry) { if (state.canRetry) {
builder.addAction(cancelAction) builder.addAction(cancelAction)
builder.addAction(retryAction) builder.addAction(retryAction)
} }
builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.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) builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Queued -> { is DownloadState.Queued -> {
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
@ -120,6 +257,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true) builder.setOngoing(true)
builder.addAction(cancelAction) builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_LOW
} }
is DownloadState.Preparing -> { is DownloadState.Preparing -> {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
@ -127,23 +265,41 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true) builder.setOngoing(true)
builder.addAction(cancelAction) builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
} }
is DownloadState.Progress -> { is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false) builder.setProgress(state.max, state.progress, false)
val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
if (timeLeft > 0L) { if (timeLeft > 0L) {
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS) val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
builder.setContentText(eta) builder.setContentText(eta)
builder.setSubText(percent)
} else { } else {
val percent = (state.percent * 100).format() builder.setContentText(percent)
builder.setContentText(context.getString(R.string.percent_string_pattern, percent))
} }
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true) builder.setOngoing(true)
builder.addAction(cancelAction) builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
}
val notification = builder.build()
states.append(startId, state)
updateGroupNotification()
manager.notify(TAG, startId, notification)
}
fun dismiss() {
manager.cancel(TAG, startId)
states.remove(startId)
updateGroupNotification()
} }
} }
return builder.build()
private fun updateGroupNotification() {
val notification = buildGroupNotification()
manager.notify(ID_GROUP, notification)
} }
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity( private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
@ -155,8 +311,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
companion object { companion object {
private const val TAG = "download"
private const val CHANNEL_ID = "download" private const val CHANNEL_ID = "download"
private const val REQUEST_LIST = 6 private const val GROUP_ID = "downloads"
private const val REQUEST_QUEUE = 6
private const val REQUEST_LIST_LOCAL = 7
const val ID_GROUP = 9999
private const val ID_GROUP_DETACHED = 9998
fun createChannel(context: Context) { fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

@ -8,6 +8,8 @@ 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.annotation.MainThread
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
@ -19,14 +21,12 @@ import javax.inject.Inject
import kotlin.collections.set import kotlin.collections.set
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseService import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
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.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.throttle import org.koitharu.kotatsu.utils.ext.throttle
import org.koitharu.kotatsu.utils.progress.PausingProgressJob import org.koitharu.kotatsu.utils.progress.PausingProgressJob
@ -37,7 +37,8 @@ import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
class DownloadService : BaseService() { class DownloadService : BaseService() {
private lateinit var downloadManager: DownloadManager private lateinit var downloadManager: DownloadManager
private lateinit var notificationSwitcher: ForegroundNotificationSwitcher private lateinit var downloadNotification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock
@Inject @Inject
lateinit var downloadManagerFactory: DownloadManager.Factory lateinit var downloadManagerFactory: DownloadManager.Factory
@ -49,13 +50,13 @@ class DownloadService : BaseService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
isRunning = true isRunning = true
notificationSwitcher = ForegroundNotificationSwitcher(this) downloadNotification = DownloadNotification(this)
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = downloadManagerFactory.create( downloadManager = downloadManagerFactory.create(lifecycleScope)
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)), wakeLock.acquire(TimeUnit.HOURS.toMillis(8))
)
DownloadNotification.createChannel(this) DownloadNotification.createChannel(this)
startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification())
val intentFilter = IntentFilter() val intentFilter = IntentFilter()
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL) intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
intentFilter.addAction(ACTION_DOWNLOAD_RESUME) intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
@ -71,7 +72,7 @@ class DownloadService : BaseService() {
jobCount.value = jobs.size jobCount.value = jobs.size
START_REDELIVER_INTENT START_REDELIVER_INTENT
} else { } else {
stopSelf(startId) stopSelfIfIdle()
START_NOT_STICKY START_NOT_STICKY
} }
} }
@ -83,6 +84,7 @@ class DownloadService : BaseService() {
override fun onDestroy() { override fun onDestroy() {
unregisterReceiver(controlReceiver) unregisterReceiver(controlReceiver)
wakeLock.release()
isRunning = false isRunning = false
super.onDestroy() super.onDestroy()
} }
@ -100,10 +102,10 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob<DownloadState>) { private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch { lifecycleScope.launch {
val startId = job.progressValue.startId val startId = job.progressValue.startId
val notification = DownloadNotification(this@DownloadService, startId) val notificationItem = downloadNotification.newItem(startId)
try { try {
val timeLeftEstimator = TimeLeftEstimator() val timeLeftEstimator = TimeLeftEstimator()
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L)) notificationItem.notify(job.progressValue, -1L)
job.progressAsFlow() job.progressAsFlow()
.onEach { state -> .onEach { state ->
if (state is DownloadState.Progress) { if (state is DownloadState.Progress) {
@ -116,7 +118,7 @@ class DownloadService : BaseService() {
.whileActive() .whileActive()
.collect { state -> .collect { state ->
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft() val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
notificationSwitcher.notify(startId, notification.create(state, timeLeft)) notificationItem.notify(state, timeLeft)
} }
job.join() job.join()
} finally { } finally {
@ -126,19 +128,18 @@ class DownloadService : BaseService() {
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)), .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)),
) )
} }
notificationSwitcher.detach(
startId,
if (job.isCancelled) { if (job.isCancelled) {
null notificationItem.dismiss()
} else { if (jobs.remove(startId) != null) {
notification.create(job.progressValue, -1L)
},
)
jobs.remove(job.progressValue.startId)
jobCount.value = jobs.size jobCount.value = jobs.size
stopSelf(startId) }
} else {
notificationItem.notify(job.progressValue, -1L)
} }
} }
}.invokeOnCompletion {
stopSelfIfIdle()
}
} }
private fun Flow<DownloadState>.whileActive(): Flow<DownloadState> = transformWhile { state -> private fun Flow<DownloadState>.whileActive(): Flow<DownloadState> = transformWhile { state ->
@ -149,6 +150,16 @@ class DownloadService : BaseService() {
private val DownloadState.isTerminal: Boolean private val DownloadState.isTerminal: Boolean
get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry) get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry)
@MainThread
private fun stopSelfIfIdle() {
if (jobs.any { (_, job) -> job.isActive }) {
return
}
downloadNotification.detach()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
inner class ControlReceiver : BroadcastReceiver() { inner class ControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
@ -167,12 +178,12 @@ class DownloadService : BaseService() {
class DownloadBinder(service: DownloadService) : Binder(), DefaultLifecycleObserver { class DownloadBinder(service: DownloadService) : Binder(), DefaultLifecycleObserver {
private var downloadsStateFlow = MutableStateFlow<Collection<PausingProgressJob<DownloadState>>>(emptyList()) private var downloadsStateFlow = MutableStateFlow<List<PausingProgressJob<DownloadState>>>(emptyList())
init { init {
service.lifecycle.addObserver(this) service.lifecycle.addObserver(this)
service.jobCount.onEach { service.jobCount.onEach {
downloadsStateFlow.value = service.jobs.values downloadsStateFlow.value = service.jobs.values.toList()
}.launchIn(service.lifecycleScope) }.launchIn(service.lifecycleScope)
} }

@ -1,62 +0,0 @@
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()) {
service.startForeground(startId, notification)
} else {
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 NotifyRunnable(
private val startId: Int,
private val notification: Notification?,
) : Runnable {
override fun run() {
if (notification != null) {
notificationManager.notify(startId, notification)
} else {
notificationManager.cancel(startId)
}
}
}
}

@ -275,7 +275,7 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
locks.lock(id) locks.lock(id)
} }
suspend fun unlockManga(id: Long) { fun unlockManga(id: Long) {
locks.unlock(id) locks.unlock(id)
} }

@ -33,7 +33,7 @@ class SettingsHeadersFragment : PreferenceHeaderFragmentCompat(), SlidingPaneLay
fun setTitle(title: CharSequence?) { fun setTitle(title: CharSequence?) {
currentTitle = title currentTitle = title
if (slidingPaneLayout.isOpen) { if (slidingPaneLayout.width != 0 && slidingPaneLayout.isOpen) {
activity?.title = title activity?.title = title
} }
} }

@ -1,14 +1,14 @@
package org.koitharu.kotatsu.utils package org.koitharu.kotatsu.utils
import android.util.ArrayMap import android.util.ArrayMap
import java.util.*
import kotlin.coroutines.coroutineContext
import kotlin.coroutines.resume
import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.util.*
import kotlin.coroutines.resume
class CompositeMutex<T : Any> : Set<T> { class CompositeMutex<T : Any> : Set<T> {
@ -35,7 +35,7 @@ class CompositeMutex<T : Any> : Set<T> {
} }
suspend fun lock(element: T) { suspend fun lock(element: T) {
while (currentCoroutineContext().isActive) { while (coroutineContext.isActive) {
waitForRemoval(element) waitForRemoval(element)
mutex.withLock { mutex.withLock {
if (data[element] == null) { if (data[element] == null) {
@ -46,12 +46,10 @@ class CompositeMutex<T : Any> : Set<T> {
} }
} }
suspend fun unlock(element: T) { fun unlock(element: T) {
val continuations = mutex.withLock { val continuations = checkNotNull(data.remove(element)) {
checkNotNull(data.remove(element)) {
"CompositeMutex is not locked for $element" "CompositeMutex is not locked for $element"
} }
}
continuations.forEach { c -> continuations.forEach { c ->
if (c.isActive) { if (c.isActive) {
c.resume(Unit) c.resume(Unit)

@ -1,45 +0,0 @@
package org.koitharu.kotatsu.utils
import android.app.Activity
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class LifecycleAwareServiceConnection(
private val host: Activity,
) : ServiceConnection, DefaultLifecycleObserver {
private val serviceStateFlow = MutableStateFlow<IBinder?>(null)
val service: StateFlow<IBinder?>
get() = serviceStateFlow
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
serviceStateFlow.value = service
}
override fun onServiceDisconnected(name: ComponentName?) {
serviceStateFlow.value = null
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
host.unbindService(this)
}
}
fun Activity.bindServiceWithLifecycle(
owner: LifecycleOwner,
service: Intent,
flags: Int
): LifecycleAwareServiceConnection {
val connection = LifecycleAwareServiceConnection(this)
bindService(service, connection, flags)
owner.lifecycle.addObserver(connection)
return connection
}

@ -49,7 +49,6 @@
android:gravity="center" android:gravity="center"
android:text="@string/text_downloads_holder" android:text="@string/text_downloads_holder"
android:textAppearance="?attr/textAppearanceBody2" android:textAppearance="?attr/textAppearanceBody2"
android:visibility="gone"
tools:visibility="visible" /> tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -360,6 +360,8 @@
<string name="removed_from_s">Removed from \"%s\"</string> <string name="removed_from_s">Removed from \"%s\"</string>
<string name="options">Options</string> <string name="options">Options</string>
<string name="not_found_404">Content not found or removed</string> <string name="not_found_404">Content not found or removed</string>
<string name="downloading_manga">Downloading manga</string>
<string name="download_summary_pattern" translatable="false">&lt;b>%1$s&lt;/b> %2$s</string>
<string name="incognito_mode">Incognito mode</string> <string name="incognito_mode">Incognito mode</string>
<string name="app_update_available_s">Application update available: %s</string> <string name="app_update_available_s">Application update available: %s</string>
<string name="no_chapters">No chapters</string> <string name="no_chapters">No chapters</string>

@ -1,17 +1,14 @@
package org.koitharu.kotatsu.utils package org.koitharu.kotatsu.utils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.yield
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
class CompositeMutexTest { class CompositeMutexTest {
@Test @Test
fun testSingleLock() = runTest { fun singleLock() = runTest {
val mutex = CompositeMutex<Int>() val mutex = CompositeMutex<Int>()
mutex.lock(1) mutex.lock(1)
mutex.lock(2) mutex.lock(2)
@ -22,7 +19,7 @@ class CompositeMutexTest {
} }
@Test @Test
fun testDoubleLock() = runTest { fun doubleLock() = runTest {
val mutex = CompositeMutex<Int>() val mutex = CompositeMutex<Int>()
repeat(2) { repeat(2) {
launch(Dispatchers.Default) { launch(Dispatchers.Default) {
@ -36,4 +33,20 @@ class CompositeMutexTest {
} }
assertNull(tryLock) assertNull(tryLock)
} }
@Test
fun cancellation() = runTest {
val mutex = CompositeMutex<Int>()
mutex.lock(1)
val job = launch {
try {
mutex.lock(1)
} finally {
mutex.unlock(1)
}
}
withTimeout(2000) {
job.cancelAndJoin()
}
}
} }
Loading…
Cancel
Save