From 935086e003fb802e136972c92ae464f300f61c44 Mon Sep 17 00:00:00 2001 From: SanjaySargam Date: Wed, 24 Jul 2024 10:32:45 +0530 Subject: [PATCH 1/2] rename 'image' to 'media' In preparation for 'Drag & Drop' work, which will allow importing of media --- .../main/java/com/ichi2/anki/FieldEditText.kt | 26 ++++++++-------- .../java/com/ichi2/anki/MediaRegistration.kt | 30 +++++++++---------- .../main/java/com/ichi2/anki/NoteEditor.kt | 14 ++++----- .../java/com/ichi2/utils/ClipboardUtil.kt | 2 +- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt b/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt index 8a14b79ca723..cea4773b0cdf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt @@ -41,8 +41,8 @@ import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.servicelayer.NoteService import com.ichi2.ui.FixedEditText import com.ichi2.utils.ClipboardUtil.IMAGE_MIME_TYPES -import com.ichi2.utils.ClipboardUtil.getImageUri import com.ichi2.utils.ClipboardUtil.getPlainText +import com.ichi2.utils.ClipboardUtil.getUri import com.ichi2.utils.ClipboardUtil.hasImage import com.ichi2.utils.KotlinCleanup import kotlinx.parcelize.Parcelize @@ -55,7 +55,7 @@ class FieldEditText : FixedEditText, NoteService.NoteField { override var ord = 0 private var origBackground: Drawable? = null private var selectionChangeListener: TextSelectionListener? = null - private var imageListener: ImagePasteListener? = null + private var pasteListener: PasteListener? = null @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) var clipboard: ClipboardManager? = null @@ -96,8 +96,8 @@ class FieldEditText : FixedEditText, NoteService.NoteField { setDefaultStyle() } - fun setImagePasteListener(imageListener: ImagePasteListener?) { - this.imageListener = imageListener + fun setPasteListener(pasteListener: PasteListener) { + this.pasteListener = pasteListener } @KotlinCleanup("add extension method to iterate clip items") @@ -113,7 +113,7 @@ class FieldEditText : FixedEditText, NoteService.NoteField { val uriContent = pair.first val remaining = pair.second - if (imageListener == null || uriContent == null) { + if (pasteListener == null || uriContent == null) { return remaining } @@ -127,7 +127,7 @@ class FieldEditText : FixedEditText, NoteService.NoteField { for (i in 0 until clip.itemCount) { val uri = clip.getItemAt(i).uri try { - onImagePaste(uri) + onPaste(uri) } catch (e: Exception) { Timber.w(e) CrashReportService.sendExceptionReport(e, "NoteEditor::onImage") @@ -191,10 +191,10 @@ class FieldEditText : FixedEditText, NoteService.NoteField { } override fun onTextContextMenuItem(id: Int): Boolean { - // This handles both CTRL+V and "Paste" + // The current function is called both by Ctrl+V and pasting from the context menu if (id == android.R.id.paste) { if (hasImage(clipboard)) { - return onImagePaste(getImageUri(clipboard)) + return onPaste(getUri(clipboard)) } return pastePlainText() } @@ -215,11 +215,11 @@ class FieldEditText : FixedEditText, NoteService.NoteField { return false } - private fun onImagePaste(imageUri: Uri?): Boolean { - return if (imageUri == null) { + private fun onPaste(mediaUri: Uri?): Boolean { + return if (mediaUri == null) { false } else { - imageListener!!.onImagePaste(this, imageUri) + pasteListener!!.onPaste(this, mediaUri) } } @@ -251,8 +251,8 @@ class FieldEditText : FixedEditText, NoteService.NoteField { fun onSelectionChanged(selStart: Int, selEnd: Int) } - fun interface ImagePasteListener { - fun onImagePaste(editText: EditText, uri: Uri?): Boolean + fun interface PasteListener { + fun onPaste(editText: EditText, uri: Uri?): Boolean } companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt b/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt index 0bb437f8c822..f27372219687 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt @@ -39,15 +39,15 @@ import java.io.InputStream */ class MediaRegistration(private val context: Context) { // Use the same HTML if the same image is pasted multiple times. - private val pastedImageCache = HashMap() + private val pastedMediaCache = HashMap() /** - * Loads an image into the collection.media directory and returns a HTML reference + * Loads media into the collection.media directory and returns a HTML reference * @param uri The uri of the image to load * @return HTML referring to the loaded image */ @Throws(IOException::class) - fun loadImageIntoCollection(uri: Uri): String? { + fun loadMediaIntoCollection(uri: Uri): String? { val fileName: String val filename = getFileName(context.contentResolver, uri) val fd = openInputStreamWithURI(uri) @@ -87,7 +87,7 @@ class MediaRegistration(private val context: Context) { } val field = ImageField() field.hasTemporaryMedia = true - field.extraImagePathRef = tempFilePath + field.mediaPath = tempFilePath return field.formattedValue } @@ -129,13 +129,13 @@ class MediaRegistration(private val context: Context) { return fileNameAndExtension.key.length <= 3 } - fun onImagePaste(uri: Uri): String? { + fun onPaste(uri: Uri): String? { return try { // check if cache already holds registered file or not - if (!pastedImageCache.containsKey(uri.toString())) { - pastedImageCache[uri.toString()] = loadImageIntoCollection(uri) + if (!pastedMediaCache.containsKey(uri.toString())) { + pastedMediaCache[uri.toString()] = loadMediaIntoCollection(uri) } - pastedImageCache[uri.toString()] + 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 @@ -143,28 +143,28 @@ class MediaRegistration(private val context: Context) { // 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 image") + Timber.w(ex, "Failed to paste media") null } catch (ex: SecurityException) { - Timber.w(ex, "Failed to paste image") + 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 image") + Timber.w(e, "Failed to paste media") showThemedToast(context, context.getString(R.string.multimedia_editor_something_wrong), false) null } } @CheckResult - fun registerMediaForWebView(imagePath: String?): Boolean { - if (imagePath == null) { + fun registerMediaForWebView(mediaPath: String?): Boolean { + if (mediaPath == null) { // Nothing to register - continue with execution. return true } - Timber.i("Adding media to collection: %s", imagePath) - val f = File(imagePath) + Timber.i("Adding media to collection: %s", mediaPath) + val f = File(mediaPath) return try { CollectionManager.getColUnsafe().media.addFile(f) true diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt index 3192577e8639..e7b09accbdaa 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt @@ -108,6 +108,7 @@ import com.ichi2.anki.noteeditor.CustomToolbarButton import com.ichi2.anki.noteeditor.FieldState import com.ichi2.anki.noteeditor.FieldState.FieldChangeType import com.ichi2.anki.noteeditor.NoteEditorLauncher +import com.ichi2.anki.noteeditor.Toolbar import com.ichi2.anki.noteeditor.Toolbar.TextFormatListener import com.ichi2.anki.noteeditor.Toolbar.TextWrapper import com.ichi2.anki.pages.ImageOcclusion @@ -173,7 +174,6 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import androidx.appcompat.widget.Toolbar as MainToolbar -import com.ichi2.anki.noteeditor.Toolbar as Toolbar /** * Allows the user to edit a note, for instance if there is a typo. A card is a presentation of a note, and has two @@ -626,7 +626,7 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su // TODO: Support all extensions // See https://github.com/ankitects/anki/blob/6f3550464d37aee1b8b784e431cbfce8382d3ce7/rslib/src/image_occlusion/imagedata.rs#L154 if (ClipboardUtil.hasImage(clipboard)) { - val uri = ClipboardUtil.getImageUri(clipboard) + val uri = ClipboardUtil.getUri(clipboard) val i = Intent().apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) clipData = ClipData.newUri(requireActivity().contentResolver, uri.toString(), uri) @@ -1592,8 +1592,8 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su val editLineView = editLines[i] customViewIds.add(editLineView.id) val newEditText = editLineView.editText - newEditText.setImagePasteListener { editText: EditText?, uri: Uri? -> - onImagePaste( + newEditText.setPasteListener { editText: EditText?, uri: Uri? -> + onPaste( editText!!, uri!! ) @@ -1835,9 +1835,9 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su } } - private fun onImagePaste(editText: EditText, uri: Uri): Boolean { - val imageTag = mediaRegistration!!.onImagePaste(uri) ?: return false - insertStringInField(editText, imageTag) + private fun onPaste(editText: EditText, uri: Uri): Boolean { + val mediaTag = mediaRegistration!!.onPaste(uri) ?: return false + insertStringInField(editText, mediaTag) return true } diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt index eb61b9e16a92..7e3e462b2531 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt @@ -54,7 +54,7 @@ object ClipboardUtil { ?.takeIf { it.itemCount > 0 } ?.getItemAt(0) - fun getImageUri(clipboard: ClipboardManager?): Uri? { + fun getUri(clipboard: ClipboardManager?): Uri? { return getFirstItem(clipboard)?.uri } From 33498e87639ddd4b1d12705ac77b7c1d3294ec5e Mon Sep 17 00:00:00 2001 From: SanjaySargam Date: Wed, 24 Jul 2024 10:34:24 +0530 Subject: [PATCH 2/2] note-editor: drop files This commit ensures that we can drop files (photos, videos, and audio) in NoteEditor (FixedEditText). Fix: Handle minSdkVersion conflict with DropHelper and enable file drop in NoteEditor Added DropHelperCompat to handle the incompatibility issue with DropHelper and minSdkVersion 23. The manifest merger failed with the following error: > Manifest merger failed: uses-sdk:minSdkVersion 23 cannot be smaller than version 24 declared in library [androidx.draganddrop:draganddrop:1.0.0] /Users/davidallison/.gradle/cache> s/8.8/transforms/0a1688833d368c1b9b07d2054911030e/transformed/draganddrop-1.0.0/AndroidManifest.xml as the library might be using APIs not available in 23 > Suggestion: use a compatible library with a minSdk of at most 23, > or increase this project's minSdk version to at least 24, > or use tools:overrideLibrary="androidx.draganddrop" to force usage > (may lead to runtime failures) To resolve this, the DropHelperCompat class is used to conditionally configure the view for drag and drop operations only when the SDK version is 24 or higher. --- AnkiDroid/build.gradle | 1 + AnkiDroid/src/main/AndroidManifest.xml | 6 ++ .../main/java/com/ichi2/anki/FieldEditText.kt | 66 +++---------------- .../java/com/ichi2/anki/MediaRegistration.kt | 37 +++++++++-- .../main/java/com/ichi2/anki/NoteEditor.kt | 59 +++++++++++++++-- .../src/main/java/com/ichi2/compat/Compat.kt | 16 +++++ .../main/java/com/ichi2/compat/CompatV23.kt | 13 ++++ .../main/java/com/ichi2/compat/CompatV24.kt | 20 ++++++ .../java/com/ichi2/utils/ClipboardUtil.kt | 55 ++++++++++++---- AnkiDroid/src/main/res/values/02-strings.xml | 4 +- .../java/com/ichi2/utils/ClipboardUtilTest.kt | 64 ++++++++++++++++++ gradle/libs.versions.toml | 3 + testlib/src/main/AndroidManifest.xml | 15 ++++- 13 files changed, 275 insertions(+), 84 deletions(-) diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index 9d9009bab331..6f6199380d04 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -337,6 +337,7 @@ dependencies { implementation libs.androidx.appcompat implementation libs.androidx.browser implementation libs.androidx.core.ktx + implementation libs.androidx.draganddrop implementation libs.androidx.exifinterface implementation libs.androidx.fragment.ktx implementation libs.androidx.media diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml index 414e15995574..7819613e76dc 100644 --- a/AnkiDroid/src/main/AndroidManifest.xml +++ b/AnkiDroid/src/main/AndroidManifest.xml @@ -21,6 +21,12 @@ + + + + + + diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt b/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt index cea4773b0cdf..40d94e7fc359 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt @@ -16,6 +16,7 @@ package com.ichi2.anki +import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context import android.graphics.drawable.Drawable @@ -25,25 +26,18 @@ import android.os.LocaleList import android.os.Parcelable import android.text.InputType import android.util.AttributeSet -import android.view.View import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputConnection import android.widget.EditText import androidx.annotation.RequiresApi import androidx.annotation.VisibleForTesting -import androidx.core.view.ContentInfoCompat -import androidx.core.view.OnReceiveContentListener -import androidx.core.view.ViewCompat -import androidx.core.view.inputmethod.EditorInfoCompat -import androidx.core.view.inputmethod.InputConnectionCompat import com.google.android.material.color.MaterialColors import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.servicelayer.NoteService import com.ichi2.ui.FixedEditText -import com.ichi2.utils.ClipboardUtil.IMAGE_MIME_TYPES +import com.ichi2.utils.ClipboardUtil.getDescription import com.ichi2.utils.ClipboardUtil.getPlainText import com.ichi2.utils.ClipboardUtil.getUri -import com.ichi2.utils.ClipboardUtil.hasImage +import com.ichi2.utils.ClipboardUtil.hasMedia import com.ichi2.utils.KotlinCleanup import kotlinx.parcelize.Parcelize import timber.log.Timber @@ -100,49 +94,6 @@ class FieldEditText : FixedEditText, NoteService.NoteField { this.pasteListener = pasteListener } - @KotlinCleanup("add extension method to iterate clip items") - override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection? { - val inputConnection = super.onCreateInputConnection(editorInfo) ?: return null - EditorInfoCompat.setContentMimeTypes(editorInfo, IMAGE_MIME_TYPES) - ViewCompat.setOnReceiveContentListener( - this, - IMAGE_MIME_TYPES, - object : OnReceiveContentListener { - override fun onReceiveContent(view: View, payload: ContentInfoCompat): ContentInfoCompat? { - val pair = payload.partition { item -> item.uri != null } - val uriContent = pair.first - val remaining = pair.second - - if (pasteListener == null || uriContent == null) { - return remaining - } - - val clip = uriContent.clip - val description = clip.description - - if (!hasImage(description)) { - return remaining - } - - for (i in 0 until clip.itemCount) { - val uri = clip.getItemAt(i).uri - try { - onPaste(uri) - } catch (e: Exception) { - Timber.w(e) - CrashReportService.sendExceptionReport(e, "NoteEditor::onImage") - return remaining - } - } - - return remaining - } - } - ) - - return InputConnectionCompat.createWrapper(this, inputConnection, editorInfo) - } - override fun onSelectionChanged(selStart: Int, selEnd: Int) { if (selectionChangeListener != null) { try { @@ -192,9 +143,10 @@ class FieldEditText : FixedEditText, NoteService.NoteField { override fun onTextContextMenuItem(id: Int): Boolean { // The current function is called both by Ctrl+V and pasting from the context menu + // It does not deal with drag and drop if (id == android.R.id.paste) { - if (hasImage(clipboard)) { - return onPaste(getUri(clipboard)) + if (hasMedia(clipboard)) { + return onPaste(getUri(clipboard), getDescription(clipboard)) } return pastePlainText() } @@ -215,11 +167,11 @@ class FieldEditText : FixedEditText, NoteService.NoteField { return false } - private fun onPaste(mediaUri: Uri?): Boolean { + private fun onPaste(mediaUri: Uri?, description: ClipDescription?): Boolean { return if (mediaUri == null) { false } else { - pasteListener!!.onPaste(this, mediaUri) + pasteListener!!.onPaste(this, mediaUri, description) } } @@ -252,7 +204,7 @@ class FieldEditText : FixedEditText, NoteService.NoteField { } fun interface PasteListener { - fun onPaste(editText: EditText, uri: Uri?): Boolean + fun onPaste(editText: EditText, uri: Uri?, description: ClipDescription?): Boolean } companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt b/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt index f27372219687..2ab46df5b963 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt @@ -15,14 +15,17 @@ */ package com.ichi2.anki +import android.content.ClipDescription import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import androidx.annotation.CheckResult import com.ichi2.anki.multimediacard.fields.ImageField +import com.ichi2.anki.multimediacard.fields.MediaClipField import com.ichi2.compat.CompatHelper import com.ichi2.libanki.exception.EmptyMediaException +import com.ichi2.utils.ClipboardUtil import com.ichi2.utils.ContentResolverUtil.getFileName import com.ichi2.utils.FileUtil.getFileNameAndExtension import timber.log.Timber @@ -47,7 +50,7 @@ class MediaRegistration(private val context: Context) { * @return HTML referring to the loaded image */ @Throws(IOException::class) - fun loadMediaIntoCollection(uri: Uri): String? { + fun loadMediaIntoCollection(uri: Uri, description: ClipDescription): String? { val fileName: String val filename = getFileName(context.contentResolver, uri) val fd = openInputStreamWithURI(uri) @@ -59,9 +62,12 @@ class MediaRegistration(private val context: Context) { } var clipCopy: File var bytesWritten: Long + 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(fileNameAndExtension.value, copyFd)) { + if (shouldConvertToJPG(fileNameAndExtension.value, copyFd, isImage)) { clipCopy = File.createTempFile(fileName, ".jpg") bytesWritten = CompatHelper.compat.copyFile(fd, clipCopy.absolutePath) // return null if jpg conversion false. @@ -81,11 +87,22 @@ class MediaRegistration(private val context: Context) { Timber.d("File was %d bytes", bytesWritten) if (bytesWritten > MEDIA_MAX_SIZE) { Timber.w("File was too large: %d bytes", bytesWritten) - showThemedToast(context, context.getString(R.string.note_editor_paste_too_large), false) + 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) File(tempFilePath).delete() return null } - val field = ImageField() + val field = if (isImage) { + ImageField() + } else { + MediaClipField() + } field.hasTemporaryMedia = true field.mediaPath = tempFilePath return field.formattedValue @@ -112,7 +129,13 @@ class MediaRegistration(private val context: Context) { return true // successful conversion to jpg. } - private fun shouldConvertToJPG(fileNameExtension: String, fileStream: InputStream): Boolean { + private fun shouldConvertToJPG(fileNameExtension: String, fileStream: InputStream, isImage: Boolean): Boolean { + if (!isImage) { + return false + } + if (".svg" == fileNameExtension) { + return false + } if (".jpg" == fileNameExtension) { return false // we are already a jpg, no conversion } @@ -129,11 +152,11 @@ class MediaRegistration(private val context: Context) { return fileNameAndExtension.key.length <= 3 } - fun onPaste(uri: Uri): String? { + fun onPaste(uri: Uri, description: ClipDescription): String? { return try { // check if cache already holds registered file or not if (!pastedMediaCache.containsKey(uri.toString())) { - pastedMediaCache[uri.toString()] = loadMediaIntoCollection(uri) + pastedMediaCache[uri.toString()] = loadMediaIntoCollection(uri, description) } pastedMediaCache[uri.toString()] } catch (ex: NullPointerException) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt index e7b09accbdaa..68be362d572b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt @@ -23,6 +23,7 @@ import android.app.Activity import android.app.Activity.RESULT_CANCELED import android.content.BroadcastReceiver import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context import android.content.Intent @@ -67,7 +68,11 @@ import androidx.core.content.edit import androidx.core.content.res.ResourcesCompat import androidx.core.os.BundleCompat import androidx.core.text.HtmlCompat +import androidx.core.util.component1 +import androidx.core.util.component2 +import androidx.core.view.OnReceiveContentListener import androidx.core.view.isVisible +import androidx.draganddrop.DropHelper import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import anki.config.ConfigKey @@ -126,6 +131,7 @@ import com.ichi2.anki.utils.ext.isImageOcclusion import com.ichi2.anki.utils.ext.sharedPrefs import com.ichi2.anki.widgets.DeckDropDownAdapter.SubtitleListener import com.ichi2.annotations.NeedsTest +import com.ichi2.compat.CompatHelper import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat import com.ichi2.libanki.Card @@ -144,6 +150,8 @@ import com.ichi2.libanki.load import com.ichi2.libanki.note import com.ichi2.libanki.undoableOp import com.ichi2.utils.ClipboardUtil +import com.ichi2.utils.ClipboardUtil.hasMedia +import com.ichi2.utils.ClipboardUtil.items import com.ichi2.utils.HashUtil import com.ichi2.utils.ImageUtils import com.ichi2.utils.ImportUtils @@ -169,7 +177,6 @@ import java.io.File import java.util.LinkedList import java.util.Locale import java.util.function.Consumer -import kotlin.collections.ArrayList import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt @@ -333,6 +340,37 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su } ) + /** + * Listener for handling content received via drag and drop or copy and paste. + * This listener processes URIs contained in the payload and attempts to paste the content into the target EditText view. + */ + private val onReceiveContentListener = OnReceiveContentListener { view, payload -> + val (uriContent, remaining) = payload.partition { item -> item.uri != null } + + if (uriContent == null) { + return@OnReceiveContentListener remaining + } + + val clip = uriContent.clip + val description = clip.description + + if (!hasMedia(description)) { + return@OnReceiveContentListener remaining + } + + for (uri in clip.items().map { it.uri }) { + try { + onPaste(view as EditText, uri, description) + } catch (e: Exception) { + Timber.w(e) + CrashReportService.sendExceptionReport(e, "NoteEditor::onReceiveContent") + return@OnReceiveContentListener remaining + } + } + + return@OnReceiveContentListener remaining + } + private inner class NoteEditorActivityResultCallback(private val callback: (result: ActivityResult) -> Unit) : ActivityResultCallback { override fun onActivityResult(result: ActivityResult) { Timber.d("onActivityResult() with result: %s", result.resultCode) @@ -1592,12 +1630,23 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su val editLineView = editLines[i] customViewIds.add(editLineView.id) val newEditText = editLineView.editText - newEditText.setPasteListener { editText: EditText?, uri: Uri? -> + newEditText.setPasteListener { editText: EditText?, uri: Uri?, description: ClipDescription? -> onPaste( editText!!, - uri!! + uri!!, + description!! ) } + CompatHelper.compat.configureView( + requireActivity(), + editLineView, + DropHelper.Options.Builder() + .setHighlightColor(R.color.material_lime_green_A700) + .setHighlightCornerRadiusPx(0) + .addInnerEditTexts(newEditText) + .build(), + onReceiveContentListener + ) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { if (i == 0) { findViewById(R.id.note_deck_spinner).nextFocusForwardId = newEditText.id @@ -1835,8 +1884,8 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su } } - private fun onPaste(editText: EditText, uri: Uri): Boolean { - val mediaTag = mediaRegistration!!.onPaste(uri) ?: return false + private fun onPaste(editText: EditText, uri: Uri, description: ClipDescription): Boolean { + val mediaTag = mediaRegistration!!.onPaste(uri, description) ?: return false insertStringInField(editText, mediaTag) return true } diff --git a/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt b/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt index 01708d410a36..dd8414fe8d16 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt @@ -17,6 +17,7 @@ package com.ichi2.compat +import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageInfo @@ -30,6 +31,8 @@ import android.net.Uri import android.os.Bundle import android.view.View import androidx.annotation.CheckResult +import androidx.core.view.OnReceiveContentListener +import androidx.draganddrop.DropHelper import java.io.File import java.io.FileNotFoundException import java.io.IOException @@ -201,6 +204,19 @@ interface Compat { @Throws(IOException::class) fun contentOfDirectory(directory: File): FileStream + /** + * If possible, configures a [View] for drag and drop operations, including highlighting that + * indicates the view is a drop target. Sets a listener that enables the view to handle dropped data. + * + * @see DropHelper.configureView + */ + fun configureView( + activity: Activity, + view: View, + options: DropHelper.Options, + onReceiveContentListener: OnReceiveContentListener + ) + /** * Converts a locale to a 'two letter' code (ISO-639-1 + ISO 3166-1 alpha-2) * Locale("spa", "MEX", "001") => Locale("es", "MX", "001") diff --git a/AnkiDroid/src/main/java/com/ichi2/compat/CompatV23.kt b/AnkiDroid/src/main/java/com/ichi2/compat/CompatV23.kt index 264e257ba6a3..9112fbbf48a1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compat/CompatV23.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compat/CompatV23.kt @@ -16,6 +16,7 @@ package com.ichi2.compat +import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageInfo @@ -31,6 +32,8 @@ import android.os.Vibrator import android.provider.MediaStore import android.view.View import androidx.appcompat.widget.TooltipCompat +import androidx.core.view.OnReceiveContentListener +import androidx.draganddrop.DropHelper import com.ichi2.utils.KotlinCleanup import timber.log.Timber import java.io.File @@ -147,6 +150,16 @@ open class CompatV23 : Compat { } } + // Until API 24 + override fun configureView( + activity: Activity, + view: View, + options: DropHelper.Options, + onReceiveContentListener: OnReceiveContentListener + ) { + // No implementation possible. + } + // Until API 26 @Throws(IOException::class) override fun deleteFile(file: File) { diff --git a/AnkiDroid/src/main/java/com/ichi2/compat/CompatV24.kt b/AnkiDroid/src/main/java/com/ichi2/compat/CompatV24.kt index 1ee05b2cc627..4edcf131c034 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compat/CompatV24.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compat/CompatV24.kt @@ -17,9 +17,14 @@ package com.ichi2.compat import android.annotation.TargetApi +import android.app.Activity import android.icu.util.ULocale import android.view.MotionEvent +import android.view.View +import androidx.core.view.OnReceiveContentListener +import androidx.draganddrop.DropHelper import com.ichi2.anki.common.utils.android.isRobolectric +import com.ichi2.utils.ClipboardUtil.MEDIA_MIME_TYPES import timber.log.Timber import java.util.Locale @@ -40,6 +45,21 @@ open class CompatV24 : CompatV23(), Compat { } } + override fun configureView( + activity: Activity, + view: View, + options: DropHelper.Options, + onReceiveContentListener: OnReceiveContentListener + ) { + DropHelper.configureView( + activity, + view, + MEDIA_MIME_TYPES, + options, + onReceiveContentListener + ) + } + override val AXIS_RELATIVE_X: Int = MotionEvent.AXIS_RELATIVE_X override val AXIS_RELATIVE_Y: Int = MotionEvent.AXIS_RELATIVE_Y } diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt index 7e3e462b2531..8b515799c106 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt @@ -31,13 +31,13 @@ import com.ichi2.anki.snackbar.showSnackbar import timber.log.Timber object ClipboardUtil { - // JPEG is sent via pasted content - val IMAGE_MIME_TYPES = arrayOf("image/gif", "image/png", "image/jpg", "image/jpeg") + val IMAGE_MIME_TYPES = arrayOf("image/*") + val AUDIO_MIME_TYPES = arrayOf("audio/*") + val VIDEO_MIME_TYPES = arrayOf("video/*") + val MEDIA_MIME_TYPES = arrayOf(*IMAGE_MIME_TYPES, *AUDIO_MIME_TYPES, *VIDEO_MIME_TYPES) fun hasImage(clipboard: ClipboardManager?): Boolean { - return clipboard - ?.takeIf { it.hasPrimaryClip() } - ?.primaryClip + return clipboard?.primaryClip ?.let { hasImage(it.description) } ?: false } @@ -48,24 +48,53 @@ object ClipboardUtil { ?: false } - private fun getFirstItem(clipboard: ClipboardManager?) = clipboard - ?.takeIf { it.hasPrimaryClip() } - ?.primaryClip - ?.takeIf { it.itemCount > 0 } - ?.getItemAt(0) + fun hasVideo(description: ClipDescription?): Boolean { + return description + ?.run { VIDEO_MIME_TYPES.any { hasMimeType(it) } } + ?: false + } + + private fun ClipboardManager.getFirstItem() = + primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0) fun getUri(clipboard: ClipboardManager?): Uri? { - return getFirstItem(clipboard)?.uri + return clipboard?.getFirstItem()?.uri + } + + fun hasSVG(description: ClipDescription): Boolean { + return description.hasMimeType("image/svg+xml") + } + + fun hasMedia(clipboard: ClipboardManager?): Boolean { + return clipboard?.primaryClip + ?.let { hasMedia(it.description) } + ?: false + } + + fun hasMedia(description: ClipDescription?): Boolean { + return description + ?.run { MEDIA_MIME_TYPES.any { hasMimeType(it) } } + ?: false + } + + fun ClipData.items() = sequence { + for (j in 0 until itemCount) { + yield(getItemAt(j)) + } + } + + fun getDescription(clipboard: ClipboardManager?): ClipDescription? { + return clipboard?.primaryClip?.description } @CheckResult fun getText(clipboard: ClipboardManager?): CharSequence? { - return getFirstItem(clipboard)?.text + return clipboard?.getFirstItem()?.text } @CheckResult fun getPlainText(clipboard: ClipboardManager?, context: Context): CharSequence? { - return getFirstItem(clipboard)?.coerceToText(context) + return clipboard?.getFirstItem()?.coerceToText(context) } } diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index 473e0fc16a9d..661c9475ea82 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -240,7 +240,9 @@ Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it Remove Toolbar Item? - The image is too large to paste, please insert the image manually + The image is too large, please insert the image manually + The video file is too large, please insert the video manually + The audio file is too large, please insert the audio manually diff --git a/AnkiDroid/src/test/java/com/ichi2/utils/ClipboardUtilTest.kt b/AnkiDroid/src/test/java/com/ichi2/utils/ClipboardUtilTest.kt index 26c636c4583e..80522927a48b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/utils/ClipboardUtilTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/utils/ClipboardUtilTest.kt @@ -2,13 +2,33 @@ package com.ichi2.utils +import android.content.ClipData import android.content.ClipDescription import android.content.ClipboardManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.utils.ClipboardUtil.AUDIO_MIME_TYPES +import com.ichi2.utils.ClipboardUtil.IMAGE_MIME_TYPES +import com.ichi2.utils.ClipboardUtil.VIDEO_MIME_TYPES import com.ichi2.utils.ClipboardUtil.hasImage +import com.ichi2.utils.ClipboardUtil.hasMedia import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class ClipboardUtilTest { + + private lateinit var clipboardManager: ClipboardManager + + @Before + fun setUp() { + clipboardManager = ApplicationProvider.getApplicationContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + } + @Test fun hasImageClipboardManagerNullTest() { val clipboardManager: ClipboardManager? = null @@ -20,4 +40,48 @@ class ClipboardUtilTest { val clipDescription: ClipDescription? = null assertFalse(hasImage(clipDescription)) } + + @Test + fun hasMediaClipboardManagerNullTest() { + val clipboardManager: ClipboardManager? = null + assertFalse(hasMedia(clipboardManager)) + } + + @Test + fun hasMediaDescriptionNullTest() { + val clipDescription: ClipDescription? = null + assertFalse(hasMedia(clipDescription)) + } + + @Test + fun hasMediaWithImageMimeTypeTest() { + val clipDescription = ClipDescription("label", IMAGE_MIME_TYPES) + val clipData = ClipData(clipDescription, ClipData.Item("image data")) + clipboardManager.setPrimaryClip(clipData) + assertTrue(hasMedia(clipboardManager)) + } + + @Test + fun hasMediaWithSVGMimeTypeTest() { + val clipDescription = ClipDescription("label", arrayOf("image/svg+xml")) + val clipData = ClipData(clipDescription, ClipData.Item("svg data")) + clipboardManager.setPrimaryClip(clipData) + assertTrue(hasMedia(clipboardManager)) + } + + @Test + fun hasMediaWithAudioMimeTypeTest() { + val clipDescription = ClipDescription("label", AUDIO_MIME_TYPES) + val clipData = ClipData(clipDescription, ClipData.Item("audio data")) + clipboardManager.setPrimaryClip(clipData) + assertTrue(hasMedia(clipboardManager)) + } + + @Test + fun hasMediaWithVideoMimeTypeTest() { + val clipDescription = ClipDescription("label", VIDEO_MIME_TYPES) + val clipData = ClipData(clipDescription, ClipData.Item("video data")) + clipboardManager.setPrimaryClip(clipData) + assertTrue(hasMedia(clipboardManager)) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 640160781008..6f7dde922099 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,8 @@ androidxBrowser = "1.8.0" androidxConstraintlayout = "2.1.4" # https://developer.android.com/jetpack/androidx/releases/core androidxCoreKtx = "1.13.1" +# https://developer.android.com/jetpack/androidx/releases/draganddrop +androidxDragAndDrop = "1.0.0" # https://developer.android.com/jetpack/androidx/releases/exifinterface androidxExifinterface = "1.3.7" # https://developer.android.com/jetpack/androidx/releases/fragment @@ -106,6 +108,7 @@ androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayo androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidxFragmentKtx" } androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidxExifinterface" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" } +androidx-draganddrop = { module = "androidx.draganddrop:draganddrop", version.ref = "androidxDragAndDrop" } androidx-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppCompat" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidxAnnotation" } diff --git a/testlib/src/main/AndroidManifest.xml b/testlib/src/main/AndroidManifest.xml index 568741e54f2a..0140c31c9d98 100644 --- a/testlib/src/main/AndroidManifest.xml +++ b/testlib/src/main/AndroidManifest.xml @@ -1,2 +1,15 @@ - \ No newline at end of file + + + + \ No newline at end of file