Option to save/share combined double pages

pull/7308/head
Jays2Kings 3 years ago
parent 443887c89a
commit 2ec4db3c10

@ -12,6 +12,7 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
@ -52,9 +53,19 @@ open class MaterialMenuSheet(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.isInNightMode() && !activity.window.decorView.rootWindowInsets.hasSideNavBar()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.isInNightMode() && !activity.window.decorView.rootWindowInsets.hasSideNavBar()) {
window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
} }
maxHeight?.let { if (maxHeight != null) {
binding.menuScrollView.maxHeight = it + activity.window.decorView.rootWindowInsets.systemWindowInsetBottom binding.menuScrollView.maxHeight = maxHeight + activity.window.decorView.rootWindowInsets.systemWindowInsetBottom
binding.menuScrollView.requestLayout() binding.menuScrollView.requestLayout()
} else {
binding.titleLayout.viewTreeObserver.addOnGlobalLayoutListener {
binding.menuScrollView.updateLayoutParams<ConstraintLayout.LayoutParams> {
val fullHeight = activity.window.decorView.height
val insets = activity.window.decorView.rootWindowInsets
matchConstraintMaxHeight =
fullHeight - (insets?.systemWindowInsetTop ?: 0) -
binding.titleLayout.height - 26.dpToPx
}
}
} }
binding.divider.visibleIf(showDivider) binding.divider.visibleIf(showDivider)

@ -9,11 +9,14 @@ class MaxHeightScrollView @JvmOverloads constructor(context: Context, attrs: Att
var maxHeight = -1 var maxHeight = -1
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val heightS = if (maxHeight > 0) { var heightS = if (maxHeight > 0) {
MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST) MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
} else { } else {
heightMeasureSpec heightMeasureSpec
} }
if (maxHeight < height + (rootWindowInsets?.systemWindowInsetBottom ?: 0)) {
heightS = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
}
super.onMeasure(widthMeasureSpec, heightS) super.onMeasure(widthMeasureSpec, heightS)
} }
} }

