From bf8838f943ece7acebf99cffcc68114e691e4b35 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 22 May 2024 12:32:52 +0300 Subject: [PATCH] Save and share manga cover #253 --- .../kotatsu/image/ui/ImageActivity.kt | 71 ++++++++++++++++--- .../kotatsu/image/ui/ImageMenuProvider.kt | 68 ++++++++++++++++++ .../kotatsu/image/ui/ImageViewModel.kt | 50 +++++++++++++ app/src/main/res/layout/activity_image.xml | 12 ++++ app/src/main/res/menu/opt_image.xml | 9 +++ build.gradle | 2 +- 6 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageMenuProvider.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt create mode 100644 app/src/main/res/menu/opt_image.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt index 28f811e2c..d3ea527bf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt @@ -7,11 +7,12 @@ import android.net.Uri import android.os.Bundle import android.view.View import android.view.ViewGroup +import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.core.graphics.drawable.toBitmap import androidx.core.view.isVisible -import androidx.core.view.marginBottom import androidx.core.view.updateLayoutParams +import androidx.swiperefreshlayout.widget.CircularProgressDrawable import coil.ImageLoader import coil.request.CachePolicy import coil.request.ErrorResult @@ -20,17 +21,26 @@ import coil.request.SuccessResult import coil.target.ViewTarget import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator +import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getDisplayIcon import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ActivityImageBinding import org.koitharu.kotatsu.databinding.ItemErrorStateBinding import org.koitharu.kotatsu.parsers.model.MangaSource import javax.inject.Inject +import com.google.android.material.R as materialR @AndroidEntryPoint class ImageActivity : BaseActivity(), ImageRequest.Listener, View.OnClickListener { @@ -39,27 +49,45 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene lateinit var coil: ImageLoader private var errorBinding: ItemErrorStateBinding? = null + private val viewModel: ImageViewModel by viewModels() + private lateinit var menuMediator: PopupMenuMediator override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityImageBinding.inflate(layoutInflater)) viewBinding.buttonBack.setOnClickListener(this) - loadImage(intent.data) + viewBinding.buttonMenu.setOnClickListener(this) + val imageUrl = requireNotNull(intent.data) + + val menuProvider = ImageMenuProvider( + activity = this, + snackbarHost = viewBinding.root, + viewModel = viewModel, + ) + menuMediator = PopupMenuMediator(menuProvider) + viewModel.isLoading.observe(this, ::onLoadingStateChanged) + viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.root, null)) + viewModel.onImageSaved.observeEvent(this, ::onImageSaved) + loadImage(imageUrl) } override fun onWindowInsetsChanged(insets: Insets) { - with(viewBinding.buttonBack) { - updateLayoutParams { - topMargin = insets.top + marginBottom - leftMargin = insets.left + marginBottom - rightMargin = insets.right + marginBottom - } + viewBinding.buttonBack.updateLayoutParams { + topMargin = insets.top + bottomMargin + leftMargin = insets.left + bottomMargin + rightMargin = insets.right + bottomMargin + } + viewBinding.buttonMenu.updateLayoutParams { + topMargin = insets.top + bottomMargin + leftMargin = insets.left + bottomMargin + rightMargin = insets.right + bottomMargin } } override fun onClick(v: View) { when (v.id) { R.id.button_back -> dispatchNavigateUp() + R.id.button_menu -> menuMediator.onLongClick(v) else -> loadImage(intent.data) } } @@ -92,11 +120,34 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene .memoryCachePolicy(CachePolicy.DISABLED) .lifecycle(this) .listener(this) - .tag(intent.getSerializableExtraCompat(EXTRA_SOURCE)) + .source(intent.getSerializableExtraCompat(EXTRA_SOURCE)) .target(SsivTarget(viewBinding.ssiv)) .enqueueWith(coil) } + private fun onImageSaved(uri: Uri) { + Snackbar.make(viewBinding.root, R.string.page_saved, Snackbar.LENGTH_LONG) + .setAction(R.string.share) { + ShareHelper(this).shareImage(uri) + }.show() + } + + private fun onLoadingStateChanged(isLoading: Boolean) { + val button = viewBinding.buttonMenu + button.isClickable = !isLoading + if (isLoading) { + button.setImageDrawable( + CircularProgressDrawable(this).also { + it.setStyle(CircularProgressDrawable.LARGE) + it.setColorSchemeColors(getThemeColor(com.google.android.material.R.attr.colorControlNormal)) + it.start() + }, + ) + } else { + button.setImageResource(materialR.drawable.abc_ic_menu_overflow_material) + } + } + private class SsivTarget( override val view: SubsamplingScaleImageView, ) : ViewTarget { @@ -124,7 +175,7 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene companion object { - private const val EXTRA_SOURCE = "source" + const val EXTRA_SOURCE = "source" fun newIntent(context: Context, url: String, source: MangaSource?): Intent { return Intent(context, ImageActivity::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageMenuProvider.kt new file mode 100644 index 000000000..73ddf3a93 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageMenuProvider.kt @@ -0,0 +1,68 @@ +package org.koitharu.kotatsu.image.ui + +import android.Manifest +import android.os.Build +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.MenuProvider +import com.google.android.material.snackbar.Snackbar +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.tryLaunch +import org.koitharu.kotatsu.local.data.isZipUri + +class ImageMenuProvider( + private val activity: ComponentActivity, + private val snackbarHost: View, + private val viewModel: ImageViewModel, +) : MenuProvider { + + private val permissionLauncher = activity.registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + saveImage() + } + } + + private val saveLauncher = activity.registerForActivityResult( + ActivityResultContracts.CreateDocument("image/png"), + ) { uri -> + if (uri != null) { + viewModel.saveImage(uri) + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_image, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_save -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + saveImage() + } + true + } + + else -> false + } + + private fun saveImage() { + val name = activity.intent.data?.let { + if (it.isZipUri()) { + it.fragment + } else { + it.lastPathSegment + }?.substringBeforeLast('.')?.plus(".png") + } + if (name == null || !saveLauncher.tryLaunch(name)) { + Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt new file mode 100644 index 000000000..167f635aa --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt @@ -0,0 +1,50 @@ +package org.koitharu.kotatsu.image.ui + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.SavedStateHandle +import coil.ImageLoader +import coil.request.CachePolicy +import coil.request.ImageRequest +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow +import org.koitharu.kotatsu.core.util.ext.require +import org.koitharu.kotatsu.core.util.ext.source +import javax.inject.Inject + +@HiltViewModel +class ImageViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val savedStateHandle: SavedStateHandle, + private val coil: ImageLoader, +) : BaseViewModel() { + + val onImageSaved = MutableEventFlow() + + fun saveImage(destination: Uri) { + launchLoadingJob(Dispatchers.Default) { + val request = ImageRequest.Builder(context) + .memoryCachePolicy(CachePolicy.READ_ONLY) + .data(savedStateHandle.require(BaseActivity.EXTRA_DATA)) + .memoryCachePolicy(CachePolicy.DISABLED) + .source(savedStateHandle[ImageActivity.EXTRA_SOURCE]) + .build() + val bitmap = coil.execute(request).getDrawableOrThrow().toBitmap() + runInterruptible(Dispatchers.IO) { + context.contentResolver.openOutputStream(destination)?.use { output -> + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) + } ?: error("Cannot open output stream") + } + onImageSaved.call(destination) + } + } +} diff --git a/app/src/main/res/layout/activity_image.xml b/app/src/main/res/layout/activity_image.xml index 14872d55c..70dcf6812 100644 --- a/app/src/main/res/layout/activity_image.xml +++ b/app/src/main/res/layout/activity_image.xml @@ -24,6 +24,18 @@ android:scaleType="center" app:srcCompat="?homeAsUpIndicator" /> + + + + + + + diff --git a/build.gradle b/build.gradle index c71dac215..72ac4332e 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.4.0' + classpath 'com.android.tools.build:gradle:8.4.1' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24' classpath 'com.google.dagger:hilt-android-gradle-plugin:2.51.1' classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:1.9.24-1.0.20'