Use DownloadManager for pages saving

This commit is contained in:
Koitharu
2021-11-18 20:06:44 +02:00
parent 2ce5cb524f
commit f22963b315
10 changed files with 119 additions and 83 deletions

View File

@@ -65,7 +65,7 @@ class KotatsuApp : Application() {
trackerModule,
settingsModule,
readerModule,
appWidgetModule
appWidgetModule,
)
}
}

View File

@@ -6,4 +6,5 @@ object CommonHeaders {
const val USER_AGENT = "User-Agent"
const val ACCEPT = "Accept"
const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie"
}

View File

@@ -8,6 +8,7 @@ import org.koin.dsl.bind
import org.koin.dsl.module
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.DownloadManagerHelper
import java.util.concurrent.TimeUnit
val networkModule
@@ -28,4 +29,5 @@ val networkModule
}
}.build()
}
factory { DownloadManagerHelper(get(), get()) }
}

View File

@@ -16,9 +16,9 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.resolveName
import java.io.File
import java.io.IOException
@@ -74,7 +74,7 @@ class LocalListViewModel(
launchLoadingJob {
val contentResolver = context.contentResolver
withContext(Dispatchers.IO) {
val name = MediaStoreCompat(contentResolver).getName(uri)
val name = contentResolver.resolveName(uri)
?: throw IOException("Cannot fetch name from uri: $uri")
if (!LocalMangaRepository.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri")

View File

@@ -13,6 +13,6 @@ val readerModule
single { PagesCache(get()) }
viewModel { params ->
ReaderViewModel(params[0], params[1], get(), get(), get(), get())
ReaderViewModel(params[0], params[1], get(), get(), get(), get(), get())
}
}

View File

@@ -196,7 +196,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
override fun onActivityResult(result: Boolean) {
if (result) {
viewModel.saveCurrentState(reader?.getCurrentState())
viewModel.saveCurrentPage(contentResolver)
viewModel.saveCurrentPage()
}
}

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.reader.ui
import android.content.ContentResolver
import android.net.Uri
import android.util.LongSparseArray
import android.webkit.URLUtil
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
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.ReaderMode
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.ReaderUiState
import org.koitharu.kotatsu.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.DownloadManagerHelper
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -38,7 +36,8 @@ class ReaderViewModel(
private val dataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val shortcutsRepository: ShortcutsRepository,
private val settings: AppSettings
private val settings: AppSettings,
private val downloadManagerHelper: DownloadManagerHelper,
) : BaseViewModel() {
private var loadingJob: Job? = null
@@ -150,7 +149,7 @@ class ReaderViewModel(
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
}
fun saveCurrentPage(resolver: ContentResolver) {
fun saveCurrentPage() {
launchJob(Dispatchers.Default) {
try {
val state = currentState.value ?: error("Undefined state")
@@ -159,13 +158,8 @@ class ReaderViewModel(
}?.toMangaPage() ?: error("Page not found")
val repo = MangaRepository(page.source)
val pageUrl = repo.getPageUrl(page)
val file = get<PagesCache>()[pageUrl] ?: error("Page not found in cache")
val uri = file.inputStream().use { input ->
val fileName = URLUtil.guessFileName(pageUrl, null, null)
MediaStoreCompat(resolver).insertImage(fileName) {
input.copyTo(it)
}
}
val downloadId = downloadManagerHelper.downloadPage(page, pageUrl)
val uri = downloadManagerHelper.awaitDownload(downloadId)
onPageSaved.postCall(uri)
} catch (e: CancellationException) {
} catch (e: Exception) {

View File

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

View File

@@ -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('/')
}

View File

@@ -1,10 +1,13 @@
package org.koitharu.kotatsu.utils.ext
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.provider.OpenableColumns
import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
@@ -60,4 +63,19 @@ fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
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
}