Downloads queue activity

This commit is contained in:
Koitharu
2021-07-23 06:51:01 +03:00
parent 77186d271d
commit e8e95a485b
24 changed files with 620 additions and 92 deletions

View File

@@ -68,6 +68,7 @@
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
android:exported="true"
android:label="@string/manga_shelf">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
@@ -83,9 +84,12 @@
<activity
android:name=".settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:label="@string/downloads" />
<service
android:name="org.koitharu.kotatsu.download.DownloadService"
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:foregroundServiceType="dataSync" />
<service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"

View File

@@ -22,7 +22,7 @@ import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.download.DownloadService
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState

View File

@@ -33,7 +33,7 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.download.DownloadService
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.download
package org.koitharu.kotatsu.download.domain
import android.content.Context
import android.graphics.drawable.Drawable
@@ -6,6 +6,7 @@ import android.net.ConnectivityManager
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
@@ -60,6 +61,7 @@ class DownloadManager(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build()
).drawable
}.getOrNull()
@@ -98,7 +100,8 @@ class DownloadManager(
}
} while (false)
emit(State.Progress(startId, manga, cover,
emit(State.Progress(
startId, manga, cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
@@ -186,7 +189,14 @@ class DownloadManager(
val currentChapter: Int,
val totalPages: Int,
val currentPage: Int,
): State
): 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,

View File

@@ -0,0 +1,101 @@
package org.koitharu.kotatsu.download.ui
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
import org.koitharu.kotatsu.utils.ext.format
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat
fun downloadItemAD(
scope: CoroutineScope,
) = adapterDelegateViewBinding<JobStateFlow<DownloadManager.State>, JobStateFlow<DownloadManager.State>, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
) {
var job: Job? = null
bind {
job?.cancel()
job = item.onEach { state ->
binding.textViewTitle.text = state.manga.title
binding.imageViewCover.setImageDrawable(
state.cover ?: getDrawable(R.drawable.ic_placeholder)
)
when (state) {
is DownloadManager.State.Cancelling -> {
binding.textViewStatus.setText(R.string.cancelling_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Done -> {
binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Error -> {
binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
binding.textViewDetails.isVisible = true
}
is DownloadManager.State.PostProcessing -> {
binding.textViewStatus.setText(R.string.processing_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Preparing -> {
binding.textViewStatus.setText(R.string.preparing_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Progress -> {
binding.textViewStatus.setText(R.string.manga_downloading_)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = true
binding.progressBar.max = state.max
binding.progressBar.setProgressCompat(state.progress, true)
binding.textViewPercent.text = (state.percent * 100f).format(1) + "%"
binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Queued -> {
binding.textViewStatus.setText(R.string.queued)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.WaitingForNetwork -> {
binding.textViewStatus.setText(R.string.waiting_for_network)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
}
}.launchIn(scope)
}
onViewRecycled {
job?.cancel()
job = null
}
}

View File

@@ -0,0 +1,58 @@
package org.koitharu.kotatsu.download.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = DownloadsAdapter(lifecycleScope)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
LifecycleAwareServiceConnection.bindService(
this,
this,
Intent(this, DownloadService::class.java),
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) {
binding.recyclerView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom
)
binding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
top = insets.top
)
}
companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
}
}

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.download.ui
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
class DownloadsAdapter(
scope: CoroutineScope,
) : AsyncListDifferDelegationAdapter<JobStateFlow<DownloadManager.State>>(DiffCallback()) {
init {
delegatesManager.addDelegate(downloadItemAD(scope))
setHasStableIds(true)
}
override fun getItemId(position: Int): Long {
return items[position].value.startId.toLong()
}
private class DiffCallback : DiffUtil.ItemCallback<JobStateFlow<DownloadManager.State>>() {
override fun areItemsTheSame(
oldItem: JobStateFlow<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>,
): Boolean {
return oldItem.value.startId == newItem.value.startId
}
override fun areContentsTheSame(
oldItem: JobStateFlow<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>,
): Boolean {
return oldItem.value == newItem.value
}
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.download
package org.koitharu.kotatsu.download.ui.service
import android.app.Notification
import android.app.NotificationChannel
@@ -13,9 +13,11 @@ import androidx.core.graphics.drawable.toBitmap
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.format
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import kotlin.math.roundToInt
class DownloadNotification(
private val context: Context,
@@ -26,13 +28,19 @@ class DownloadNotification(
private val cancelAction = NotificationCompat.Action(
R.drawable.ic_cross,
context.getString(android.R.string.cancel),
PendingIntent.getService(
PendingIntent.getBroadcast(
context,
startId,
DownloadService.getCancelIntent(context, startId),
DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
private val listIntent = PendingIntent.getActivity(
context,
REQUEST_LIST,
DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE,
)
init {
builder.setOnlyAlertOnce(true)
@@ -45,7 +53,7 @@ class DownloadNotification(
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(null)
builder.setContentIntent(listIntent)
builder.setStyle(null)
builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions()
@@ -72,7 +80,6 @@ class DownloadNotification(
builder.setSubText(context.getString(R.string.error))
builder.setContentText(message)
builder.setAutoCancel(true)
builder.setContentIntent(null)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
@@ -89,13 +96,8 @@ class DownloadNotification(
builder.addAction(cancelAction)
}
is DownloadManager.State.Progress -> {
val max = state.totalChapters * PROGRESS_STEP
val progress = state.currentChapter * PROGRESS_STEP +
(state.currentPage / state.totalPages.toFloat() * PROGRESS_STEP)
.roundToInt()
val percent = (progress / max.toFloat() * 100).roundToInt()
builder.setProgress(max, progress, false)
builder.setContentText("%d%%".format(percent))
builder.setProgress(state.max, state.progress, false)
builder.setContentText((state.percent * 100).format() + "%")
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.addAction(cancelAction)
@@ -120,7 +122,7 @@ class DownloadNotification(
companion object {
private const val CHANNEL_ID = "download"
private const val PROGRESS_STEP = 20
private const val REQUEST_LIST = 6
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.download
package org.koitharu.kotatsu.download.ui.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.Binder
import android.os.IBinder
@@ -11,11 +13,16 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get
import org.koin.core.context.GlobalContext
import org.koitharu.kotatsu.BuildConfig
@@ -24,9 +31,9 @@ import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.LiveStateFlow
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
import org.koitharu.kotatsu.utils.ext.toArraySet
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.collections.set
@@ -35,10 +42,11 @@ class DownloadService : BaseService() {
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var downloadManager: DownloadManager
private lateinit var dispatcher: ExecutorCoroutineDispatcher
private val jobs = HashMap<Int, LiveStateFlow<DownloadManager.State>>()
private val jobs = LinkedHashMap<Int, JobStateFlow<DownloadManager.State>>()
private val jobCount = MutableStateFlow(0)
private val mutex = Mutex()
private val controlReceiver = ControlReceiver()
override fun onCreate() {
super.onCreate()
@@ -46,37 +54,23 @@ class DownloadService : BaseService() {
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = DownloadManager(this, get(), get(), get(), get(), get())
dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
DownloadNotification.createChannel(this)
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
when (intent?.action) {
ACTION_DOWNLOAD_START -> {
val manga = intent.getParcelableExtra<Manga>(EXTRA_MANGA)
val chapters = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
if (manga != null) {
jobs[startId] = downloadManga(startId, manga, chapters)
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
return START_REDELIVER_INTENT
} else {
stopSelf(startId)
}
}
ACTION_DOWNLOAD_CANCEL -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs.remove(cancelId)?.cancel()
stopSelf(startId)
}
else -> stopSelf(startId)
val manga = intent?.getParcelableExtra<Manga>(EXTRA_MANGA)
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
return if (manga != null) {
jobs[startId] = downloadManga(startId, manga, chapters)
jobCount.value = jobs.size
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
START_REDELIVER_INTENT
} else {
stopSelf(startId)
START_NOT_STICKY
}
return START_NOT_STICKY
}
override fun onDestroy() {
super.onDestroy()
dispatcher.close()
}
override fun onBind(intent: Intent): IBinder {
@@ -84,11 +78,16 @@ class DownloadService : BaseService() {
return DownloadBinder()
}
override fun onDestroy() {
unregisterReceiver(controlReceiver)
super.onDestroy()
}
private fun downloadManga(
startId: Int,
manga: Manga,
chaptersIds: Set<Long>?,
): LiveStateFlow<DownloadManager.State> {
): JobStateFlow<DownloadManager.State> {
val initialState = DownloadManager.State.Queued(startId, manga, null)
val stateFlow = MutableStateFlow<DownloadManager.State>(initialState)
val job = lifecycleScope.launch {
@@ -97,13 +96,19 @@ class DownloadService : BaseService() {
val notification = DownloadNotification(this@DownloadService, startId)
startForeground(startId, notification.create(initialState))
try {
withContext(dispatcher) {
withContext(Dispatchers.Default) {
downloadManager.downloadManga(manga, chaptersIds, startId)
.collect { state ->
stateFlow.value = state
notificationManager.notify(startId, notification.create(state))
}
}
if (stateFlow.value is DownloadManager.State.Done) {
sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, manga)
)
}
} finally {
ServiceCompat.stopForeground(
this@DownloadService,
@@ -120,19 +125,33 @@ class DownloadService : BaseService() {
}
}
}
return LiveStateFlow(stateFlow, job)
return JobStateFlow(stateFlow, job)
}
inner class ControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
when (intent?.action) {
ACTION_DOWNLOAD_CANCEL -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs.remove(cancelId)?.cancel()
jobCount.value = jobs.size
}
}
}
}
inner class DownloadBinder : Binder() {
val downloads: Collection<LiveStateFlow<DownloadManager.State>>
get() = jobs.values
val downloads: Flow<Collection<JobStateFlow<DownloadManager.State>>>
get() = jobCount.mapLatest { jobs.values }
}
companion object {
private const val ACTION_DOWNLOAD_START =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_START"
const val ACTION_DOWNLOAD_COMPLETE =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
private const val ACTION_DOWNLOAD_CANCEL =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
@@ -143,7 +162,6 @@ class DownloadService : BaseService() {
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
confirmDataTransfer(context) {
val intent = Intent(context, DownloadService::class.java)
intent.action = ACTION_DOWNLOAD_START
intent.putExtra(EXTRA_MANGA, manga)
if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
@@ -152,10 +170,8 @@ class DownloadService : BaseService() {
}
}
fun getCancelIntent(context: Context, startId: Int) =
Intent(context, DownloadService::class.java)
.setAction(ACTION_DOWNLOAD_CANCEL)
.putExtra(ACTION_DOWNLOAD_CANCEL, startId)
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(ACTION_DOWNLOAD_CANCEL, startId)
private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

View File

@@ -44,6 +44,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
adapter = CategoriesAdapter(this)
editDelegate = CategoriesEditDelegate(this, this)
binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
binding.fabAdd.setOnClickListener(this)
reorderHelper = ItemTouchHelper(ReorderHelperCallback())

View File

@@ -15,5 +15,5 @@ val localModule
single { LocalMangaRepository(androidContext()) }
factory<MangaRepository>(named(MangaSource.LOCAL)) { get<LocalMangaRepository>() }
viewModel { LocalListViewModel(get(), get(), get(), get(), androidContext()) }
viewModel { LocalListViewModel(get(), get(), get(), get()) }
}

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.sub
import org.koitharu.kotatsu.utils.ext.toCamelCase
import java.io.File
import java.util.*
import java.util.zip.ZipEntry
@@ -36,8 +37,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
require(offset == 0) {
"LocalMangaRepository does not support pagination"
}
val files = getAvailableStorageDirs(context)
.flatMap { x -> x.listFiles(filenameFilter)?.toList().orEmpty() }
val files = getAllFiles()
return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() }
}
@@ -102,7 +102,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
)
}
// fallback
val title = file.nameWithoutExtension.replace("_", " ").capitalize()
val title = file.nameWithoutExtension.replace("_", " ").toCamelCase()
val chapters = ArraySet<String>()
for (x in zip.entries()) {
if (!x.isDirectory) {
@@ -120,7 +120,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
MangaChapter(
id = "$i$s".longHashCode(),
name = if (s.isEmpty()) title else s,
name = s.ifEmpty { title },
number = i + 1,
source = MangaSource.LOCAL,
url = uriBuilder.fragment(s).build().toString()
@@ -134,13 +134,36 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
Uri.parse(localManga.url).toFile()
}.getOrNull() ?: return null
return withContext(Dispatchers.IO) {
val zip = ZipFile(file)
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
index.getMangaInfo()
@Suppress("BlockingMethodInNonBlockingContext")
ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
index.getMangaInfo()
}
}
}
suspend fun findSavedManga(remoteManga: Manga): Manga? = withContext(Dispatchers.IO) {
val files = getAllFiles()
for (file in files) {
@Suppress("BlockingMethodInNonBlockingContext")
val index = ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
entry?.let(zip::readText)?.let(::MangaIndex)
} ?: continue
val info = index.getMangaInfo() ?: continue
if (info.id == remoteManga.id) {
val fileUri = file.toUri().toString()
return@withContext info.copy(
source = MangaSource.LOCAL,
url = fileUri,
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
)
}
}
null
}
private fun zipUri(file: File, entryName: String) =
Uri.fromParts("cbz", file.path, entryName).toString()
@@ -165,12 +188,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
override suspend fun getTags() = emptySet<MangaTag>()
private fun getAllFiles() = getAvailableStorageDirs(context).flatMap { dir ->
dir.listFiles(filenameFilter)?.toList().orEmpty()
}
companion object {
private const val DIR_NAME = "manga"
fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT)
val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}

View File

@@ -1,6 +1,6 @@
package org.koitharu.kotatsu.local.ui
import android.content.ActivityNotFoundException
import android.content.*
import android.net.Uri
import android.os.Bundle
import android.view.Menu
@@ -15,6 +15,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ext.ellipsize
@@ -25,12 +26,32 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
ActivityResultContracts.OpenDocument(),
this
)
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == DownloadService.ACTION_DOWNLOAD_COMPLETE) {
viewModel.onRefresh()
}
}
}
override fun onAttach(context: Context) {
super.onAttach(context)
context.registerReceiver(
downloadReceiver,
IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
}
override fun onDetach() {
requireContext().unregisterReceiver(downloadReceiver)
super.onDetach()
}
override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@@ -65,7 +86,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
override fun onActivityResult(result: Uri?) {
if (result != null) {
viewModel.importFile(result)
viewModel.importFile(context?.applicationContext ?: return, result)
}
}

View File

@@ -19,7 +19,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File
import java.io.IOException
class LocalListViewModel(
@@ -27,7 +27,6 @@ class LocalListViewModel(
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
private val shortcutsRepository: ShortcutsRepository,
private val context: Context
) : MangaListViewModel(settings) {
val onMangaRemoved = SingleLiveEvent<Manga>()
@@ -71,7 +70,7 @@ class LocalListViewModel(
override fun onRetry() = onRefresh()
fun importFile(uri: Uri) {
fun importFile(context: Context, uri: Uri) {
launchLoadingJob {
val contentResolver = context.contentResolver
withContext(Dispatchers.IO) {
@@ -80,8 +79,9 @@ class LocalListViewModel(
if (!LocalMangaRepository.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri")
}
val dest = settings.getStorageDir(context)?.sub(name)
val dest = settings.getStorageDir(context)?.let { File(it, name) }
?: throw IOException("External files dir unavailable")
@Suppress("BlockingMethodInNonBlockingContext")
contentResolver.openInputStream(uri)?.use { source ->
dest.outputStream().use { output ->
source.copyTo(output)

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
class DeferredStateFlow<R, S>(
private val stateFlow: StateFlow<S>,
private val deferred: Deferred<R>,
) : StateFlow<S> by stateFlow, Deferred<R> by deferred {
suspend fun collectAndAwait(): R {
return coroutineScope {
val collectJob = launchIn(this)
val result = await()
collectJob.cancelAndJoin()
result
}
}
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
class JobStateFlow<S>(
private val stateFlow: StateFlow<S>,
private val job: Job,
) : StateFlow<S> by stateFlow, Job by job {
suspend fun collectAndJoin(): Unit {
coroutineScope {
val collectJob = launchIn(this)
join()
collectJob.cancelAndJoin()
}
}
}

View File

@@ -0,0 +1,49 @@
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 constructor(
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)
}
companion object {
fun bindService(
host: Activity,
lifecycleOwner: LifecycleOwner,
service: Intent,
flags: Int,
): LifecycleAwareServiceConnection {
val connection = LifecycleAwareServiceConnection(host)
host.bindService(service, connection, flags)
lifecycleOwner.lifecycle.addObserver(connection)
return connection
}
}
}

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
class LiveStateFlow<T>(
private val stateFlow: StateFlow<T>,
private val job: Job,
) : StateFlow<T> by stateFlow, Job by job {
}

View File

@@ -1,9 +1,12 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import androidx.fragment.app.Fragment
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.coroutineScope
import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection
inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val b = Bundle(size)
@@ -27,4 +30,10 @@ inline fun <T : Parcelable> Fragment.parcelableArgument(name: String): Lazy<T> {
@Suppress("NOTHING_TO_INLINE")
inline fun Fragment.stringArgument(name: String) = lazy(LazyThreadSafetyMode.NONE) {
arguments?.getString(name)
}
}
fun Fragment.bindService(
lifecycleOwner: LifecycleOwner,
service: Intent,
flags: Int,
) = LifecycleAwareServiceConnection.bindService(requireActivity(), lifecycleOwner, service, flags)

View File

@@ -16,6 +16,7 @@ import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
fun View.hideKeyboard() {
@@ -158,4 +159,16 @@ fun RecyclerView.findCenterViewPosition(): Int {
inline fun <reified T> RecyclerView.ViewHolder.getItem(): T? {
return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T)
}
fun BaseProgressIndicator<*>.setIndeterminateCompat(indeterminate: Boolean) {
if (isIndeterminate != indeterminate) {
if (indeterminate && visibility == View.VISIBLE) {
visibility = View.INVISIBLE
isIndeterminate = indeterminate
visibility = View.VISIBLE
} else {
isIndeterminate = indeterminate
}
}
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
style="@style/Widget.Kotatsu.AppBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="4dp">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/Widget.Kotatsu.Toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
<TextView
android:id="@+id/textView_holder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="20dp"
android:gravity="center"
android:text="@string/text_downloads_holder"
android:textAppearance="?android:textAppearanceMedium"
android:textColor="?android:textColorSecondary"
android:visibility="gone"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingStart="16dp"
android:paddingTop="4dp"
android:paddingEnd="16dp"
android:paddingBottom="6dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_cover"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:cardCornerRadius="4dp"
app:cardElevation="4dp"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/imageView_cover"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@tools:sample/backgrounds/scenic" />
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/card_cover"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/lorem" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/card_cover"
app:layout_constraintTop_toBottomOf="@id/textView_title" />
<TextView
android:id="@+id/textView_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/card_cover"
app:layout_constraintTop_toBottomOf="@id/progressBar"
tools:text="@string/manga_downloading_" />
<TextView
android:id="@+id/textView_percent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
app:layout_constraintBaseline_toBaselineOf="@id/textView_status"
app:layout_constraintEnd_toEndOf="parent"
tools:text="25%" />
<TextView
android:id="@+id/textView_details"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="4"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/card_cover"
app:layout_constraintTop_toBottomOf="@id/textView_status"
tools:text="@tools:sample/lorem[3]" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -217,4 +217,6 @@
<string name="backup_saved">Резервная копия успешно сохранена</string>
<string name="tracker_warning">Некоторые производители могут изменять поведение системы, нарушая работу фоновых задач.</string>
<string name="read_more">Подробнее</string>
<string name="queued">В очереди</string>
<string name="text_downloads_holder">На данный момент нет активных загрузок</string>
</resources>

View File

@@ -220,4 +220,6 @@
<string name="backup_saved">Backup saved successfully</string>
<string name="tracker_warning">Some manufacturers can change the system behavior, which may breaks background tasks.</string>
<string name="read_more">Read more</string>
<string name="queued">Queued</string>
<string name="text_downloads_holder">There are currently no active downloads</string>
</resources>