Fix page saving
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import okhttp3.CookieJar
|
import okhttp3.CookieJar
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.dsl.bind
|
import org.koin.dsl.bind
|
||||||
@@ -8,8 +9,6 @@ import org.koitharu.kotatsu.BuildConfig
|
|||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.utils.DownloadManagerHelper
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
val networkModule
|
val networkModule
|
||||||
get() = module {
|
get() = module {
|
||||||
@@ -28,6 +27,5 @@ val networkModule
|
|||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
factory { DownloadManagerHelper(get(), get()) }
|
|
||||||
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }
|
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import org.koin.dsl.module
|
|||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.local.ui.LocalListViewModel
|
import org.koitharu.kotatsu.local.ui.LocalListViewModel
|
||||||
|
import org.koitharu.kotatsu.utils.ExternalStorageHelper
|
||||||
|
|
||||||
val localModule
|
val localModule
|
||||||
get() = module {
|
get() = module {
|
||||||
@@ -13,5 +14,7 @@ val localModule
|
|||||||
single { LocalStorageManager(androidContext(), get()) }
|
single { LocalStorageManager(androidContext(), get()) }
|
||||||
single { LocalMangaRepository(get()) }
|
single { LocalMangaRepository(get()) }
|
||||||
|
|
||||||
|
factory { ExternalStorageHelper(androidContext()) }
|
||||||
|
|
||||||
viewModel { LocalListViewModel(get(), get(), get(), get()) }
|
viewModel { LocalListViewModel(get(), get(), get(), get()) }
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ val readerModule
|
|||||||
historyRepository = get(),
|
historyRepository = get(),
|
||||||
shortcutsRepository = get(),
|
shortcutsRepository = get(),
|
||||||
settings = get(),
|
settings = get(),
|
||||||
downloadManagerHelper = get(),
|
externalStorageHelper = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,10 @@ import android.graphics.BitmapFactory
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.collection.LongSparseArray
|
import androidx.collection.LongSparseArray
|
||||||
import androidx.collection.set
|
import androidx.collection.set
|
||||||
|
import java.io.File
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.zip.ZipFile
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -27,10 +31,6 @@ import org.koitharu.kotatsu.parsers.util.await
|
|||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||||
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
||||||
import org.koitharu.kotatsu.utils.progress.ProgressDeferred
|
import org.koitharu.kotatsu.utils.progress.ProgressDeferred
|
||||||
import java.io.File
|
|
||||||
import java.util.*
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
private const val PROGRESS_UNDEFINED = -1f
|
private const val PROGRESS_UNDEFINED = -1f
|
||||||
private const val PREFETCH_LIMIT_DEFAULT = 10
|
private const val PREFETCH_LIMIT_DEFAULT = 10
|
||||||
@@ -77,7 +77,7 @@ class PageLoader : KoinComponent, Closeable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadPageAsync(page: MangaPage, force: Boolean) : ProgressDeferred<File, Float> {
|
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<File, Float> {
|
||||||
if (!force) {
|
if (!force) {
|
||||||
cache[page.url]?.let {
|
cache[page.url]?.let {
|
||||||
return getCompletedTask(it)
|
return getCompletedTask(it)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package org.koitharu.kotatsu.reader.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
|
class PageSaveContract : ActivityResultContracts.CreateDocument() {
|
||||||
|
|
||||||
|
override fun createIntent(context: Context, input: String): Intent {
|
||||||
|
val intent = super.createIntent(context, input)
|
||||||
|
intent.type = MimeTypeMap.getSingleton()
|
||||||
|
.getMimeTypeFromExtension(input.substringAfterLast('.')) ?: "image/*"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
intent.putExtra(
|
||||||
|
DocumentsContract.EXTRA_INITIAL_URI,
|
||||||
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).toUri(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui
|
package org.koitharu.kotatsu.reader.ui
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.*
|
import android.view.*
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResultCallback
|
import androidx.activity.result.ActivityResultCallback
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.core.view.*
|
import androidx.core.view.*
|
||||||
import androidx.fragment.app.commit
|
import androidx.fragment.app.commit
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -60,7 +57,7 @@ class ReaderActivity :
|
|||||||
GridTouchHelper.OnGridTouchListener,
|
GridTouchHelper.OnGridTouchListener,
|
||||||
OnPageSelectListener,
|
OnPageSelectListener,
|
||||||
ReaderConfigDialog.Callback,
|
ReaderConfigDialog.Callback,
|
||||||
ActivityResultCallback<Boolean>,
|
ActivityResultCallback<Uri?>,
|
||||||
ReaderControlDelegate.OnInteractionListener,
|
ReaderControlDelegate.OnInteractionListener,
|
||||||
OnApplyWindowInsetsListener {
|
OnApplyWindowInsetsListener {
|
||||||
|
|
||||||
@@ -75,10 +72,7 @@ class ReaderActivity :
|
|||||||
private lateinit var touchHelper: GridTouchHelper
|
private lateinit var touchHelper: GridTouchHelper
|
||||||
private lateinit var orientationHelper: ScreenOrientationHelper
|
private lateinit var orientationHelper: ScreenOrientationHelper
|
||||||
private lateinit var controlDelegate: ReaderControlDelegate
|
private lateinit var controlDelegate: ReaderControlDelegate
|
||||||
private val permissionsRequest = registerForActivityResult(
|
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
|
||||||
ActivityResultContracts.RequestPermission(),
|
|
||||||
this
|
|
||||||
)
|
|
||||||
private var gestureInsets: Insets = Insets.NONE
|
private var gestureInsets: Insets = Insets.NONE
|
||||||
|
|
||||||
private val reader
|
private val reader
|
||||||
@@ -190,29 +184,20 @@ class ReaderActivity :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
R.id.action_save_page -> {
|
R.id.action_save_page -> {
|
||||||
if (!viewModel.content.value?.pages.isNullOrEmpty()) {
|
viewModel.getCurrentPage()?.also { page ->
|
||||||
if (ContextCompat.checkSelfPermission(
|
viewModel.saveCurrentState(reader?.getCurrentState())
|
||||||
this,
|
val name = page.url.toUri().lastPathSegment
|
||||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
savePageRequest.launch(name)
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
} ?: showWaitWhileLoading()
|
||||||
) {
|
|
||||||
onActivityResult(true)
|
|
||||||
} else {
|
|
||||||
permissionsRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showWaitWhileLoading()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else -> return super.onOptionsItemSelected(item)
|
else -> return super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityResult(result: Boolean) {
|
override fun onActivityResult(uri: Uri?) {
|
||||||
if (result) {
|
if (uri != null) {
|
||||||
viewModel.saveCurrentState(reader?.getCurrentState())
|
viewModel.saveCurrentPage(uri)
|
||||||
viewModel.saveCurrentPage()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.*
|
|||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.get
|
import org.koin.core.component.get
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.base.domain.MangaIntent
|
import org.koitharu.kotatsu.base.domain.MangaIntent
|
||||||
import org.koitharu.kotatsu.base.domain.MangaUtils
|
import org.koitharu.kotatsu.base.domain.MangaUtils
|
||||||
@@ -25,7 +26,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
|
|||||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||||
import org.koitharu.kotatsu.utils.DownloadManagerHelper
|
import org.koitharu.kotatsu.utils.ExternalStorageHelper
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
|
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
@@ -39,7 +40,7 @@ class ReaderViewModel(
|
|||||||
private val historyRepository: HistoryRepository,
|
private val historyRepository: HistoryRepository,
|
||||||
private val shortcutsRepository: ShortcutsRepository,
|
private val shortcutsRepository: ShortcutsRepository,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
private val downloadManagerHelper: DownloadManagerHelper,
|
private val externalStorageHelper: ExternalStorageHelper,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
private var loadingJob: Job? = null
|
private var loadingJob: Job? = null
|
||||||
@@ -136,25 +137,29 @@ class ReaderViewModel(
|
|||||||
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
|
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveCurrentPage() {
|
fun saveCurrentPage(destination: Uri) {
|
||||||
launchJob(Dispatchers.Default) {
|
launchJob(Dispatchers.Default) {
|
||||||
try {
|
try {
|
||||||
val state = currentState.value ?: error("Undefined state")
|
val page = getCurrentPage() ?: error("Page not found")
|
||||||
val page = content.value?.pages?.find {
|
externalStorageHelper.savePage(page, destination)
|
||||||
it.chapterId == state.chapterId && it.index == state.page
|
onPageSaved.postCall(destination)
|
||||||
}?.toMangaPage() ?: error("Page not found")
|
|
||||||
val repo = MangaRepository(page.source)
|
|
||||||
val pageUrl = repo.getPageUrl(page)
|
|
||||||
val downloadId = downloadManagerHelper.downloadPage(page, pageUrl)
|
|
||||||
val uri = downloadManagerHelper.awaitDownload(downloadId)
|
|
||||||
onPageSaved.postCall(uri)
|
|
||||||
} catch (_: CancellationException) {
|
} catch (_: CancellationException) {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
onPageSaved.postCall(null)
|
onPageSaved.postCall(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getCurrentPage(): MangaPage? {
|
||||||
|
val state = currentState.value ?: return null
|
||||||
|
return content.value?.pages?.find {
|
||||||
|
it.chapterId == state.chapterId && it.index == state.page
|
||||||
|
}?.toMangaPage()
|
||||||
|
}
|
||||||
|
|
||||||
fun switchChapter(id: Long) {
|
fun switchChapter(id: Long) {
|
||||||
val prevJob = loadingJob
|
val prevJob = loadingJob
|
||||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.utils
|
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.app.DownloadManager.Request.*
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.Cookie
|
|
||||||
import okhttp3.CookieJar
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
|
||||||
import java.io.File
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
class DownloadManagerHelper(
|
|
||||||
private val context: Context,
|
|
||||||
private val cookieJar: CookieJar,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
private val subDir = context.getString(R.string.app_name).toFileNameSafe()
|
|
||||||
|
|
||||||
fun downloadPage(page: MangaPage, fullUrl: String): Long {
|
|
||||||
val uri = fullUrl.toUri()
|
|
||||||
val cookies = cookieJar.loadForRequest(fullUrl.toHttpUrl())
|
|
||||||
val dest = subDir + File.separator + uri.lastPathSegment
|
|
||||||
val request = DownloadManager.Request(uri)
|
|
||||||
.addRequestHeader(CommonHeaders.REFERER, page.referer)
|
|
||||||
.addRequestHeader(CommonHeaders.COOKIE, cookieHeader(cookies))
|
|
||||||
.setAllowedOverMetered(true)
|
|
||||||
.setAllowedNetworkTypes(NETWORK_WIFI or NETWORK_MOBILE)
|
|
||||||
.setNotificationVisibility(VISIBILITY_VISIBLE)
|
|
||||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, dest)
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
request.allowScanningByMediaScanner()
|
|
||||||
}
|
|
||||||
return manager.enqueue(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun awaitDownload(id: Long): Uri {
|
|
||||||
getUriForDownloadedFile(id)?.let { return it } // fast path
|
|
||||||
suspendCancellableCoroutine<Unit> { cont ->
|
|
||||||
val receiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent?) {
|
|
||||||
if (
|
|
||||||
intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE &&
|
|
||||||
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) == id
|
|
||||||
) {
|
|
||||||
context.unregisterReceiver(this)
|
|
||||||
cont.resume(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context.registerReceiver(
|
|
||||||
receiver,
|
|
||||||
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
|
||||||
)
|
|
||||||
cont.invokeOnCancellation {
|
|
||||||
context.unregisterReceiver(receiver)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return checkNotNull(getUriForDownloadedFile(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getUriForDownloadedFile(id: Long) = withContext(Dispatchers.IO) {
|
|
||||||
manager.getUriForDownloadedFile(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cookieHeader(cookies: List<Cookie>): String = buildString {
|
|
||||||
cookies.forEachIndexed { index, cookie ->
|
|
||||||
if (index > 0) append("; ")
|
|
||||||
append(cookie.name).append('=').append(cookie.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package org.koitharu.kotatsu.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.IOException
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
|
|
||||||
|
class ExternalStorageHelper(context: Context) {
|
||||||
|
|
||||||
|
private val contentResolver = context.contentResolver
|
||||||
|
|
||||||
|
suspend fun savePage(page: MangaPage, destination: Uri) {
|
||||||
|
val pageLoader = PageLoader()
|
||||||
|
val pageFile = pageLoader.loadPage(page, force = false)
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
contentResolver.openOutputStream(destination)?.use { output ->
|
||||||
|
pageFile.inputStream().use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
} ?: throw IOException("Output stream is null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user