diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt index 311a9bdb6..44662e54a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt @@ -65,7 +65,7 @@ class KotatsuApp : Application() { trackerModule, settingsModule, readerModule, - appWidgetModule + appWidgetModule, ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt index afd162a81..18e5bbf55 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -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" } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt index 4d5c26d3b..6c41a5291 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt @@ -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()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 5c12ad115..8e40277c9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -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") diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt index 74ea5b618..a7a806fef 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt @@ -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()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 5f1c103af..c745d2071 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -196,7 +196,7 @@ class ReaderActivity : BaseFullscreenActivity(), override fun onActivityResult(result: Boolean) { if (result) { viewModel.saveCurrentState(reader?.getCurrentState()) - viewModel.saveCurrentPage(contentResolver) + viewModel.saveCurrentPage() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index 996b14642..cb4a0e38e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -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()[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) { diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt new file mode 100644 index 000000000..87f42f742 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt @@ -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 { 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): String = buildString { + cookies.forEachIndexed { index, cookie -> + if (index > 0) append("; ") + append(cookie.name).append('=').append(cookie.value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt b/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt deleted file mode 100644 index cee13322f..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt +++ /dev/null @@ -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('/') -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt index 248f0f158..adc5f6f0b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt @@ -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 } \ No newline at end of file