@ -968,6 +968,16 @@ class ReaderActivity :
2, 2,
R.drawable.ic_photo_24dp, R.drawable.ic_photo_24dp,
R.string.set_first_page_as_cover R.string.set_first_page_as_cover
),
MaterialMenuSheet.MenuSheetItem(
6,
R.drawable.ic_share_all_outline_24dp,
R.string.share_combined_pages
),
MaterialMenuSheet.MenuSheetItem(
7,
R.drawable.ic_save_all_outline_24dp,
R.string.save_combined_pages
) )
) )
} else { } else {
@ -997,6 +1007,22 @@ class ReaderActivity :
3 -> extraPage?.let { shareImage(it) } 3 -> extraPage?.let { shareImage(it) }
4 -> extraPage?.let { saveImage(it) } 4 -> extraPage?.let { saveImage(it) }
5 -> extraPage?.let { showSetCoverPrompt(it) } 5 -> extraPage?.let { showSetCoverPrompt(it) }
6, 7 -> extraPage?.let { secondPage ->
(viewer as? PagerViewer)?.let { viewer ->
val isLTR = (viewer !is R2LPagerViewer).xor(viewer.config.invertDoublePages)
val bg =
if (viewer.config.readerTheme >= 2 || viewer.config.readerTheme == 0) {
Color.WHITE
} else {
Color.BLACK
}
if (item == 6) {
presenter.shareImages(page, secondPage, isLTR, bg)
} else {
presenter.saveImages(page, secondPage, isLTR, bg)
}
}
}
} }
true true
}.show() }.show()
@ -1051,17 +1077,22 @@ class ReaderActivity :
* 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(file: File, page: ReaderPage, secondPage: ReaderPage? = null) {
val manga = presenter.manga ?: return val manga = presenter.manga ?: return
val chapter = page.chapter.chapter val chapter = page.chapter.chapter
val decimalFormat = val decimalFormat =
DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' }) DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' })
val pageNumber = if (secondPage != null) {
getString(R.string.pages_, if (resources.isLTR) "${page.number}-${page.number + 1}" else "${page.number + 1}-${page.number}")
} else {
getString(R.string.page_, page.number)
}
val text = "${manga.title}: ${getString( val text = "${manga.title}: ${getString(
R.string.chapter_, R.string.chapter_,
decimalFormat.format(chapter.chapter_number) decimalFormat.format(chapter.chapter_number)
)}, ${getString(R.string.page_, page.number)}" )}, $pageNumber"
val stream = file.getUriCompat(this) val stream = file.getUriCompat(this)
val intent = Intent(Intent.ACTION_SEND).apply { val intent = Intent(Intent.ACTION_SEND).apply {

@ -1,9 +1,11 @@
package eu.kanade.tachiyomi.ui.reader package eu.kanade.tachiyomi.ui.reader
import android.app.Application import android.app.Application
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import androidx.annotation.ColorInt
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
@ -32,8 +34,11 @@ import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.executeOnIO import eu.kanade.tachiyomi.util.system.executeOnIO
import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import rx.Completable import rx.Completable
@ -117,6 +122,8 @@ class ReaderPresenter(
var chapterItems = emptyList<ReaderChapterItem>() var chapterItems = emptyList<ReaderChapterItem>()
private var scope = CoroutineScope(Job() + Dispatchers.Default)
/** /**
* Called when the presenter is created. It retrieves the saved active chapter if the process * Called when the presenter is created. It retrieves the saved active chapter if the process
* was restored. * was restored.
@ -610,6 +617,40 @@ class ReaderPresenter(
return destFile return destFile
} }
/**
* Saves the image of this [page] in the given [directory] and returns the file location.
*/
private fun saveImages(page1: ReaderPage, page2: ReaderPage, isLTR: Boolean, @ColorInt bg: Int, directory: File, manga: Manga): File {
val stream1 = page1.stream!!
ImageUtil.findImageType(stream1) ?: throw Exception("Not an image")
val stream2 = page2.stream!!
ImageUtil.findImageType(stream2) ?: throw Exception("Not an image")
val imageBytes = stream1().readBytes()
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
val imageBytes2 = stream2().readBytes()
val imageBitmap2 = BitmapFactory.decodeByteArray(imageBytes2, 0, imageBytes2.size)
val stream = ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, bg)
directory.mkdirs()
val chapter = page1.chapter.chapter
// Build destination file.
val filename = DiskUtil.buildValidFilename(
"${manga.title} - ${chapter.name}".take(225)
) + " - ${page1.number}-${page2.number}.jpg"
val destFile = File(directory, filename)
stream.use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
stream.close()
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.
@ -644,6 +685,34 @@ class ReaderPresenter(
) )
} }
fun saveImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) {
scope.launch {
if (firstPage.status != Page.READY) return@launch
if (secondPage.status != Page.READY) return@launch
val manga = manga ?: return@launch
val context = Injekt.get<Application>()
val notifier = SaveImageNotifier(context)
notifier.onClear()
// Pictures directory.
val destDir = File(
Environment.getExternalStorageDirectory().absolutePath +
File.separator + Environment.DIRECTORY_PICTURES +
File.separator + "Tachiyomi"
)
try {
val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga)
DiskUtil.scanMedia(context, file)
notifier.onComplete(file)
withUIContext { view?.onSaveImageResult(SaveImageResult.Success(file)) }
} catch (e: Exception) {
withUIContext { view?.onSaveImageResult(SaveImageResult.Error(e)) }
}
}
}
/** /**
* Shares the image of this [page] and notifies the UI with the path of the file to share. * Shares the image of this [page] and notifies the UI with the path of the file to share.
* The image must be first copied to the internal partition because there are many possible * The image must be first copied to the internal partition because there are many possible
@ -668,6 +737,25 @@ class ReaderPresenter(
) )
} }
fun shareImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) {
scope.launch {
if (firstPage.status != Page.READY) return@launch
if (secondPage.status != Page.READY) return@launch
val manga = manga ?: return@launch
val context = Injekt.get<Application>()
val destDir = File(context.cacheDir, "shared_image")
destDir.deleteRecursively()
try {
val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga)
withUIContext {
view?.onShareImageResult(file, firstPage, secondPage)
}
} catch (e: Exception) {
}
}
}
/** /**
* Sets the image of this [page] as cover and notifies the UI of the result. * Sets the image of this [page] as cover and notifies the UI of the result.
*/ */

