From ef425b96e655e7a5a32969ec8529ba9f2c8a06b8 Mon Sep 17 00:00:00 2001 From: SanjaySargam Date: Wed, 24 Jul 2024 10:34:24 +0530 Subject: [PATCH] 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/caches/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 | 4 ++ .../main/java/com/ichi2/anki/FieldEditText.kt | 65 +++---------------- .../java/com/ichi2/anki/MediaRegistration.kt | 20 ++++-- .../main/java/com/ichi2/anki/NoteEditor.kt | 59 +++++++++++++++-- .../java/com/ichi2/compat/DropHelperCompat.kt | 59 +++++++++++++++++ .../java/com/ichi2/utils/ClipboardUtil.kt | 42 ++++++++++-- .../java/com/ichi2/utils/ClipboardUtilTest.kt | 56 ++++++++++++++++ gradle/libs.versions.toml | 2 + 9 files changed, 234 insertions(+), 74 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/compat/DropHelperCompat.kt diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index abc005a5ad29..8e2e05a49412 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 075cdf87aecb..84b1ba438b92 100644 --- a/AnkiDroid/src/main/AndroidManifest.xml +++ b/AnkiDroid/src/main/AndroidManifest.xml @@ -21,6 +21,10 @@ + + + + diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt b/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt index 6256685ae776..bea81979ee9f 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 = imageListener } - @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 { @@ -193,8 +144,8 @@ class FieldEditText : FixedEditText, NoteService.NoteField { override fun onTextContextMenuItem(id: Int): Boolean { // This handles both CTRL+V and "Paste" 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 +166,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 +203,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 01198534682e..38fb396ceafb 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 isSVG = ClipboardUtil.hasSVG(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 (!isSVG && isImage && shouldConvertToJPG(fileNameAndExtension.value, copyFd)) { clipCopy = File.createTempFile(fileName, ".jpg") bytesWritten = CompatHelper.compat.copyFile(fd, clipCopy.absolutePath) // return null if jpg conversion false. @@ -85,7 +91,11 @@ class MediaRegistration(private val context: Context) { File(tempFilePath).delete() return null } - val field = ImageField() + val field = if (isImage) { + ImageField() + } else { + MediaClipField() + } field.hasTemporaryMedia = true field.mediaPath = tempFilePath return field.formattedValue @@ -129,11 +139,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 e211d90b8299..30dc021206da 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 @@ -68,6 +69,9 @@ 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.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope @@ -129,6 +133,8 @@ import com.ichi2.anki.widgets.DeckDropDownAdapter.SubtitleListener import com.ichi2.annotations.NeedsTest import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat +import com.ichi2.compat.DropHelperCompat +import com.ichi2.compat.DropHelperOptionsBuilder import com.ichi2.libanki.Card import com.ichi2.libanki.Collection import com.ichi2.libanki.Consts @@ -146,6 +152,8 @@ import com.ichi2.libanki.note import com.ichi2.libanki.undoableOp import com.ichi2.utils.AdaptionUtil 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 @@ -171,7 +179,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 @@ -369,6 +376,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) @@ -1623,12 +1661,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!! ) } + DropHelperCompat.configureView( + requireActivity(), + editLineView, + DropHelperOptionsBuilder() + .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 @@ -1823,8 +1872,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/DropHelperCompat.kt b/AnkiDroid/src/main/java/com/ichi2/compat/DropHelperCompat.kt new file mode 100644 index 000000000000..ee86b4cdd04e --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/compat/DropHelperCompat.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.compat + +import android.app.Activity +import android.os.Build +import android.view.View +import androidx.core.view.OnReceiveContentListener +import androidx.draganddrop.DropHelper +import com.ichi2.utils.ClipboardUtil.MEDIA_MIME_TYPES + +typealias DropHelperOptionsCompat = DropHelper.Options +typealias DropHelperOptionsBuilder = DropHelper.Options.Builder + +/** + * We have applied `tools:overrideLibrary="androidx.draganddrop"` so we manually need to handle + * compat of [DropHelper] + */ +object DropHelperCompat { + + /** + * Configures a [View] for drag and drop operations, including the 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: DropHelperOptionsCompat, + onReceiveContentListener: OnReceiveContentListener + ) { + // library will fail < API 24 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + + DropHelper.configureView( + activity, + view, + MEDIA_MIME_TYPES, + options, + onReceiveContentListener + ) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt index 7e3e462b2531..718b9c57a4d2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ClipboardUtil.kt @@ -32,12 +32,13 @@ 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,9 +49,7 @@ object ClipboardUtil { ?: false } - private fun getFirstItem(clipboard: ClipboardManager?) = clipboard - ?.takeIf { it.hasPrimaryClip() } - ?.primaryClip + private fun getFirstItem(clipboard: ClipboardManager?) = clipboard?.primaryClip ?.takeIf { it.itemCount > 0 } ?.getItemAt(0) @@ -58,6 +57,35 @@ object ClipboardUtil { return getFirstItem(clipboard)?.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 + ?.takeIf { it.hasPrimaryClip() } + ?.primaryClip + ?.description + } + @CheckResult fun getText(clipboard: ClipboardManager?): CharSequence? { return getFirstItem(clipboard)?.text diff --git a/AnkiDroid/src/test/java/com/ichi2/utils/ClipboardUtilTest.kt b/AnkiDroid/src/test/java/com/ichi2/utils/ClipboardUtilTest.kt index 26c636c4583e..17c31d358240 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,40 @@ 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 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 38c3af7a1ad7..7ca6513ac80c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ androidxAppCompat = "1.7.0" androidxBrowser = "1.8.0" androidxConstraintlayout = "2.1.4" androidxCoreKtx = "1.13.1" +androidxDragAndDrop = "1.0.0" androidxExifinterface = "1.3.7" androidxFragmentKtx = "1.8.1" androidxImageCropper = "4.5.0" @@ -83,6 +84,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" }