diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index 2a0e7732f..755f183c5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -4,15 +4,22 @@ import android.content.Context import android.graphics.BitmapFactory import android.net.Uri import android.webkit.MimeTypeMap +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toFile import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import okhttp3.HttpUrl.Companion.toHttpUrl import okio.FileSystem import okio.IOException import okio.Path.Companion.toPath @@ -30,48 +37,98 @@ import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.reader.domain.PageLoader import java.io.File -import javax.inject.Inject -import kotlin.coroutines.Continuation +import javax.inject.Provider import kotlin.coroutines.resume -private const val MAX_FILENAME_LENGTH = 10 -private const val EXTENSION_FALLBACK = "png" - -class PageSaveHelper @Inject constructor( +class PageSaveHelper @AssistedInject constructor( + @Assisted activityResultCaller: ActivityResultCaller, @ApplicationContext private val context: Context, private val settings: AppSettings, -) { + private val pageLoaderProvider: Provider, +) : ActivityResultCallback { - private var continuation: Continuation? = null - private val contentResolver = context.contentResolver + private val savePageRequest = activityResultCaller.registerForActivityResult(PageSaveContract(), this) + private val pickDirectoryRequest = + activityResultCaller.registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), this) - suspend fun savePage( - pageLoader: PageLoader, - page: MangaPage, - saveLauncher: ActivityResultLauncher, - ): Uri { - val pageUrl = pageLoader.getPageUrl(page) + private var continuation: CancellableContinuation? = null + + override fun onActivityResult(result: Uri?) { + continuation?.also { cont -> + if (result != null) { + cont.resume(result) + } else { + cont.cancel() + } + } + } + + suspend fun save(pages: Collection): Uri? = when (pages.size) { + 0 -> null + 1 -> saveImpl(pages.first()) + else -> { + saveImpl(pages) + null + } + } + + private suspend fun saveImpl(page: MangaPage): Uri { + val pageLoader = pageLoaderProvider.get() + val pageUrl = pageLoader.getPageUrl(page).toUri() val pageUri = pageLoader.loadPage(page, force = false) val proposedName = getProposedFileName(pageUrl, pageUri) - val destination = getDefaultFileUri(proposedName) ?: pickFileUri(saveLauncher, proposedName) - runInterruptible(Dispatchers.IO) { - contentResolver.openOutputStream(destination)?.sink()?.buffer() - }?.use { output -> - getSource(pageUri).use { input -> - output.writeAllCancellable(input) - } - } ?: throw IOException("Output stream is null") + val destination = getDefaultFileUri(proposedName)?.uri ?: run { + val defaultUri = settings.getPagesSaveDir(context)?.uri?.buildUpon()?.appendPath(proposedName)?.toString() + savePageRequest.launchAndAwait(defaultUri ?: proposedName) + } + copyImpl(pageUri, destination) return destination } - private fun getDefaultFileUri(proposedName: String): Uri? { + private suspend fun saveImpl(pages: Collection) { + val pageLoader = pageLoaderProvider.get() + val destinationDir = getDefaultFileUri(null) ?: run { + val defaultUri = settings.getPagesSaveDir(context)?.uri + DocumentFile.fromTreeUri(context, pickDirectoryRequest.launchAndAwait(defaultUri)) + } ?: throw IOException("Cannot get destination directory") + for (page in pages) { + val pageUrl = pageLoader.getPageUrl(page).toUri() + val pageUri = pageLoader.loadPage(page, force = false) + val proposedName = getProposedFileName(pageUrl, pageUri) + val ext = proposedName.substringAfterLast('.', "") + val mime = requireNotNull(MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)) { + "Unknown type of $proposedName" + } + val destination = destinationDir.createFile(mime, proposedName.substringBeforeLast('.')) + copyImpl(pageUri, destination?.uri ?: throw IOException("Cannot create destination file")) + } + } + + private suspend fun ActivityResultLauncher.launchAndAwait(input: I): Uri { + continuation?.cancel() + return withContext(Dispatchers.Main) { + try { + suspendCancellableCoroutine { cont -> + continuation = cont + launch(input) + } + } finally { + continuation = null + } + } + } + + private fun getDefaultFileUri(proposedName: String?): DocumentFile? { if (settings.isPagesSavingAskEnabled) { return null } - return settings.getPagesSaveDir(context)?.let { + val dir = settings.getPagesSaveDir(context) ?: return null + if (proposedName == null) { + return dir + } else { val ext = proposedName.substringAfterLast('.', "") val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null - it.createFile(mime, proposedName.substringBeforeLast('.'))?.uri + return dir.createFile(mime, proposedName.substringBeforeLast('.')) } } @@ -83,28 +140,24 @@ class PageSaveHelper @Inject constructor( else -> throw IllegalArgumentException("Bad uri $uri: unsupported scheme") } - private suspend fun pickFileUri(saveLauncher: ActivityResultLauncher, proposedName: String): Uri { - val defaultUri = settings.getPagesSaveDir(context)?.uri?.buildUpon()?.appendPath(proposedName)?.toString() - return withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - continuation = cont - saveLauncher.launch(defaultUri ?: proposedName) - }.also { - continuation = null + private suspend fun copyImpl(source: Uri, destination: Uri) = withContext(Dispatchers.IO) { + runInterruptible { + context.contentResolver.openOutputStream(destination) ?: throw IOException("Output stream is null") + }.sink().buffer().use { sink -> + getSource(source).use { input -> + sink.writeAllCancellable(input) } } } - fun onActivityResult(uri: Uri): Boolean = continuation?.apply { - resume(uri) - } != null - - private suspend fun getProposedFileName(url: String, fileUri: Uri): String { - var name = if (url.startsWith("cbz:")) { - requireNotNull(url.toUri().fragment) - } else { - url.toHttpUrl().pathSegments.last() - } + private suspend fun getProposedFileName(url: Uri, fileUri: Uri): String { + var name = requireNotNull( + if (url.isZipUri()) { + url.fragment?.substringAfterLast(File.separatorChar) + } else { + url.lastPathSegment + }, + ) { "Invalid page url: $url" } var extension = name.substringAfterLast('.', "") name = name.substringBeforeLast('.') if (extension.length !in 2..4) { @@ -125,4 +178,16 @@ class PageSaveHelper @Inject constructor( BitmapFactory.decodeFile(file.path, options)?.recycle() options.outMimeType } + + @AssistedFactory + interface Factory { + + fun create(activityResultCaller: ActivityResultCaller): PageSaveHelper + } + + private companion object { + + private const val MAX_FILENAME_LENGTH = 16 + private const val EXTENSION_FALLBACK = "png" + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 6553b7db2..e59e4d019 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -14,7 +14,6 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup.MarginLayoutParams import android.view.WindowManager -import androidx.activity.result.ActivityResultCallback import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.core.view.OnApplyWindowInsetsListener @@ -74,7 +73,6 @@ class ReaderActivity : OnApplyWindowInsetsListener, ReaderNavigationCallback, IdlingDetector.Callback, - ActivityResultCallback, ZoomControl.ZoomControlListener { @Inject @@ -83,6 +81,9 @@ class ReaderActivity : @Inject lateinit var tapGridSettings: TapGridSettings + @Inject + lateinit var pageSaveHelperFactory: PageSaveHelper.Factory + @Inject lateinit var scrollTimerFactory: ScrollTimer.Factory @@ -90,7 +91,6 @@ class ReaderActivity : lateinit var screenOrientationHelper: ScreenOrientationHelper private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this) - private val savePageRequest = registerForActivityResult(PageSaveContract(), this) private val viewModel: ReaderViewModel by viewModels() @@ -104,6 +104,7 @@ class ReaderActivity : } private lateinit var scrollTimer: ScrollTimer + private lateinit var pageSaveHelper: PageSaveHelper private lateinit var touchHelper: TapGridDispatcher private lateinit var controlDelegate: ReaderControlDelegate private var gestureInsets: Insets = Insets.NONE @@ -118,6 +119,7 @@ class ReaderActivity : supportActionBar?.setDisplayHomeAsUpEnabled(true) touchHelper = TapGridDispatcher(this, this) scrollTimer = scrollTimerFactory.create(this, this) + pageSaveHelper = pageSaveHelperFactory.create(this) controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this) viewBinding.slider.setLabelFormatter(PageLabelFormatter()) viewBinding.zoomControl.listener = this @@ -163,10 +165,6 @@ class ReaderActivity : viewBinding.toolbarBottom.addMenuProvider(ReaderBottomMenuProvider(this, readerManager, viewModel)) } - override fun onActivityResult(result: Uri?) { - viewModel.onActivityResult(result) - } - override fun getParentActivityIntent(): Intent? { val manga = viewModel.getMangaOrNull() ?: return null return DetailsActivity.newIntent(this, manga) @@ -292,15 +290,14 @@ class ReaderActivity : } private fun onPageSaved(uri: Uri?) { + val snackbar = Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG) if (uri != null) { - Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG) - .setAction(R.string.share) { - ShareHelper(this).shareImage(uri) - } - } else { - Snackbar.make(viewBinding.container, R.string.error_occurred, Snackbar.LENGTH_SHORT) - }.setAnchorView(viewBinding.appbarBottom) - .show() + snackbar.setAction(R.string.share) { + ShareHelper(this).shareImage(uri) + } + } + snackbar.setAnchorView(viewBinding.appbarBottom) + snackbar.show() } private fun setKeepScreenOn(isKeep: Boolean) { @@ -383,8 +380,7 @@ class ReaderActivity : } override fun onSavePageClick() { - val page = viewModel.getCurrentPage() ?: return - viewModel.saveCurrentPage(page, savePageRequest) + viewModel.saveCurrentPage(pageSaveHelper) } private fun onReaderBarChanged(isBarEnabled: Boolean) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index 852fba563..d6a411358 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -1,14 +1,12 @@ package org.koitharu.kotatsu.reader.ui import android.net.Uri -import androidx.activity.result.ActivityResultLauncher import androidx.annotation.AnyThread import androidx.annotation.MainThread import androidx.annotation.WorkerThread import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -44,7 +42,6 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.sizeOrZero import org.koitharu.kotatsu.details.data.MangaDetails @@ -80,7 +77,6 @@ class ReaderViewModel @Inject constructor( private val historyRepository: HistoryRepository, private val bookmarksRepository: BookmarksRepository, settings: AppSettings, - private val pageSaveHelper: PageSaveHelper, private val pageLoader: PageLoader, private val chaptersLoader: ChaptersLoader, private val appShortcutManager: AppShortcutManager, @@ -256,30 +252,14 @@ class ReaderViewModel @Inject constructor( } fun saveCurrentPage( - page: MangaPage, - saveLauncher: ActivityResultLauncher, + pageSaveHelper: PageSaveHelper ) { val prevJob = pageSaveJob pageSaveJob = launchLoadingJob(Dispatchers.Default) { prevJob?.cancelAndJoin() - try { - val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher) - onPageSaved.call(dest) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - e.printStackTraceDebug() - onPageSaved.call(null) - } - } - } - - fun onActivityResult(uri: Uri?) { - if (uri != null) { - pageSaveHelper.onActivityResult(uri) - } else { - pageSaveJob?.cancel() - pageSaveJob = null + val currentPage = checkNotNull(getCurrentPage()) { "Cannot find current page" } + val dest = pageSaveHelper.save(setOf(currentPage)) + onPageSaved.call(dest) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b6b3cd656..d37afee10 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,7 +56,7 @@ Remove \"%s\" deleted from local storage Save page - Saved + Page saved Share image Import Delete