Share logic for saving page/cover (#6787)

* Use MediaStore on newer Android Q or newer

* Use flow instead of Observable

* Review comment fixes

* Use suspended function instead of flow
This commit is contained in:
Andreas
2022-03-19 21:46:23 +01:00
committed by GitHub
parent ddb856edc7
commit 1163aa4e4e
10 changed files with 265 additions and 143 deletions

View File

@@ -44,6 +44,8 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.saver.Image
import eu.kanade.tachiyomi.data.saver.Location
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
@@ -85,7 +87,7 @@ import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
import eu.kanade.tachiyomi.util.hasCustomCover
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast
@@ -775,26 +777,47 @@ class MangaController :
fun shareCover() {
try {
val manga = manga!!
val activity = activity!!
useCoverAsBitmap(activity) { coverBitmap ->
val cover = presenter.shareCover(activity, coverBitmap)
val uri = cover.getUriCompat(activity)
startActivity(uri.toShareIntent(activity))
viewScope.launchIO {
val uri = presenter.saveImage(
image = Image.Cover(
bitmap = coverBitmap,
name = manga.title,
location = Location.Cache
)
)
launchUI {
startActivity(uri.toShareIntent(activity))
}
}
}
} catch (e: Exception) {
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)
activity?.toast(R.string.error_sharing_cover)
activity?.toast(R.string.error_saving_cover)
}
}
fun saveCover() {
try {
val manga = manga!!
val activity = activity!!
useCoverAsBitmap(activity) { coverBitmap ->
presenter.saveCover(activity, coverBitmap)
activity.toast(R.string.cover_saved)
viewScope.launchIO {
presenter.saveImage(
image = Image.Cover(
bitmap = coverBitmap,
name = manga.title,
location = Location.Pictures.create()
)
)
launchUI {
activity.toast(R.string.cover_saved)
}
}
}
} catch (e: Exception) {
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)
activity?.toast(R.string.error_saving_cover)
}

View File

