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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 265 additions and 143 deletions

View File

@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.saver.ImageSaver
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.job.DelayedTrackingStore import eu.kanade.tachiyomi.data.track.job.DelayedTrackingStore
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
@ -46,6 +47,8 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { DelayedTrackingStore(app) } addSingletonFactory { DelayedTrackingStore(app) }
addSingletonFactory { ImageSaver(app) }
// Asynchronously init expensive components for a faster cold start // Asynchronously init expensive components for a faster cold start
ContextCompat.getMainExecutor(app).execute { ContextCompat.getMainExecutor(app).execute {
get<PreferencesHelper>() get<PreferencesHelper>()

View File

@ -6,8 +6,6 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.storage.getUriCompat
import java.io.File
/** /**
* Class that manages [PendingIntent] of activity's * Class that manages [PendingIntent] of activity's
@ -32,9 +30,8 @@ object NotificationHandler {
* @param context context of application * @param context context of application
* @param file file containing image * @param file file containing image
*/ */
internal fun openImagePendingActivity(context: Context, file: File): PendingIntent { internal fun openImagePendingActivity(context: Context, uri: Uri): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
val uri = file.getUriCompat(context)
setDataAndType(uri, "image/*") setDataAndType(uri, "image/*")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
} }

View File

@ -0,0 +1,143 @@
package eu.kanade.tachiyomi.data.saver
import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.ImageUtil
import okio.IOException
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
class ImageSaver(
val context: Context
) {
@SuppressLint("InlinedApi")
suspend fun save(image: Image): Uri {
val data = image.data
val type = ImageUtil.findImageType(data) ?: throw Exception("Not an image")
val filename = DiskUtil.buildValidFilename("${image.name}.$type")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return save(data(), image.location.directory(context), filename)
}
if (image.location !is Location.Pictures) {
return save(data(), image.location.directory(context), filename)
}
val pictureDir =
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, image.name)
put(
MediaStore.Images.Media.RELATIVE_PATH,
"${Environment.DIRECTORY_PICTURES}/${context.getString(R.string.app_name)}/" +
(image.location as Location.Pictures).relativePath
)
}
val picture = context.contentResolver.insert(
pictureDir,
contentValues
) ?: throw IOException("Couldn't create file")
data().use { input ->
@Suppress("BlockingMethodInNonBlockingContext")
context.contentResolver.openOutputStream(picture, "w").use { output ->
input.copyTo(output!!)
}
}
return picture
}
private fun save(inputStream: InputStream, directory: File, filename: String): Uri {
directory.mkdirs()
val destFile = File(directory, filename)
inputStream.use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
return destFile.getUriCompat(context)
}
}
sealed class Image(
open val name: String,
open val location: Location
) {
data class Cover(
val bitmap: Bitmap,
override val name: String,
override val location: Location
) : Image(name, location)
data class Page(
val inputStream: () -> InputStream,
override val name: String,
override val location: Location
) : Image(name, location)
val data: () -> InputStream
get() {
return when (this) {
is Cover -> {
{
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos)
ByteArrayInputStream(baos.toByteArray())
}
}
is Page -> inputStream
}
}
}
sealed class Location {
data class Pictures private constructor(val relativePath: String) : Location() {
companion object {
fun create(relativePath: String = ""): Pictures {
return Pictures(relativePath)
}
}
}
object Cache : Location()
fun directory(context: Context): File {
return when (this) {
Cache -> context.cacheImageDir
is Pictures -> {
val file = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
context.getString(R.string.app_name)
)
if (relativePath.isNotEmpty()) {
return File(
file,
relativePath
)
}
file
}
}
}
}

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.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper 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.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch 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.chapter.NoChaptersException
import eu.kanade.tachiyomi.util.hasCustomCover import eu.kanade.tachiyomi.util.hasCustomCover
import eu.kanade.tachiyomi.util.lang.launchIO 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.logcat
import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
@ -775,26 +777,47 @@ class MangaController :
fun shareCover() { fun shareCover() {
try { try {
val manga = manga!!
val activity = activity!! val activity = activity!!
useCoverAsBitmap(activity) { coverBitmap -> useCoverAsBitmap(activity) { coverBitmap ->
val cover = presenter.shareCover(activity, coverBitmap) viewScope.launchIO {
val uri = cover.getUriCompat(activity) val uri = presenter.saveImage(
image = Image.Cover(
bitmap = coverBitmap,
name = manga.title,
location = Location.Cache
)
)
launchUI {
startActivity(uri.toShareIntent(activity)) startActivity(uri.toShareIntent(activity))
} }
} catch (e: Exception) { }
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
activity?.toast(R.string.error_sharing_cover) activity?.toast(R.string.error_saving_cover)
} }
} }
fun saveCover() { fun saveCover() {
try { try {
val manga = manga!!
val activity = activity!! val activity = activity!!
useCoverAsBitmap(activity) { coverBitmap -> useCoverAsBitmap(activity) { coverBitmap ->
presenter.saveCover(activity, coverBitmap) viewScope.launchIO {
presenter.saveImage(
image = Image.Cover(
bitmap = coverBitmap,
name = manga.title,
location = Location.Pictures.create()
)
)
launchUI {
activity.toast(R.string.cover_saved) activity.toast(R.string.cover_saved)
} }
} catch (e: Exception) { }
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
activity?.toast(R.string.error_saving_cover) 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.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper 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.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService 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.prepUpdateCover
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters 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.logcat
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.updateCoverLastModified import eu.kanade.tachiyomi.util.updateCoverLastModified
@ -58,7 +56,7 @@ import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File import uy.kohesive.injekt.injectLazy
import java.util.Date import java.util.Date
class MangaPresenter( class MangaPresenter(
@ -110,6 +108,8 @@ class MangaPresenter(
private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } private val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
private val imageSaver: ImageSaver by injectLazy()
private var trackSubscription: Subscription? = null private var trackSubscription: Subscription? = null
private var searchTrackerJob: Job? = null private var searchTrackerJob: Job? = null
private var refreshTrackersJob: 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 image the image with specified location
* @param coverBitmap the cover to save (as Bitmap) * @return flow Flow which emits the Uri which specifies where the image is saved when
* @return cover File in temporary share directory
*/ */
fun shareCover(context: Context, coverBitmap: Bitmap): File { suspend fun saveImage(image: Image): Uri {
return saveCover(getTempShareDir(context), coverBitmap) return imageSaver.save(image)
}
/**
* 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
} }
/** /**

View File

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

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader package eu.kanade.tachiyomi.ui.reader
import android.app.Application import android.app.Application
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R 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.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper 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.TrackManager
import eu.kanade.tachiyomi.data.track.job.DelayedTrackingStore import eu.kanade.tachiyomi.data.track.job.DelayedTrackingStore
import eu.kanade.tachiyomi.data.track.job.DelayedTrackingUpdateJob 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.isLocal
import eu.kanade.tachiyomi.util.lang.byteSize import eu.kanade.tachiyomi.util.lang.byteSize
import eu.kanade.tachiyomi.util.lang.launchIO 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.lang.takeBytes
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.getPicturesDir import eu.kanade.tachiyomi.util.storage.cacheImageDir
import eu.kanade.tachiyomi.util.storage.getTempShareDir
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.isOnline import eu.kanade.tachiyomi.util.system.isOnline
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.updateCoverLastModified import eu.kanade.tachiyomi.util.updateCoverLastModified
@ -45,7 +48,7 @@ import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File import uy.kohesive.injekt.injectLazy
import java.util.Date import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -92,6 +95,8 @@ class ReaderPresenter(
*/ */
private val isLoadingAdjacentChapterRelay = BehaviorRelay.create<Boolean>() 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 * 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. * 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. * 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. * 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) { fun saveImage(page: ReaderPage) {
if (page.status != Page.READY) return if (page.status != Page.READY) return
val manga = manga ?: return val manga = manga ?: return
val context = Injekt.get<Application>()
val context = Injekt.get<Application>()
val notifier = SaveImageNotifier(context) val notifier = SaveImageNotifier(context)
notifier.onClear() 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. // Pictures directory.
val baseDir = getPicturesDir(context).absolutePath val relativePath = if (preferences.folderPerManga()) DiskUtil.buildValidFilename(manga.title) else ""
val destDir = if (preferences.folderPerManga()) {
File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title))
} else {
File(baseDir)
}
// Copy file in background. // Copy file in background.
Observable.fromCallable { saveImage(page, destDir, manga) }
.doOnNext { file -> try {
DiskUtil.scanMedia(context, file) presenterScope.launchIO {
notifier.onComplete(file) val uri = imageSaver.save(
} image = Image.Page(
.doOnError { notifier.onError(it.message) } inputStream = page.stream!!,
.subscribeOn(Schedulers.io()) name = filename,
.observeOn(AndroidSchedulers.mainThread()) location = Location.Pictures.create(relativePath)
.subscribeFirst(
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
) )
)
launchUI {
DiskUtil.scanMedia(context, uri)
notifier.onComplete(uri)
view!!.onSaveImageResult(SaveImageResult.Success(uri))
}
}
} catch (e: Throwable) {
notifier.onError(e.message)
view!!.onSaveImageResult(SaveImageResult.Error(e))
}
} }
/** /**
@ -631,18 +620,27 @@ class ReaderPresenter(
fun shareImage(page: ReaderPage) { fun shareImage(page: ReaderPage) {
if (page.status != Page.READY) return if (page.status != Page.READY) return
val manga = manga ?: return val manga = manga ?: return
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
val destDir = context.cacheImageDir
val destDir = getTempShareDir(context) try {
presenterScope.launchIO {
Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file destDir.deleteRecursively()
.map { saveImage(page, destDir, manga) } val uri = imageSaver.save(
.subscribeOn(Schedulers.io()) image = Image.Page(
.observeOn(AndroidSchedulers.mainThread()) inputStream = page.stream!!,
.subscribeFirst( name = manga.title,
{ view, file -> view.onShareImageResult(file, page) }, location = Location.Cache
{ _, _ -> /* Empty */ }
) )
)
launchUI {
view!!.onShareImageResult(uri, page)
}
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)
}
} }
/** /**
@ -691,7 +689,7 @@ class ReaderPresenter(
* Results of the save image feature. * Results of the save image feature.
*/ */
sealed class SaveImageResult { sealed class SaveImageResult {
class Success(val file: File) : SaveImageResult() class Success(val uri: Uri) : SaveImageResult()
class Error(val error: Throwable) : 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.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import coil.imageLoader import coil.imageLoader
import coil.request.CachePolicy 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.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.notificationBuilder import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.notificationManager import eu.kanade.tachiyomi.util.system.notificationManager
import java.io.File
/** /**
* Class used to show BigPictureStyle notifications * Class used to show BigPictureStyle notifications
@ -36,14 +36,14 @@ class SaveImageNotifier(private val context: Context) {
* *
* @param file image file containing downloaded page image. * @param file image file containing downloaded page image.
*/ */
fun onComplete(file: File) { fun onComplete(uri: Uri) {
val request = ImageRequest.Builder(context) val request = ImageRequest.Builder(context)
.data(file) .data(uri)
.memoryCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.DISABLED)
.size(720, 1280) .size(720, 1280)
.target( .target(
onSuccess = { result -> onSuccess = { result ->
showCompleteNotification(file, (result as BitmapDrawable).bitmap) showCompleteNotification(uri, (result as BitmapDrawable).bitmap)
}, },
onError = { onError = {
onError(null) onError(null)
@ -53,7 +53,7 @@ class SaveImageNotifier(private val context: Context) {
context.imageLoader.enqueue(request) context.imageLoader.enqueue(request)
} }
private fun showCompleteNotification(file: File, image: Bitmap) { private fun showCompleteNotification(uri: Uri, image: Bitmap) {
with(notificationBuilder) { with(notificationBuilder) {
setContentTitle(context.getString(R.string.picture_saved)) setContentTitle(context.getString(R.string.picture_saved))
setSmallIcon(R.drawable.ic_photo_24dp) setSmallIcon(R.drawable.ic_photo_24dp)
@ -64,18 +64,18 @@ class SaveImageNotifier(private val context: Context) {
// Clear old actions if they exist // Clear old actions if they exist
clearActions() clearActions()
setContentIntent(NotificationHandler.openImagePendingActivity(context, file)) setContentIntent(NotificationHandler.openImagePendingActivity(context, uri))
// Share action // Share action
addAction( addAction(
R.drawable.ic_share_24dp, R.drawable.ic_share_24dp,
context.getString(R.string.action_share), context.getString(R.string.action_share),
NotificationReceiver.shareImagePendingBroadcast(context, file.absolutePath, notificationId) NotificationReceiver.shareImagePendingBroadcast(context, uri.path!!, notificationId)
) )
// Delete action // Delete action
addAction( addAction(
R.drawable.ic_delete_24dp, R.drawable.ic_delete_24dp,
context.getString(R.string.action_delete), context.getString(R.string.action_delete),
NotificationReceiver.deleteImagePendingBroadcast(context, file.absolutePath, notificationId) NotificationReceiver.deleteImagePendingBroadcast(context, uri.path!!, notificationId)
) )
updateNotification() updateNotification()

View File

@ -3,20 +3,13 @@ package eu.kanade.tachiyomi.util.storage
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import java.io.File import java.io.File
fun getTempShareDir(context: Context) = File(context.cacheDir, "shared_image") val Context.cacheImageDir: File
get() = File(cacheDir, "shared_image")
fun getPicturesDir(context: Context) = File(
Environment.getExternalStorageDirectory().absolutePath +
File.separator + Environment.DIRECTORY_PICTURES +
File.separator + context.getString(R.string.app_name)
)
/** /**
* Returns the uri of a file * Returns the uri of a file

View File

@ -6,10 +6,11 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
fun Uri.toShareIntent(context: Context, type: String = "image/*"): Intent { fun Uri.toShareIntent(context: Context, type: String = "image/*", message: String? = null): Intent {
val uri = this val uri = this
val shareIntent = Intent(Intent.ACTION_SEND).apply { val shareIntent = Intent(Intent.ACTION_SEND).apply {
if (message != null) putExtra(Intent.EXTRA_TEXT, message)
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(null, uri) clipData = ClipData.newRawUri(null, uri)
setType(type) setType(type)