PageSaveHelper refactor
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user