PageSaveHelper refactor

master
Koitharu 2 years ago
parent 90f0846fb4
commit a0de73a7ed
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -4,15 +4,22 @@ import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri 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 dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.FileSystem import okio.FileSystem
import okio.IOException import okio.IOException
import okio.Path.Companion.toPath 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.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Provider
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
private const val MAX_FILENAME_LENGTH = 10 class PageSaveHelper @AssistedInject constructor(
private const val EXTENSION_FALLBACK = "png" @Assisted activityResultCaller: ActivityResultCaller,
class PageSaveHelper @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val settings: AppSettings, private val settings: AppSettings,
) { private val pageLoaderProvider: Provider<PageLoader>,
) : ActivityResultCallback<Uri?> {
private val savePageRequest = activityResultCaller.registerForActivityResult(PageSaveContract(), this)
private val pickDirectoryRequest =
activityResultCaller.registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), this)
private var continuation: Continuation<Uri>? = null private var continuation: CancellableContinuation<Uri>? = null
private val contentResolver = context.contentResolver
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
}
}
suspend fun savePage( private suspend fun saveImpl(page: MangaPage): Uri {
pageLoader: PageLoader, val pageLoader = pageLoaderProvider.get()
page: MangaPage, val pageUrl = pageLoader.getPageUrl(page).toUri()
saveLauncher: ActivityResultLauncher<String>,
): Uri {
val pageUrl = pageLoader.getPageUrl(page)
val pageUri = pageLoader.loadPage(page, force = false) val pageUri = pageLoader.loadPage(page, force = false)
val proposedName = getProposedFileName(pageUrl, pageUri) val proposedName = getProposedFileName(pageUrl, pageUri)
val destination = getDefaultFileUri(proposedName) ?: pickFileUri(saveLauncher, proposedName) val destination = getDefaultFileUri(proposedName)?.uri ?: run {
runInterruptible(Dispatchers.IO) { val defaultUri = settings.getPagesSaveDir(context)?.uri?.buildUpon()?.appendPath(proposedName)?.toString()
contentResolver.openOutputStream(destination)?.sink()?.buffer() savePageRequest.launchAndAwait(defaultUri ?: proposedName)
}?.use { output -> }
getSource(pageUri).use { input -> copyImpl(pageUri, destination)
output.writeAllCancellable(input)
}
} ?: throw IOException("Output stream is null")
return 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) { if (settings.isPagesSavingAskEnabled) {
return null 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 ext = proposedName.substringAfterLast('.', "")
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null 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") else -> throw IllegalArgumentException("Bad uri $uri: unsupported scheme")
} }
private suspend fun pickFileUri(saveLauncher: ActivityResultLauncher<String>, proposedName: String): Uri { private suspend fun copyImpl(source: Uri, destination: Uri) = withContext(Dispatchers.IO) {
val defaultUri = settings.getPagesSaveDir(context)?.uri?.buildUpon()?.appendPath(proposedName)?.toString() runInterruptible {
return withContext(Dispatchers.Main) { context.contentResolver.openOutputStream(destination) ?: throw IOException("Output stream is null")
suspendCancellableCoroutine { cont -> }.sink().buffer().use { sink ->
continuation = cont getSource(source).use { input ->
saveLauncher.launch(defaultUri ?: proposedName) sink.writeAllCancellable(input)
}.also {
continuation = null
} }
} }
} }
fun onActivityResult(uri: Uri): Boolean = continuation?.apply { private suspend fun getProposedFileName(url: Uri, fileUri: Uri): String {
resume(uri) var name = requireNotNull(
} != null if (url.isZipUri()) {
url.fragment?.substringAfterLast(File.separatorChar)
private suspend fun getProposedFileName(url: String, fileUri: Uri): String {
var name = if (url.startsWith("cbz:")) {
requireNotNull(url.toUri().fragment)
} else { } else {
url.toHttpUrl().pathSegments.last() url.lastPathSegment
} },
) { "Invalid page url: $url" }
var extension = name.substringAfterLast('.', "") var extension = name.substringAfterLast('.', "")
name = name.substringBeforeLast('.') name = name.substringBeforeLast('.')
if (extension.length !in 2..4) { if (extension.length !in 2..4) {
@ -125,4 +178,16 @@ class PageSaveHelper @Inject constructor(
BitmapFactory.decodeFile(file.path, options)?.recycle() BitmapFactory.decodeFile(file.path, options)?.recycle()
options.outMimeType 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.View
import android.view.ViewGroup.MarginLayoutParams import android.view.ViewGroup.MarginLayoutParams
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.result.ActivityResultCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.OnApplyWindowInsetsListener
@ -74,7 +73,6 @@ class ReaderActivity :
OnApplyWindowInsetsListener, OnApplyWindowInsetsListener,
ReaderNavigationCallback, ReaderNavigationCallback,
IdlingDetector.Callback, IdlingDetector.Callback,
ActivityResultCallback<Uri?>,
ZoomControl.ZoomControlListener { ZoomControl.ZoomControlListener {
@Inject @Inject
@ -83,6 +81,9 @@ class ReaderActivity :
@Inject @Inject
lateinit var tapGridSettings: TapGridSettings lateinit var tapGridSettings: TapGridSettings
@Inject
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
@Inject @Inject
lateinit var scrollTimerFactory: ScrollTimer.Factory lateinit var scrollTimerFactory: ScrollTimer.Factory
@ -90,7 +91,6 @@ class ReaderActivity :
lateinit var screenOrientationHelper: ScreenOrientationHelper lateinit var screenOrientationHelper: ScreenOrientationHelper
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this) private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
private val viewModel: ReaderViewModel by viewModels() private val viewModel: ReaderViewModel by viewModels()
@ -104,6 +104,7 @@ class ReaderActivity :
} }
private lateinit var scrollTimer: ScrollTimer private lateinit var scrollTimer: ScrollTimer
private lateinit var pageSaveHelper: PageSaveHelper
private lateinit var touchHelper: TapGridDispatcher private lateinit var touchHelper: TapGridDispatcher
private lateinit var controlDelegate: ReaderControlDelegate private lateinit var controlDelegate: ReaderControlDelegate
private var gestureInsets: Insets = Insets.NONE private var gestureInsets: Insets = Insets.NONE
@ -118,6 +119,7 @@ class ReaderActivity :
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
touchHelper = TapGridDispatcher(this, this) touchHelper = TapGridDispatcher(this, this)
scrollTimer = scrollTimerFactory.create(this, this) scrollTimer = scrollTimerFactory.create(this, this)
pageSaveHelper = pageSaveHelperFactory.create(this)
controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this) controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this)
viewBinding.slider.setLabelFormatter(PageLabelFormatter()) viewBinding.slider.setLabelFormatter(PageLabelFormatter())
viewBinding.zoomControl.listener = this viewBinding.zoomControl.listener = this
@ -163,10 +165,6 @@ class ReaderActivity :
viewBinding.toolbarBottom.addMenuProvider(ReaderBottomMenuProvider(this, readerManager, viewModel)) viewBinding.toolbarBottom.addMenuProvider(ReaderBottomMenuProvider(this, readerManager, viewModel))
} }
override fun onActivityResult(result: Uri?) {
viewModel.onActivityResult(result)
}
override fun getParentActivityIntent(): Intent? { override fun getParentActivityIntent(): Intent? {
val manga = viewModel.getMangaOrNull() ?: return null val manga = viewModel.getMangaOrNull() ?: return null
return DetailsActivity.newIntent(this, manga) return DetailsActivity.newIntent(this, manga)
@ -292,15 +290,14 @@ class ReaderActivity :
} }
private fun onPageSaved(uri: Uri?) { private fun onPageSaved(uri: Uri?) {
val snackbar = Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG)
if (uri != null) { if (uri != null) {
Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG) snackbar.setAction(R.string.share) {
.setAction(R.string.share) {
ShareHelper(this).shareImage(uri) ShareHelper(this).shareImage(uri)
} }
} else { }
Snackbar.make(viewBinding.container, R.string.error_occurred, Snackbar.LENGTH_SHORT) snackbar.setAnchorView(viewBinding.appbarBottom)
}.setAnchorView(viewBinding.appbarBottom) snackbar.show()
.show()
} }
private fun setKeepScreenOn(isKeep: Boolean) { private fun setKeepScreenOn(isKeep: Boolean) {
@ -383,8 +380,7 @@ class ReaderActivity :
} }
override fun onSavePageClick() { override fun onSavePageClick() {
val page = viewModel.getCurrentPage() ?: return viewModel.saveCurrentPage(pageSaveHelper)
viewModel.saveCurrentPage(page, savePageRequest)
} }
private fun onReaderBarChanged(isBarEnabled: Boolean) { private fun onReaderBarChanged(isBarEnabled: Boolean) {

@ -1,14 +1,12 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import android.net.Uri import android.net.Uri
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin 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.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty 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.requireValue
import org.koitharu.kotatsu.core.util.ext.sizeOrZero import org.koitharu.kotatsu.core.util.ext.sizeOrZero
import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.MangaDetails
@ -80,7 +77,6 @@ class ReaderViewModel @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
settings: AppSettings, settings: AppSettings,
private val pageSaveHelper: PageSaveHelper,
private val pageLoader: PageLoader, private val pageLoader: PageLoader,
private val chaptersLoader: ChaptersLoader, private val chaptersLoader: ChaptersLoader,
private val appShortcutManager: AppShortcutManager, private val appShortcutManager: AppShortcutManager,
@ -256,30 +252,14 @@ class ReaderViewModel @Inject constructor(
} }
fun saveCurrentPage( fun saveCurrentPage(
page: MangaPage, pageSaveHelper: PageSaveHelper
saveLauncher: ActivityResultLauncher<String>,
) { ) {
val prevJob = pageSaveJob val prevJob = pageSaveJob
pageSaveJob = launchLoadingJob(Dispatchers.Default) { pageSaveJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin() prevJob?.cancelAndJoin()
try { val currentPage = checkNotNull(getCurrentPage()) { "Cannot find current page" }
val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher) val dest = pageSaveHelper.save(setOf(currentPage))
onPageSaved.call(dest) 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
} }
} }

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

Loading…
Cancel
Save