Use DownloadManager for pages saving
This commit is contained in:
@@ -65,7 +65,7 @@ class KotatsuApp : Application() {
|
|||||||
trackerModule,
|
trackerModule,
|
||||||
settingsModule,
|
settingsModule,
|
||||||
readerModule,
|
readerModule,
|
||||||
appWidgetModule
|
appWidgetModule,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ object CommonHeaders {
|
|||||||
const val USER_AGENT = "User-Agent"
|
const val USER_AGENT = "User-Agent"
|
||||||
const val ACCEPT = "Accept"
|
const val ACCEPT = "Accept"
|
||||||
const val CONTENT_DISPOSITION = "Content-Disposition"
|
const val CONTENT_DISPOSITION = "Content-Disposition"
|
||||||
|
const val COOKIE = "Cookie"
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import org.koin.dsl.bind
|
|||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.utils.CacheUtils
|
import org.koitharu.kotatsu.utils.CacheUtils
|
||||||
|
import org.koitharu.kotatsu.utils.DownloadManagerHelper
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
val networkModule
|
val networkModule
|
||||||
@@ -28,4 +29,5 @@ val networkModule
|
|||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
factory { DownloadManagerHelper(get(), get()) }
|
||||||
}
|
}
|
||||||
@@ -16,9 +16,9 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
|
|||||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.*
|
import org.koitharu.kotatsu.list.ui.model.*
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
|
import org.koitharu.kotatsu.utils.ext.resolveName
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ class LocalListViewModel(
|
|||||||
launchLoadingJob {
|
launchLoadingJob {
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val name = MediaStoreCompat(contentResolver).getName(uri)
|
val name = contentResolver.resolveName(uri)
|
||||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||||
if (!LocalMangaRepository.isFileSupported(name)) {
|
if (!LocalMangaRepository.isFileSupported(name)) {
|
||||||
throw UnsupportedFileException("Unsupported file on $uri")
|
throw UnsupportedFileException("Unsupported file on $uri")
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ val readerModule
|
|||||||
single { PagesCache(get()) }
|
single { PagesCache(get()) }
|
||||||
|
|
||||||
viewModel { params ->
|
viewModel { params ->
|
||||||
ReaderViewModel(params[0], params[1], get(), get(), get(), get())
|
ReaderViewModel(params[0], params[1], get(), get(), get(), get(), get())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
|
|||||||
override fun onActivityResult(result: Boolean) {
|
override fun onActivityResult(result: Boolean) {
|
||||||
if (result) {
|
if (result) {
|
||||||
viewModel.saveCurrentState(reader?.getCurrentState())
|
viewModel.saveCurrentState(reader?.getCurrentState())
|
||||||
viewModel.saveCurrentPage(contentResolver)
|
viewModel.saveCurrentPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.reader.ui
|
|||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.LongSparseArray
|
import android.util.LongSparseArray
|
||||||
import android.webkit.URLUtil
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@@ -23,10 +22,9 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
|
|||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
|
||||||
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.MediaStoreCompat
|
import org.koitharu.kotatsu.utils.DownloadManagerHelper
|
||||||
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
|
||||||
@@ -38,7 +36,8 @@ class ReaderViewModel(
|
|||||||
private val dataRepository: MangaDataRepository,
|
private val dataRepository: MangaDataRepository,
|
||||||
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,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
private var loadingJob: Job? = null
|
private var loadingJob: Job? = null
|
||||||
@@ -150,7 +149,7 @@ class ReaderViewModel(
|
|||||||
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
|
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveCurrentPage(resolver: ContentResolver) {
|
fun saveCurrentPage() {
|
||||||
launchJob(Dispatchers.Default) {
|
launchJob(Dispatchers.Default) {
|
||||||
try {
|
try {
|
||||||
val state = currentState.value ?: error("Undefined state")
|
val state = currentState.value ?: error("Undefined state")
|
||||||
@@ -159,13 +158,8 @@ class ReaderViewModel(
|
|||||||
}?.toMangaPage() ?: error("Page not found")
|
}?.toMangaPage() ?: error("Page not found")
|
||||||
val repo = MangaRepository(page.source)
|
val repo = MangaRepository(page.source)
|
||||||
val pageUrl = repo.getPageUrl(page)
|
val pageUrl = repo.getPageUrl(page)
|
||||||
val file = get<PagesCache>()[pageUrl] ?: error("Page not found in cache")
|
val downloadId = downloadManagerHelper.downloadPage(page, pageUrl)
|
||||||
val uri = file.inputStream().use { input ->
|
val uri = downloadManagerHelper.awaitDownload(downloadId)
|
||||||
val fileName = URLUtil.guessFileName(pageUrl, null, null)
|
|
||||||
MediaStoreCompat(resolver).insertImage(fileName) {
|
|
||||||
input.copyTo(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onPageSaved.postCall(uri)
|
onPageSaved.postCall(uri)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.utils.ext.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.utils
|
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import androidx.core.database.getStringOrNull
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import java.io.OutputStream
|
|
||||||
|
|
||||||
class MediaStoreCompat(private val contentResolver: ContentResolver) {
|
|
||||||
|
|
||||||
fun insertImage(
|
|
||||||
fileName: String,
|
|
||||||
block: (OutputStream) -> Unit
|
|
||||||
): Uri? {
|
|
||||||
val name = fileName.substringBeforeLast('.')
|
|
||||||
val cv = ContentValues(7)
|
|
||||||
cv.put(MediaStore.Images.Media.DISPLAY_NAME, name)
|
|
||||||
cv.put(MediaStore.Images.Media.TITLE, name)
|
|
||||||
cv.put(
|
|
||||||
MediaStore.Images.Media.MIME_TYPE,
|
|
||||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileName.substringAfterLast('.'))
|
|
||||||
)
|
|
||||||
cv.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1_000)
|
|
||||||
cv.put(MediaStore.Images.Media.DATE_MODIFIED, System.currentTimeMillis())
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
cv.put(MediaStore.Images.Media.IS_PENDING, 1)
|
|
||||||
}
|
|
||||||
var uri: Uri? = null
|
|
||||||
try {
|
|
||||||
uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cv)
|
|
||||||
contentResolver.openOutputStream(uri!!)?.use(block)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
cv.clear()
|
|
||||||
cv.put(MediaStore.Images.Media.IS_PENDING, 0)
|
|
||||||
contentResolver.update(uri, cv, null, null)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
uri?.let {
|
|
||||||
contentResolver.delete(it, null, null)
|
|
||||||
}
|
|
||||||
uri = null
|
|
||||||
}
|
|
||||||
return uri
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getName(uri: Uri): String? =
|
|
||||||
(if (uri.scheme == "content") {
|
|
||||||
contentResolver.query(uri, null, null, null, null)?.use {
|
|
||||||
if (it.moveToFirst()) {
|
|
||||||
it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}) ?: uri.path?.substringAfterLast('/')
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.utils.ext
|
package org.koitharu.kotatsu.utils.ext
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import androidx.core.database.getStringOrNull
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
@@ -61,3 +64,18 @@ fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null
|
|||||||
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
|
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
|
||||||
delete()
|
delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun ContentResolver.resolveName(uri: Uri): String? {
|
||||||
|
val fallback = uri.lastPathSegment
|
||||||
|
if (uri.scheme != "content") {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
query(uri, null, null, null, null)?.use {
|
||||||
|
if (it.moveToFirst()) {
|
||||||
|
it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))?.let { name ->
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user