From e8e95a485bcf947534d444977af83c897c5c1773 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 23 Jul 2021 06:51:01 +0300 Subject: [PATCH] Downloads queue activity --- app/src/main/AndroidManifest.xml | 6 +- .../kotatsu/details/ui/ChaptersFragment.kt | 2 +- .../kotatsu/details/ui/DetailsActivity.kt | 2 +- .../download/{ => domain}/DownloadManager.kt | 16 ++- .../kotatsu/download/ui/DownloadItemAD.kt | 101 +++++++++++++++++ .../kotatsu/download/ui/DownloadsActivity.kt | 58 ++++++++++ .../kotatsu/download/ui/DownloadsAdapter.kt | 38 +++++++ .../{ => ui/service}/DownloadNotification.kt | 30 +++--- .../{ => ui/service}/DownloadService.kt | 102 ++++++++++-------- .../ui/categories/CategoriesActivity.kt | 1 + .../org/koitharu/kotatsu/local/LocalModule.kt | 2 +- .../local/domain/LocalMangaRepository.kt | 45 ++++++-- .../kotatsu/local/ui/LocalListFragment.kt | 25 ++++- .../kotatsu/local/ui/LocalListViewModel.kt | 8 +- .../kotatsu/utils/DeferredStateFlow.kt | 22 ++++ .../koitharu/kotatsu/utils/JobStateFlow.kt | 21 ++++ .../utils/LifecycleAwareServiceConnection.kt | 49 +++++++++ .../koitharu/kotatsu/utils/LiveStateFlow.kt | 12 --- .../koitharu/kotatsu/utils/ext/FragmentExt.kt | 11 +- .../org/koitharu/kotatsu/utils/ext/ViewExt.kt | 13 +++ .../main/res/layout/activity_downloads.xml | 47 ++++++++ app/src/main/res/layout/item_download.xml | 97 +++++++++++++++++ app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 24 files changed, 620 insertions(+), 92 deletions(-) rename app/src/main/java/org/koitharu/kotatsu/download/{ => domain}/DownloadManager.kt (95%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt rename app/src/main/java/org/koitharu/kotatsu/download/{ => ui/service}/DownloadNotification.kt (86%) rename app/src/main/java/org/koitharu/kotatsu/download/{ => ui/service}/DownloadService.kt (67%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/LiveStateFlow.kt create mode 100644 app/src/main/res/layout/activity_downloads.xml create mode 100644 app/src/main/res/layout/item_download.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78f461918..7e221bce1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,7 @@ android:windowSoftInputMode="stateAlwaysHidden" /> @@ -83,9 +84,12 @@ + , JobStateFlow, 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt new file mode 100644 index 000000000..5b39881ed --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt @@ -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() { + + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt new file mode 100644 index 000000000..e6998f894 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt @@ -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>(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>() { + + override fun areItemsTheSame( + oldItem: JobStateFlow, + newItem: JobStateFlow, + ): Boolean { + return oldItem.value.startId == newItem.value.startId + } + + override fun areContentsTheSame( + oldItem: JobStateFlow, + newItem: JobStateFlow, + ): Boolean { + return oldItem.value == newItem.value + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt similarity index 86% rename from app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt rename to app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt index 33b2e938b..0d38a3326 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt @@ -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) { diff --git a/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt similarity index 67% rename from app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt rename to app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index dd7e63dae..bda34d8e2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -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>() + private val jobs = LinkedHashMap>() + 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(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(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?, - ): LiveStateFlow { + ): JobStateFlow { val initialState = DownloadManager.State.Queued(startId, manga, null) val stateFlow = MutableStateFlow(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> - get() = jobs.values + val downloads: Flow>> + 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? = 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 diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt index e2ad1153e..6b4217f77 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt @@ -44,6 +44,7 @@ class CategoriesActivity : BaseActivity(), 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()) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt index b2a756cc1..dbe2d43e1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt @@ -15,5 +15,5 @@ val localModule single { LocalMangaRepository(androidContext()) } factory(named(MangaSource.LOCAL)) { get() } - viewModel { LocalListViewModel(get(), get(), get(), get(), androidContext()) } + viewModel { LocalListViewModel(get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 2f82366af..59be0a2e5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -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() 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() + 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" } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index ba700ecff..319cf5619 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -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 { 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 { override fun onActivityResult(result: Uri?) { if (result != null) { - viewModel.importFile(result) + viewModel.importFile(context?.applicationContext ?: return, result) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 42b2d570b..1991e2c0e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -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() @@ -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) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt b/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt new file mode 100644 index 000000000..5cb69e049 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt @@ -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( + private val stateFlow: StateFlow, + private val deferred: Deferred, +) : StateFlow by stateFlow, Deferred by deferred { + + suspend fun collectAndAwait(): R { + return coroutineScope { + val collectJob = launchIn(this) + val result = await() + collectJob.cancelAndJoin() + result + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt b/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt new file mode 100644 index 000000000..05af51f10 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt @@ -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( + private val stateFlow: StateFlow, + private val job: Job, +) : StateFlow by stateFlow, Job by job { + + suspend fun collectAndJoin(): Unit { + coroutineScope { + val collectJob = launchIn(this) + join() + collectJob.cancelAndJoin() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt b/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt new file mode 100644 index 000000000..03dd423ea --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt @@ -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(null) + + val service: StateFlow + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/LiveStateFlow.kt b/app/src/main/java/org/koitharu/kotatsu/utils/LiveStateFlow.kt deleted file mode 100644 index f5b1d2863..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/LiveStateFlow.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.utils - -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.StateFlow - -class LiveStateFlow( - private val stateFlow: StateFlow, - private val job: Job, -) : StateFlow by stateFlow, Job by job { - - -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt index 92dab70d6..069d685ff 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt @@ -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.withArgs(size: Int, block: Bundle.() -> Unit): T { val b = Bundle(size) @@ -27,4 +30,10 @@ inline fun Fragment.parcelableArgument(name: String): Lazy { @Suppress("NOTHING_TO_INLINE") inline fun Fragment.stringArgument(name: String) = lazy(LazyThreadSafetyMode.NONE) { arguments?.getString(name) -} \ No newline at end of file +} + +fun Fragment.bindService( + lifecycleOwner: LifecycleOwner, + service: Intent, + flags: Int, +) = LifecycleAwareServiceConnection.bindService(requireActivity(), lifecycleOwner, service, flags) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt index c73cff93f..ac6950379 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt @@ -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 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 + } + } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_downloads.xml b/app/src/main/res/layout/activity_downloads.xml new file mode 100644 index 000000000..65096b931 --- /dev/null +++ b/app/src/main/res/layout/activity_downloads.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_download.xml b/app/src/main/res/layout/item_download.xml new file mode 100644 index 000000000..b70971dc2 --- /dev/null +++ b/app/src/main/res/layout/item_download.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 227d005eb..70c4cd7ab 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -217,4 +217,6 @@ Резервная копия успешно сохранена Некоторые производители могут изменять поведение системы, нарушая работу фоновых задач. Подробнее + В очереди + На данный момент нет активных загрузок \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 615dd5eb8..1eabb0ef4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -220,4 +220,6 @@ Backup saved successfully Some manufacturers can change the system behavior, which may breaks background tasks. Read more + Queued + There are currently no active downloads \ No newline at end of file