@@ -19,6 +19,8 @@ import eu.kanade.tachiyomi.data.database.models.toMangaInfo
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.saver.Image
import eu.kanade.tachiyomi.data.saver.ImageSaver
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
@@ -39,10 +41,6 @@ import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.getPicturesDir
import eu.kanade.tachiyomi.util.storage.getTempShareDir
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.updateCoverLastModified
@@ -58,7 +56,7 @@ import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import uy.kohesive.injekt.injectLazy
import java.util.Date
class MangaPresenter(
@@ -110,6 +108,8 @@ class MangaPresenter(
private val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
private val imageSaver: ImageSaver by injectLazy()
private var trackSubscription: Subscription? = null
private var searchTrackerJob: Job? = null
private var refreshTrackersJob: Job? = null
@@ -338,44 +338,13 @@ class MangaPresenter(
}
/**
* Save manga cover Bitmap to temporary share directory.
* Save manga cover Bitmap to picture or temporary share directory.
*
* @param context for the temporary share directory
* @param coverBitmap the cover to save (as Bitmap)
* @return cover File in temporary share directory
* @param image the image with specified location
* @return flow Flow which emits the Uri which specifies where the image is saved when
*/
fun shareCover(context: Context, coverBitmap: Bitmap): File {
return saveCover(getTempShareDir(context), coverBitmap)
}
/**
* Save manga cover to pictures directory of the device.
*
* @param context for the pictures directory of the user
* @param coverBitmap the cover to save (as Bitmap)
* @return cover File in pictures directory
*/
fun saveCover(context: Context, coverBitmap: Bitmap) {
saveCover(getPicturesDir(context), coverBitmap)
}
/**
* Save a manga cover Bitmap to a new File in a given directory.
* Overwrites file if it already exists.
*
* @param directory The directory in which the new file will be created
* @param coverBitmap The manga cover to save
* @return the newly created File
*/
private fun saveCover(directory: File, coverBitmap: Bitmap): File {
directory.mkdirs()
val filename = DiskUtil.buildValidFilename("${manga.title}.${ImageUtil.ImageType.PNG}")
val destFile = File(directory, filename)
destFile.outputStream().use { desFileOutputStream ->
coverBitmap.compress(Bitmap.CompressFormat.PNG, 100, desFileOutputStream)
}
return destFile
suspend fun saveImage(image: Image): Uri {
return imageSaver.save(image)
}
/**

View File

@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.reader
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.app.ProgressDialog
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
@@ -13,6 +12,7 @@ import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.graphics.drawable.RippleDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.Gravity
@@ -69,13 +69,13 @@ import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import eu.kanade.tachiyomi.util.preference.toggle
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.applySystemAnimatorScale
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
import eu.kanade.tachiyomi.util.system.getThemeColor
import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import eu.kanade.tachiyomi.util.system.isNightMode
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.copy
import eu.kanade.tachiyomi.util.view.popupMenu
@@ -89,7 +89,6 @@ import kotlinx.coroutines.flow.sample
import logcat.LogPriority
import nucleus.factory.RequiresPresenter
import uy.kohesive.injekt.injectLazy
import java.io.File
import kotlin.math.abs
import kotlin.math.max
@@ -830,18 +829,14 @@ class ReaderActivity : BaseRxActivity<ReaderActivityBinding, ReaderPresenter>()
* Called from the presenter when a page is ready to be shared. It shows Android's default
* sharing tool.
*/
fun onShareImageResult(file: File, page: ReaderPage) {
fun onShareImageResult(uri: Uri, page: ReaderPage) {
val manga = presenter.manga ?: return
val chapter = page.chapter.chapter
val uri = file.getUriCompat(this)
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_TEXT, getString(R.string.share_page_info, manga.title, chapter.name, page.number))
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(null, uri)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
type = "image/*"
}
val intent = uri.toShareIntent(
context = applicationContext,
message = getString(R.string.share_page_info, manga.title, chapter.name, page.number)
)
startActivity(Intent.createChooser(intent, getString(R.string.action_share)))
}

View File

@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader
import android.app.Application
import android.net.Uri
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R
@@ -10,6 +11,9 @@ import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.saver.Image
import eu.kanade.tachiyomi.data.saver.ImageSaver
import eu.kanade.tachiyomi.data.saver.Location
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.job.DelayedTrackingStore
import eu.kanade.tachiyomi.data.track.job.DelayedTrackingUpdateJob
@@ -28,11 +32,10 @@ import eu.kanade.tachiyomi.util.chapter.getChapterSort
import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.lang.byteSize
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.takeBytes
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.getPicturesDir
import eu.kanade.tachiyomi.util.storage.getTempShareDir
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir
import eu.kanade.tachiyomi.util.system.isOnline
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.updateCoverLastModified
@@ -45,7 +48,7 @@ import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import uy.kohesive.injekt.injectLazy
import java.util.Date
import java.util.concurrent.TimeUnit
@@ -92,6 +95,8 @@ class ReaderPresenter(
*/
private val isLoadingAdjacentChapterRelay = BehaviorRelay.create<Boolean>()
private val imageSaver: ImageSaver by injectLazy()
/**
* Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
* time in a background thread to avoid blocking the UI.
@@ -560,32 +565,6 @@ class ReaderPresenter(
})
}
/**
* Saves the image of this [page] in the given [directory] and returns the file location.
*/
private fun saveImage(page: ReaderPage, directory: File, manga: Manga): File {
val stream = page.stream!!
val type = ImageUtil.findImageType(stream) ?: throw Exception("Not an image")
directory.mkdirs()
val chapter = page.chapter.chapter
// Build destination file.
val filenameSuffix = " - ${page.number}.${type.extension}"
val filename = DiskUtil.buildValidFilename(
"${manga.title} - ${chapter.name}".takeBytes(MAX_FILE_NAME_BYTES - filenameSuffix.byteSize())
) + filenameSuffix
val destFile = File(directory, filename)
stream().use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
return destFile
}
/**
* Saves the image of this [page] on the pictures directory and notifies the UI of the result.
* There's also a notification to allow sharing the image somewhere else or deleting it.
@@ -593,32 +572,42 @@ class ReaderPresenter(
fun saveImage(page: ReaderPage) {
if (page.status != Page.READY) return
val manga = manga ?: return
val context = Injekt.get<Application>()
val context = Injekt.get<Application>()
val notifier = SaveImageNotifier(context)
notifier.onClear()
// Generate filename
val chapter = page.chapter.chapter
val filenameSuffix = " - ${page.number}"
val filename = DiskUtil.buildValidFilename(
"${manga.title} - ${chapter.name}".takeBytes(MAX_FILE_NAME_BYTES - filenameSuffix.byteSize())
) + filenameSuffix
// Pictures directory.
val baseDir = getPicturesDir(context).absolutePath
val destDir = if (preferences.folderPerManga()) {
File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title))
} else {
File(baseDir)
}
val relativePath = if (preferences.folderPerManga()) DiskUtil.buildValidFilename(manga.title) else ""
// Copy file in background.
Observable.fromCallable { saveImage(page, destDir, manga) }
.doOnNext { file ->
DiskUtil.scanMedia(context, file)
notifier.onComplete(file)
try {
presenterScope.launchIO {
val uri = imageSaver.save(
image = Image.Page(
inputStream = page.stream!!,
name = filename,
location = Location.Pictures.create(relativePath)
)
)
launchUI {
DiskUtil.scanMedia(context, uri)
notifier.onComplete(uri)
view!!.onSaveImageResult(SaveImageResult.Success(uri))
}
}
.doOnError { notifier.onError(it.message) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
)
} catch (e: Throwable) {
notifier.onError(e.message)
view!!.onSaveImageResult(SaveImageResult.Error(e))
}
}
/**
@@ -631,18 +620,27 @@ class ReaderPresenter(
fun shareImage(page: ReaderPage) {
if (page.status != Page.READY) return
val manga = manga ?: return
val context = Injekt.get<Application>()
val destDir = context.cacheImageDir
val destDir = getTempShareDir(context)
Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file
.map { saveImage(page, destDir, manga) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onShareImageResult(file, page) },
{ _, _ -> /* Empty */ }
)
try {
presenterScope.launchIO {
destDir.deleteRecursively()
val uri = imageSaver.save(
image = Image.Page(
inputStream = page.stream!!,
name = manga.title,
location = Location.Cache
)
)
launchUI {
view!!.onShareImageResult(uri, page)
}
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)
}
}
/**
@@ -691,7 +689,7 @@ class ReaderPresenter(
* Results of the save image feature.
*/
sealed class SaveImageResult {
class Success(val file: File) : SaveImageResult()
class Success(val uri: Uri) : SaveImageResult()
class Error(val error: Throwable) : SaveImageResult()
}

