Skip to content

Commit

Permalink
feat: allow pasting images as png
Browse files Browse the repository at this point in the history
  • Loading branch information
criticalAY committed Jan 26, 2025
1 parent 140f780 commit fbf67bd
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 131 deletions.
213 changes: 95 additions & 118 deletions AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,79 @@ import java.io.InputStream
import java.lang.IllegalStateException

/**
* RegisterMediaForWebView is used for registering media in temp path,
* this class is required in summer note class for paste image event and in visual editor activity for importing media,
* (extracted code to avoid duplication of code).
* Utility class for media registration and handling errors during media paste actions.
*/
class MediaRegistration(
private val context: Context,
) {
// Use the same HTML if the same image is pasted multiple times.
private val pastedMediaCache = HashMap<String, String?>()
object MediaRegistration {
/**
* Represents different types of media errors.
*/
enum class MediaError {
GENERIC_ERROR,
CONVERSION_ERROR,
IMAGE_TOO_LARGE,
VIDEO_TO_LARGE,
AUDIO_TOO_LARGE,
}

private const val MEDIA_MAX_SIZE = 5 * 1000 * 1000

/**
* Handles the paste action for media.
*
* @param context The application context.
* @param uri The URI of the media to be pasted.
* @param description The description of the clipboard content.
* @param pasteAsPng A flag indicating whether to convert the media to PNG format.
* @param showError A callback function for displaying error messages based on media error type.
* @return A string reference to the media if successfully processed, or null if an error occurred.
*/
fun onPaste(
context: Context,
uri: Uri,
description: ClipDescription,
pasteAsPng: Boolean,
showError: (type: MediaError, message: String?) -> Unit,
): String? =
try {
loadMediaIntoCollection(context, uri, description, pasteAsPng, showError)
} catch (ex: NullPointerException) {
// Tested under FB Messenger and GMail, both apps do nothing if this occurs.
// This typically works if the user copies again - don't know the exact cause

// java.lang.SecurityException: Permission Denial: opening provider
// org.chromium.chrome.browser.util.ChromeFileProvider from ProcessRecord{80125c 11262:com.ichi2.anki/u0a455}
// (pid=11262, uid=10455) that is not exported from UID 10057
Timber.w(ex, "Failed to paste media")
showError(MediaError.GENERIC_ERROR, null)
null
} catch (ex: SecurityException) {
Timber.w(ex, "Failed to paste media")
showError(MediaError.GENERIC_ERROR, null)
null
} catch (e: Exception) {
// NOTE: This is happy path coding which works on Android 9.
CrashReportService.sendExceptionReport("File is invalid issue:8880", "RegisterMediaForWebView:onImagePaste URI of file:$uri")
Timber.w(e, "Failed to paste media")
showError(MediaError.GENERIC_ERROR, null)
null
}

fun checkMediaSize(
bytesWritten: Long,
isImage: Boolean,
isVideo: Boolean,
showError: (type: MediaError, message: String?) -> Unit,
): Boolean {
if (bytesWritten > MEDIA_MAX_SIZE) {
when {
isImage -> showError(MediaError.IMAGE_TOO_LARGE, null)
isVideo -> showError(MediaError.VIDEO_TO_LARGE, null)
else -> showError(MediaError.AUDIO_TOO_LARGE, null)
}
return false
}
return true
}

/**
* Loads media into the collection.media directory and returns a HTML reference
Expand All @@ -54,11 +118,14 @@ class MediaRegistration(
*/
@Throws(IOException::class)
fun loadMediaIntoCollection(
context: Context,
uri: Uri,
description: ClipDescription,
pasteAsPng: Boolean,
showError: (type: MediaError, message: String?) -> Unit,
): String? {
val filename = getFileName(context.contentResolver, uri)
val fd = openInputStreamWithURI(uri)
val fd = openInputStreamWithURI(context, uri)
val (fileName, fileExtensionWithDot) =
FileNameAndExtension
.fromString(filename)
Expand All @@ -69,13 +136,11 @@ class MediaRegistration(
val isImage = ClipboardUtil.hasImage(description)
val isVideo = ClipboardUtil.hasVideo(description)

openInputStreamWithURI(uri).use { copyFd ->
// no conversion to jpg in cases of gif and jpg and if png image with alpha channel
if (shouldConvertToJPG(fileExtensionWithDot, copyFd, isImage)) {
clipCopy = File.createTempFile(fileName, ".jpg")
openInputStreamWithURI(context, uri).use { _ ->
if (pasteAsPng) {
clipCopy = File.createTempFile(fileName, ".png")
bytesWritten = CompatHelper.compat.copyFile(fd, clipCopy.absolutePath)
// return null if jpg conversion false.
if (!convertToJPG(clipCopy)) {
if (!convertToPNG(clipCopy, showError)) {
return null
}
} else {
Expand All @@ -89,103 +154,44 @@ class MediaRegistration(
return null
}
Timber.d("File was %d bytes", bytesWritten)
if (bytesWritten > MEDIA_MAX_SIZE) {
Timber.w("File was too large: %d bytes", bytesWritten)
val message =
if (isImage) {
context.getString(R.string.note_editor_image_too_large)
} else if (isVideo) {
context.getString(R.string.note_editor_video_too_large)
} else {
context.getString(R.string.note_editor_audio_too_large)
}
showThemedToast(context, message, false)

if (!checkMediaSize(bytesWritten, isImage, isVideo, showError)) {
File(tempFilePath).delete()
return null
}
val field =
if (isImage) {
ImageField()
} else {
MediaClipField()
}

val field = if (isImage) ImageField() else MediaClipField()

field.hasTemporaryMedia = true
field.mediaPath = tempFilePath
return field.formattedValue
}

@Throws(FileNotFoundException::class)
private fun openInputStreamWithURI(uri: Uri): InputStream = context.contentResolver.openInputStream(uri)!!
private fun openInputStreamWithURI(
context: Context,
uri: Uri,
): InputStream = context.contentResolver.openInputStream(uri)!!

private fun convertToJPG(file: File): Boolean {
private fun convertToPNG(
file: File,
showError: (type: MediaError, message: String?) -> Unit,
): Boolean {
val bm = BitmapFactory.decodeFile(file.absolutePath)
try {
FileOutputStream(file.absolutePath).use { outStream ->
bm.compress(Bitmap.CompressFormat.JPEG, 100, outStream)
bm.compress(Bitmap.CompressFormat.PNG, 100, outStream)
outStream.flush()
}
} catch (e: IOException) {
Timber.w("MediaRegistration : Unable to convert file to png format")
CrashReportService.sendExceptionReport(e, "Unable to convert file to png format")
showThemedToast(context, context.resources.getString(R.string.multimedia_editor_png_paste_error, e.message), true)
return false
}
return true // successful conversion to jpg.
}

private fun shouldConvertToJPG(
fileNameExtension: String,
fileStream: InputStream,
isImage: Boolean,
): Boolean {
if (!isImage) {
return false
}
if (".svg" == fileNameExtension) {
showError(MediaError.CONVERSION_ERROR, e.message)
return false
}
if (".jpg" == fileNameExtension) {
return false // we are already a jpg, no conversion
}
if (".gif" == fileNameExtension) {
return false // gifs may have animation, conversion would ruin them
}
if (".png" == fileNameExtension && doesInputStreamContainTransparency(fileStream)) {
return false // pngs with transparency would be ruined by conversion
}
return true
}

fun onPaste(
uri: Uri,
description: ClipDescription,
): String? =
try {
// check if cache already holds registered file or not
if (!pastedMediaCache.containsKey(uri.toString())) {
pastedMediaCache[uri.toString()] = loadMediaIntoCollection(uri, description)
}
pastedMediaCache[uri.toString()]
} catch (ex: NullPointerException) {
// Tested under FB Messenger and GMail, both apps do nothing if this occurs.
// This typically works if the user copies again - don't know the exact cause

// java.lang.SecurityException: Permission Denial: opening provider
// org.chromium.chrome.browser.util.ChromeFileProvider from ProcessRecord{80125c 11262:com.ichi2.anki/u0a455}
// (pid=11262, uid=10455) that is not exported from UID 10057
Timber.w(ex, "Failed to paste media")
null
} catch (ex: SecurityException) {
Timber.w(ex, "Failed to paste media")
null
} catch (e: Exception) {
// NOTE: This is happy path coding which works on Android 9.
CrashReportService.sendExceptionReport("File is invalid issue:8880", "RegisterMediaForWebView:onImagePaste URI of file:$uri")
Timber.w(e, "Failed to paste media")
showThemedToast(context, context.getString(R.string.multimedia_editor_something_wrong), false)
null
}

@CheckResult
fun registerMediaForWebView(mediaPath: String?): Boolean {
if (mediaPath == null) {
Expand All @@ -205,33 +211,4 @@ class MediaRegistration(
false
}
}

companion object {
private const val MEDIA_MAX_SIZE = 5 * 1000 * 1000
private const val COLOR_GREY = 0
private const val COLOR_TRUE = 2
private const val COLOR_INDEX = 3
private const val COLOR_GREY_ALPHA = 4
private const val COLOR_TRUE_ALPHA = 6

/**
* given an inputStream of a file,
* returns true if found that it has transparency (in its header)
* code: https://stackoverflow.com/a/31311718/14148406
*/
private fun doesInputStreamContainTransparency(inputStream: InputStream): Boolean {
try {
// skip: png signature,header chunk declaration,width,height,bitDepth :
inputStream.skip((12 + 4 + 4 + 4 + 1).toLong())
when (inputStream.read()) {
COLOR_GREY_ALPHA, COLOR_TRUE_ALPHA -> return true
COLOR_INDEX, COLOR_GREY, COLOR_TRUE -> return false
}
return true
} catch (e: Exception) {
Timber.w(e, "Failed to check transparency of inputStream")
}
return false
}
}
}
60 changes: 50 additions & 10 deletions AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,6 @@ class NoteEditor :
private var reloadRequired = false

private var fieldsLayoutContainer: LinearLayout? = null
private var mediaRegistration: MediaRegistration? = null
private var tagsDialogFactory: TagsDialogFactory? = null
private var tagsButton: AppCompatButton? = null
private var cardsButton: AppCompatButton? = null
Expand Down Expand Up @@ -395,7 +394,10 @@ class NoteEditor :

for (uri in clip.items().map { it.uri }) {
try {
onPaste(view as EditText, uri, description)
lifecycleScope.launch {
val pasteAsPng = shouldPasteAsPng()
onPaste(view as EditText, uri, description, pasteAsPng)
}
} catch (e: Exception) {
Timber.w(e)
CrashReportService.sendExceptionReport(e, "NoteEditor::onReceiveContent")
Expand Down Expand Up @@ -486,7 +488,6 @@ class NoteEditor :
// ----------------------------------------------------------------------------
override fun onCreate(savedInstanceState: Bundle?) {
tagsDialogFactory = TagsDialogFactory(this).attachToFragmentManager<TagsDialogFactory>(parentFragmentManager)
mediaRegistration = MediaRegistration(requireContext())
super.onCreate(savedInstanceState)
fieldState.setInstanceState(savedInstanceState)
val intent = requireActivity().intent
Expand Down Expand Up @@ -1685,6 +1686,9 @@ class NoteEditor :
return note
}

/** Determines whether pasted images should be handled as PNG format. **/
private suspend fun shouldPasteAsPng() = withCol { config.getBool(ConfigKey.Bool.PASTE_IMAGES_AS_PNG) }

val currentFields: Fields
get() = editorNote!!.notetype.flds

Expand Down Expand Up @@ -1731,12 +1735,16 @@ class NoteEditor :
val editLineView = editLines[i]
customViewIds.add(editLineView.id)
val newEditText = editLineView.editText
newEditText.setPasteListener { editText: EditText?, uri: Uri?, description: ClipDescription? ->
onPaste(
editText!!,
uri!!,
description!!,
)
lifecycleScope.launch {
val pasteAsPng = shouldPasteAsPng()
newEditText.setPasteListener { editText: EditText?, uri: Uri?, description: ClipDescription? ->
onPaste(
editText!!,
uri!!,
description!!,
pasteAsPng,
)
}
}
editLineView.configureView(
requireActivity(),
Expand Down Expand Up @@ -2010,12 +2018,44 @@ class NoteEditor :
editText: EditText,
uri: Uri,
description: ClipDescription,
pasteAsPng: Boolean,
): Boolean {
val mediaTag = mediaRegistration!!.onPaste(uri, description) ?: return false
val mediaTag =
MediaRegistration.onPaste(
requireContext(),
uri,
description,
pasteAsPng,
showError = ::handleMediaError,
) ?: return false

insertStringInField(editText, mediaTag)
return true
}

private fun handleMediaError(
errorType: MediaRegistration.MediaError,
message: String?,
) {
when (errorType) {
MediaRegistration.MediaError.GENERIC_ERROR -> {
showSnackbar(getString(R.string.multimedia_editor_something_wrong))
}
MediaRegistration.MediaError.CONVERSION_ERROR -> {
showSnackbar(getString(R.string.multimedia_editor_png_paste_error, message ?: ""))
}
MediaRegistration.MediaError.IMAGE_TOO_LARGE -> {
showSnackbar(getString(R.string.note_editor_image_too_large))
}
MediaRegistration.MediaError.VIDEO_TO_LARGE -> {
showSnackbar(getString(R.string.note_editor_video_too_large))
}
MediaRegistration.MediaError.AUDIO_TOO_LARGE -> {
showSnackbar(getString(R.string.note_editor_audio_too_large))
}
}
}

@NeedsTest("If a field is sticky after synchronization, the toggleStickyButton should be activated.")
private fun setToggleStickyButtonListener(
toggleStickyButton: ImageButton,
Expand Down
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/res/values/10-preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
<string name="pref_cat_advanced" maxLength="41">Advanced</string>
<string name="pref_cat_workarounds" maxLength="41">Workarounds</string>
<string name="pref_cat_plugins" maxLength="41">Plugins</string>
<string name="pref_cat_editing" maxLength="41">Editing</string>

<!-- preferences.xml entries-->
<string name="whiteboard_stroke_width" maxLength="41">Stroke width</string>
Expand Down Expand Up @@ -181,7 +182,6 @@
<string name="accessibility" maxLength="41">Accessibility</string>
<!-- Paste clipboard image as png option -->
<string name="paste_as_png" maxLength="41">Paste clipboard images as PNG</string>
<string name="paste_as_png_summary">By default Anki pastes images on the clipboard as JPG files to save disk space. You can use this option to paste as PNG images instead.</string>
<string name="exit_via_double_tap_back" maxLength="41">Press back twice to go back/exit</string>
<string name="exit_via_double_tap_back_summ">To avoid accidentally leaving the reviewer or the app</string>
<!-- Allow all files in media imports -->
Expand Down
Loading

0 comments on commit fbf67bd

Please sign in to comment.