PageSaveHelper refactor

This commit is contained in:
Koitharu
2024-10-27 18:02:31 +02:00
parent 90f0846fb4
commit a0de73a7ed
4 changed files with 128 additions and 87 deletions

View File

@@ -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<PageLoader>,
) : ActivityResultCallback<Uri?> {
private var continuation: Continuation<Uri>? = 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<String>,
): Uri {
val pageUrl = pageLoader.getPageUrl(page)
private var continuation: CancellableContinuation<Uri>? = null
override fun onActivityResult(result: Uri?) {
continuation?.also { cont ->
if (result != null) {
cont.resume(result)
} else {
cont.cancel()
}
}
}
suspend fun save(pages: Collection<MangaPage>): 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<MangaPage>) {
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 <I> ActivityResultLauncher<I>.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<String>, 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"
}
}

View File

@@ -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<Uri?>,
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) {

View File

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

View File

@@ -56,7 +56,7 @@
<string name="remove">Remove</string>
<string name="_s_deleted_from_local_storage">\"%s\" deleted from local storage</string>
<string name="save_page">Save page</string>
<string name="page_saved">Saved</string>
<string name="page_saved">Page saved</string>
<string name="share_image">Share image</string>
<string name="_import">Import</string>
<string name="delete">Delete</string>