View File

@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import androidx.core.app.NotificationCompat
import coil.imageLoader
import coil.request.CachePolicy
@@ -13,7 +14,6 @@ import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.notificationManager
import java.io.File
/**
* Class used to show BigPictureStyle notifications
@@ -36,14 +36,14 @@ class SaveImageNotifier(private val context: Context) {
*
* @param file image file containing downloaded page image.
*/
fun onComplete(file: File) {
fun onComplete(uri: Uri) {
val request = ImageRequest.Builder(context)
.data(file)
.data(uri)
.memoryCachePolicy(CachePolicy.DISABLED)
.size(720, 1280)
.target(
onSuccess = { result ->
showCompleteNotification(file, (result as BitmapDrawable).bitmap)
showCompleteNotification(uri, (result as BitmapDrawable).bitmap)
},
onError = {
onError(null)
@@ -53,7 +53,7 @@ class SaveImageNotifier(private val context: Context) {
context.imageLoader.enqueue(request)
}
private fun showCompleteNotification(file: File, image: Bitmap) {
private fun showCompleteNotification(uri: Uri, image: Bitmap) {
with(notificationBuilder) {
setContentTitle(context.getString(R.string.picture_saved))
setSmallIcon(R.drawable.ic_photo_24dp)
@@ -64,18 +64,18 @@ class SaveImageNotifier(private val context: Context) {
// Clear old actions if they exist
clearActions()
setContentIntent(NotificationHandler.openImagePendingActivity(context, file))
setContentIntent(NotificationHandler.openImagePendingActivity(context, uri))
// Share action
addAction(
R.drawable.ic_share_24dp,
context.getString(R.string.action_share),
NotificationReceiver.shareImagePendingBroadcast(context, file.absolutePath, notificationId)
NotificationReceiver.shareImagePendingBroadcast(context, uri.path!!, notificationId)
)
// Delete action
addAction(
R.drawable.ic_delete_24dp,
context.getString(R.string.action_delete),
NotificationReceiver.deleteImagePendingBroadcast(context, file.absolutePath, notificationId)
NotificationReceiver.deleteImagePendingBroadcast(context, uri.path!!, notificationId)
)
updateNotification()