|
|
|
@ -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"
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|