@ -3,12 +3,9 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.PointF import android.graphics.PointF
import android.graphics.Rect
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.view.GestureDetector import android.view.GestureDetector
import android.view.Gravity import android.view.Gravity
@ -54,11 +51,8 @@ import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@ -683,36 +677,24 @@ class PagerPageHolder(
skipExtra = true skipExtra = true
return imageBytes.inputStream() return imageBytes.inputStream()
} }
val maxHeight = max(height, height2)
val result = Bitmap.createBitmap(width + width2, max(height, height2), Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
canvas.drawColor(if (viewer.config.readerTheme >= 2 || viewer.config.readerTheme == 0) Color.WHITE else Color.BLACK)
val isLTR = (viewer !is R2LPagerViewer).xor(viewer.config.invertDoublePages) val isLTR = (viewer !is R2LPagerViewer).xor(viewer.config.invertDoublePages)
val upperPart = Rect( val bg = if (viewer.config.readerTheme >= 2 || viewer.config.readerTheme == 0) {
if (isLTR) 0 else width2, Color.WHITE
(maxHeight - imageBitmap.height) / 2, } else {
(if (isLTR) 0 else width2) + imageBitmap.width, Color.BLACK
imageBitmap.height + (maxHeight - imageBitmap.height) / 2 }
)
canvas.drawBitmap(imageBitmap, imageBitmap.rect, upperPart, null)
scope?.launchUI { progressBar.setProgress(98) }
val bottomPart = Rect(
if (!isLTR) 0 else width,
(maxHeight - imageBitmap2.height) / 2,
(if (!isLTR) 0 else width) + imageBitmap2.width,
imageBitmap2.height + (maxHeight - imageBitmap2.height) / 2
)
canvas.drawBitmap(imageBitmap2, imageBitmap2.rect, bottomPart, null)
scope?.launchUI { progressBar.setProgress(99) }
val output = ByteArrayOutputStream()
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
imageStream.close() imageStream.close()
imageStream2.close() imageStream2.close()
scope?.launchUI { progressBar.completeAndFadeOut() } return ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, bg) {
return ByteArrayInputStream(output.toByteArray()) scope?.launchUI {
if (it == 100) {
progressBar.completeAndFadeOut()
} else {
progressBar.setProgress(it)
}
}
}
} }
private fun splitDoublePages() { private fun splitDoublePages() {
@ -734,9 +716,6 @@ class PagerPageHolder(
} }
} }
private val Bitmap.rect: Rect
get() = Rect(0, 0, width, height)
companion object { companion object {
fun getBGType(readerTheme: Int, context: Context): Int { fun getBGType(readerTheme: Int, context: Context): Int {
return if (readerTheme == 3) { return if (readerTheme == 3) {

@ -3,14 +3,20 @@ package eu.kanade.tachiyomi.util.system
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import androidx.annotation.ColorInt
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.net.URLConnection import java.net.URLConnection
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max
object ImageUtil { object ImageUtil {
@ -244,6 +250,47 @@ object ImageUtil {
return ColorDrawable(backgroundColor) return ColorDrawable(backgroundColor)
} }
fun mergeBitmaps(
imageBitmap: Bitmap,
imageBitmap2: Bitmap,
isLTR: Boolean,
@ColorInt background: Int = Color.WHITE,
progressCallback: ((Int) -> Unit)? = null
): ByteArrayInputStream {
val height = imageBitmap.height
val width = imageBitmap.width
val height2 = imageBitmap2.height
val width2 = imageBitmap2.width
val maxHeight = max(height, height2)
val result = Bitmap.createBitmap(width + width2, max(height, height2), Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
canvas.drawColor(background)
val upperPart = Rect(
if (isLTR) 0 else width2,
(maxHeight - imageBitmap.height) / 2,
(if (isLTR) 0 else width2) + imageBitmap.width,
imageBitmap.height + (maxHeight - imageBitmap.height) / 2
)
canvas.drawBitmap(imageBitmap, imageBitmap.rect, upperPart, null)
progressCallback?.invoke(98)
val bottomPart = Rect(
if (!isLTR) 0 else width,
(maxHeight - imageBitmap2.height) / 2,
(if (!isLTR) 0 else width) + imageBitmap2.width,
imageBitmap2.height + (maxHeight - imageBitmap2.height) / 2
)
canvas.drawBitmap(imageBitmap2, imageBitmap2.rect, bottomPart, null)
progressCallback?.invoke(99)
val output = ByteArrayOutputStream()
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
progressCallback?.invoke(100)
return ByteArrayInputStream(output.toByteArray())
}
private val Bitmap.rect: Rect
get() = Rect(0, 0, width, height)
fun Boolean.toInt() = if (this) 1 else 0 fun Boolean.toInt() = if (this) 1 else 0
private fun isDark(color: Int): Boolean { private fun isDark(color: Int): Boolean {
return Color.red(color) < 40 && Color.blue(color) < 40 && Color.green(color) < 40 && return Color.red(color) < 40 && Color.blue(color) < 40 && Color.green(color) < 40 &&

@ -0,0 +1,9 @@
<!-- drawable/content_save_all_outline.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/actionBarTintColor">
<path android:fillColor="#000" android:pathData="M1 7H3V21H17V23H3C1.9 23 1 22.11 1 21V7M19 1H7C5.89 1 5 1.9 5 3V17C5 18.1 5.89 19 7 19H21C22.1 19 23 18.1 23 17V5L19 1M21 17H7V3H18.17L21 5.83V17M14 10C12.34 10 11 11.34 11 13S12.34 16 14 16 17 14.66 17 13 15.66 10 14 10M8 4H17V8H8V4Z" />
</vector>

@ -0,0 +1,9 @@
<!-- drawable/share_all_outline.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/actionBarTintColor">
<path android:fillColor="#000" android:pathData="M13 9.8V10.7L11.3 10.9C8.7 11.3 6.8 12.3 5.4 13.6C7.1 13.1 8.9 12.8 11 12.8H13V14.1L15.2 12L13 9.8M11 5L18 12L11 19V14.9C6 14.9 2.5 16.5 0 20C1 15 4 10 11 9M17 8V5L24 12L17 19V16L21 12" />
</vector>

@ -284,6 +284,7 @@
<string name="set_as_default_for_all">Set as default for all</string> <string name="set_as_default_for_all">Set as default for all</string>
<string name="cover_updated">Cover updated</string> <string name="cover_updated">Cover updated</string>
<string name="page_">Page %1$d</string> <string name="page_">Page %1$d</string>
<string name="pages_">Pages %1$s</string>
<string name="next_chapter_not_found">Next chapter not found</string> <string name="next_chapter_not_found">Next chapter not found</string>
<string name="decode_image_error">The image could not be decoded</string> <string name="decode_image_error">The image could not be decoded</string>
<string name="use_image_as_cover">Use this image as cover art?</string> <string name="use_image_as_cover">Use this image as cover art?</string>
@ -311,6 +312,9 @@
<string name="share_second_page">Share second page</string> <string name="share_second_page">Share second page</string>
<string name="save_second_page">Save second page</string> <string name="save_second_page">Save second page</string>
<string name="share_combined_pages">Share combined pages</string>
<string name="save_combined_pages">Save combined pages</string>
<!-- Reader settings --> <!-- Reader settings -->
<string name="fullscreen">Fullscreen</string> <string name="fullscreen">Fullscreen</string>
<string name="animate_page_transitions">Animate page transitions</string> <string name="animate_page_transitions">Animate page transitions</string>

Loading…
Cancel
Save