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 8ba1c6358..4e7f0eb1c 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 @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.core.network +import java.util.concurrent.TimeUnit import okhttp3.CookieJar import okhttp3.OkHttpClient 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.local.data.LocalStorageManager import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.utils.DownloadManagerHelper -import java.util.concurrent.TimeUnit val networkModule get() = module { @@ -28,6 +27,5 @@ val networkModule } }.build() } - factory { DownloadManagerHelper(get(), get()) } single { MangaLoaderContextImpl(get(), get(), get()) } - } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt index d8fd86f65..cda2dbad6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt @@ -6,6 +6,7 @@ import org.koin.dsl.module import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.ui.LocalListViewModel +import org.koitharu.kotatsu.utils.ExternalStorageHelper val localModule get() = module { @@ -13,5 +14,7 @@ val localModule single { LocalStorageManager(androidContext(), get()) } single { LocalMangaRepository(get()) } + factory { ExternalStorageHelper(androidContext()) } + viewModel { LocalListViewModel(get(), get(), get(), get()) } } \ No newline at end of file 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 bee3fe9f7..0d0aec467 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt @@ -21,7 +21,7 @@ val readerModule historyRepository = get(), shortcutsRepository = get(), settings = get(), - downloadManagerHelper = get(), + externalStorageHelper = get(), ) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 11061c348..250a63a49 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -6,6 +6,10 @@ import android.graphics.BitmapFactory import android.net.Uri import androidx.collection.LongSparseArray 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.flow.MutableStateFlow 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.utils.ext.connectivityManager 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 PREFETCH_LIMIT_DEFAULT = 10 @@ -77,7 +77,7 @@ class PageLoader : KoinComponent, Closeable { } } - fun loadPageAsync(page: MangaPage, force: Boolean) : ProgressDeferred { + fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred { if (!force) { cache[page.url]?.let { return getCompletedTask(it) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt new file mode 100644 index 000000000..556a03cac --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt @@ -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 + } +} \ 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 f7bfce558..02e799739 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 @@ -1,18 +1,15 @@ package org.koitharu.kotatsu.reader.ui -import android.Manifest import android.content.Context import android.content.DialogInterface import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle import android.view.* import android.widget.Toast import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat import androidx.core.graphics.Insets +import androidx.core.net.toUri import androidx.core.view.* import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope @@ -60,7 +57,7 @@ class ReaderActivity : GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback, - ActivityResultCallback, + ActivityResultCallback, ReaderControlDelegate.OnInteractionListener, OnApplyWindowInsetsListener { @@ -75,10 +72,7 @@ class ReaderActivity : private lateinit var touchHelper: GridTouchHelper private lateinit var orientationHelper: ScreenOrientationHelper private lateinit var controlDelegate: ReaderControlDelegate - private val permissionsRequest = registerForActivityResult( - ActivityResultContracts.RequestPermission(), - this - ) + private val savePageRequest = registerForActivityResult(PageSaveContract(), this) private var gestureInsets: Insets = Insets.NONE private val reader @@ -190,29 +184,20 @@ class ReaderActivity : } } R.id.action_save_page -> { - if (!viewModel.content.value?.pages.isNullOrEmpty()) { - if (ContextCompat.checkSelfPermission( - this, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED - ) { - onActivityResult(true) - } else { - permissionsRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - } else { - showWaitWhileLoading() - } + viewModel.getCurrentPage()?.also { page -> + viewModel.saveCurrentState(reader?.getCurrentState()) + val name = page.url.toUri().lastPathSegment + savePageRequest.launch(name) + } ?: showWaitWhileLoading() } else -> return super.onOptionsItemSelected(item) } return true } - override fun onActivityResult(result: Boolean) { - if (result) { - viewModel.saveCurrentState(reader?.getCurrentState()) - viewModel.saveCurrentPage() + override fun onActivityResult(uri: Uri?) { + if (uri != null) { + viewModel.saveCurrentPage(uri) } } 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 56f7c6df0..9f1f18983 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 @@ -8,6 +8,7 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import org.koin.core.component.KoinComponent import org.koin.core.component.get +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaIntent 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.ui.pager.ReaderPage 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.ext.IgnoreErrors import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct @@ -39,7 +40,7 @@ class ReaderViewModel( private val historyRepository: HistoryRepository, private val shortcutsRepository: ShortcutsRepository, private val settings: AppSettings, - private val downloadManagerHelper: DownloadManagerHelper, + private val externalStorageHelper: ExternalStorageHelper, ) : BaseViewModel() { private var loadingJob: Job? = null @@ -136,25 +137,29 @@ class ReaderViewModel( return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() } } - fun saveCurrentPage() { + fun saveCurrentPage(destination: Uri) { launchJob(Dispatchers.Default) { try { - val state = currentState.value ?: error("Undefined state") - val page = content.value?.pages?.find { - it.chapterId == state.chapterId && it.index == state.page - }?.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) + val page = getCurrentPage() ?: error("Page not found") + externalStorageHelper.savePage(page, destination) + onPageSaved.postCall(destination) } catch (_: CancellationException) { } catch (e: Exception) { + if (BuildConfig.DEBUG) { + e.printStackTrace() + } 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) { val prevJob = loadingJob loadingJob = launchLoadingJob(Dispatchers.Default) { diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt deleted file mode 100644 index 28ea765e1..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt +++ /dev/null @@ -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 { 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/ExternalStorageHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ExternalStorageHelper.kt new file mode 100644 index 000000000..b769645b8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ExternalStorageHelper.kt @@ -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") + } + } +} \ No newline at end of file