0
0
mirror of https://github.com/ankidroid/Anki-Android.git synced 2024-09-19 19:42:17 +02:00

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.
This commit is contained in:
SanjaySargam 2024-07-24 10:34:24 +05:30 committed by Arthur Milchior
parent 9d036f61e8
commit 292d662d0b
13 changed files with 275 additions and 83 deletions

View File

@ -337,6 +337,7 @@ dependencies {
implementation libs.androidx.appcompat implementation libs.androidx.appcompat
implementation libs.androidx.browser implementation libs.androidx.browser
implementation libs.androidx.core.ktx implementation libs.androidx.core.ktx
implementation libs.androidx.draganddrop
implementation libs.androidx.exifinterface implementation libs.androidx.exifinterface
implementation libs.androidx.fragment.ktx implementation libs.androidx.fragment.ktx
implementation libs.androidx.media implementation libs.androidx.media

View File

@ -21,6 +21,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto"> android:installLocation="auto">
<!-- At the time of writing, ankidroid minSdk is 23. This library requires API 24.-->
<!-- In order to use it, we use <uses-sdk tools:overrideLibrary instead of-->
<!-- <uses-feature android:name. We must ensure this is never called when running on API 23.-->
<uses-sdk tools:overrideLibrary="androidx.draganddrop"/>
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" /> <uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-feature android:name="android.hardware.audio.output" android:required="false" /> <uses-feature android:name="android.hardware.audio.output" android:required="false" />

View File

@ -16,6 +16,7 @@
package com.ichi2.anki package com.ichi2.anki
import android.content.ClipDescription
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
@ -25,25 +26,18 @@ import android.os.LocaleList
import android.os.Parcelable import android.os.Parcelable
import android.text.InputType import android.text.InputType
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.widget.EditText import android.widget.EditText
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting 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.google.android.material.color.MaterialColors
import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.servicelayer.NoteService import com.ichi2.anki.servicelayer.NoteService
import com.ichi2.ui.FixedEditText 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.getPlainText
import com.ichi2.utils.ClipboardUtil.getUri import com.ichi2.utils.ClipboardUtil.getUri
import com.ichi2.utils.ClipboardUtil.hasImage import com.ichi2.utils.ClipboardUtil.hasMedia
import com.ichi2.utils.KotlinCleanup import com.ichi2.utils.KotlinCleanup
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
@ -100,49 +94,6 @@ class FieldEditText : FixedEditText, NoteService.NoteField {
this.pasteListener = pasteListener 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) { override fun onSelectionChanged(selStart: Int, selEnd: Int) {
if (selectionChangeListener != null) { if (selectionChangeListener != null) {
try { try {
@ -192,9 +143,10 @@ class FieldEditText : FixedEditText, NoteService.NoteField {
override fun onTextContextMenuItem(id: Int): Boolean { override fun onTextContextMenuItem(id: Int): Boolean {
// The current function is called both by Ctrl+V and pasting from the context menu // 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 (id == android.R.id.paste) {
if (hasImage(clipboard)) { if (hasMedia(clipboard)) {
return onPaste(getUri(clipboard)) return onPaste(getUri(clipboard), getDescription(clipboard))
} }
return pastePlainText() return pastePlainText()
} }
@ -215,11 +167,11 @@ class FieldEditText : FixedEditText, NoteService.NoteField {
return false return false
} }
private fun onPaste(mediaUri: Uri?): Boolean { private fun onPaste(mediaUri: Uri?, description: ClipDescription?): Boolean {
return if (mediaUri == null) { return if (mediaUri == null) {
false false
} else { } else {
pasteListener!!.onPaste(this, mediaUri) pasteListener!!.onPaste(this, mediaUri, description)
} }
} }
@ -252,7 +204,7 @@ class FieldEditText : FixedEditText, NoteService.NoteField {
} }
fun interface PasteListener { fun interface PasteListener {
fun onPaste(editText: EditText, uri: Uri?): Boolean fun onPaste(editText: EditText, uri: Uri?, description: ClipDescription?): Boolean
} }
companion object { companion object {

View File

@ -15,14 +15,17 @@
*/ */
package com.ichi2.anki package com.ichi2.anki
import android.content.ClipDescription
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.annotation.CheckResult import androidx.annotation.CheckResult
import com.ichi2.anki.multimediacard.fields.ImageField import com.ichi2.anki.multimediacard.fields.ImageField
import com.ichi2.anki.multimediacard.fields.MediaClipField
import com.ichi2.compat.CompatHelper import com.ichi2.compat.CompatHelper
import com.ichi2.libanki.exception.EmptyMediaException import com.ichi2.libanki.exception.EmptyMediaException
import com.ichi2.utils.ClipboardUtil
import com.ichi2.utils.ContentResolverUtil.getFileName import com.ichi2.utils.ContentResolverUtil.getFileName
import com.ichi2.utils.FileUtil.getFileNameAndExtension import com.ichi2.utils.FileUtil.getFileNameAndExtension
import timber.log.Timber import timber.log.Timber
@ -47,7 +50,7 @@ class MediaRegistration(private val context: Context) {
* @return HTML referring to the loaded image * @return HTML referring to the loaded image
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun loadMediaIntoCollection(uri: Uri): String? { fun loadMediaIntoCollection(uri: Uri, description: ClipDescription): String? {
val fileName: String val fileName: String
val filename = getFileName(context.contentResolver, uri) val filename = getFileName(context.contentResolver, uri)
val fd = openInputStreamWithURI(uri) val fd = openInputStreamWithURI(uri)
@ -59,9 +62,12 @@ class MediaRegistration(private val context: Context) {
} }
var clipCopy: File var clipCopy: File
var bytesWritten: Long var bytesWritten: Long
val isImage = ClipboardUtil.hasImage(description)
val isVideo = ClipboardUtil.hasVideo(description)
openInputStreamWithURI(uri).use { copyFd -> openInputStreamWithURI(uri).use { copyFd ->
// no conversion to jpg in cases of gif and jpg and if png image with alpha channel // 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") clipCopy = File.createTempFile(fileName, ".jpg")
bytesWritten = CompatHelper.compat.copyFile(fd, clipCopy.absolutePath) bytesWritten = CompatHelper.compat.copyFile(fd, clipCopy.absolutePath)
// return null if jpg conversion false. // return null if jpg conversion false.
@ -81,11 +87,22 @@ class MediaRegistration(private val context: Context) {
Timber.d("File was %d bytes", bytesWritten) Timber.d("File was %d bytes", bytesWritten)
if (bytesWritten > MEDIA_MAX_SIZE) { if (bytesWritten > MEDIA_MAX_SIZE) {
Timber.w("File was too large: %d bytes", bytesWritten) 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() File(tempFilePath).delete()
return null return null
} }
val field = ImageField() val field = if (isImage) {
ImageField()
} else {
MediaClipField()
}
field.hasTemporaryMedia = true field.hasTemporaryMedia = true
field.mediaPath = tempFilePath field.mediaPath = tempFilePath
return field.formattedValue return field.formattedValue
@ -112,7 +129,13 @@ class MediaRegistration(private val context: Context) {
return true // successful conversion to jpg. 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) { if (".jpg" == fileNameExtension) {
return false // we are already a jpg, no conversion return false // we are already a jpg, no conversion
} }
@ -129,11 +152,11 @@ class MediaRegistration(private val context: Context) {
return fileNameAndExtension.key.length <= 3 return fileNameAndExtension.key.length <= 3
} }
fun onPaste(uri: Uri): String? { fun onPaste(uri: Uri, description: ClipDescription): String? {
return try { return try {
// check if cache already holds registered file or not // check if cache already holds registered file or not
if (!pastedMediaCache.containsKey(uri.toString())) { if (!pastedMediaCache.containsKey(uri.toString())) {
pastedMediaCache[uri.toString()] = loadMediaIntoCollection(uri) pastedMediaCache[uri.toString()] = loadMediaIntoCollection(uri, description)
} }
pastedMediaCache[uri.toString()] pastedMediaCache[uri.toString()]
} catch (ex: NullPointerException) { } catch (ex: NullPointerException) {

View File

@ -23,6 +23,7 @@ import android.app.Activity
import android.app.Activity.RESULT_CANCELED import android.app.Activity.RESULT_CANCELED
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ClipData import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -67,7 +68,11 @@ import androidx.core.content.edit
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.core.os.BundleCompat import androidx.core.os.BundleCompat
import androidx.core.text.HtmlCompat 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.core.view.isVisible
import androidx.draganddrop.DropHelper
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import anki.config.ConfigKey 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.utils.ext.sharedPrefs
import com.ichi2.anki.widgets.DeckDropDownAdapter.SubtitleListener import com.ichi2.anki.widgets.DeckDropDownAdapter.SubtitleListener
import com.ichi2.annotations.NeedsTest import com.ichi2.annotations.NeedsTest
import com.ichi2.compat.CompatHelper
import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat
import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat
import com.ichi2.libanki.Card import com.ichi2.libanki.Card
@ -142,6 +148,8 @@ import com.ichi2.libanki.Notetypes.Companion.NOT_FOUND_NOTE_TYPE
import com.ichi2.libanki.Utils import com.ichi2.libanki.Utils
import com.ichi2.libanki.undoableOp import com.ichi2.libanki.undoableOp
import com.ichi2.utils.ClipboardUtil 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.HashUtil
import com.ichi2.utils.ImageUtils import com.ichi2.utils.ImageUtils
import com.ichi2.utils.ImportUtils import com.ichi2.utils.ImportUtils
@ -330,6 +338,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<ActivityResult> { private inner class NoteEditorActivityResultCallback(private val callback: (result: ActivityResult) -> Unit) : ActivityResultCallback<ActivityResult> {
override fun onActivityResult(result: ActivityResult) { override fun onActivityResult(result: ActivityResult) {
Timber.d("onActivityResult() with result: %s", result.resultCode) Timber.d("onActivityResult() with result: %s", result.resultCode)
@ -1594,12 +1633,23 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su
val editLineView = editLines[i] val editLineView = editLines[i]
customViewIds.add(editLineView.id) customViewIds.add(editLineView.id)
val newEditText = editLineView.editText val newEditText = editLineView.editText
newEditText.setPasteListener { editText: EditText?, uri: Uri? -> newEditText.setPasteListener { editText: EditText?, uri: Uri?, description: ClipDescription? ->
onPaste( onPaste(
editText!!, 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 (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
if (i == 0) { if (i == 0) {
findViewById<View>(R.id.note_deck_spinner).nextFocusForwardId = newEditText.id findViewById<View>(R.id.note_deck_spinner).nextFocusForwardId = newEditText.id
@ -1837,8 +1887,8 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su
} }
} }
private fun onPaste(editText: EditText, uri: Uri): Boolean { private fun onPaste(editText: EditText, uri: Uri, description: ClipDescription): Boolean {
val mediaTag = mediaRegistration!!.onPaste(uri) ?: return false val mediaTag = mediaRegistration!!.onPaste(uri, description) ?: return false
insertStringInField(editText, mediaTag) insertStringInField(editText, mediaTag)
return true return true
} }

View File

@ -17,6 +17,7 @@
package com.ichi2.compat package com.ichi2.compat
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
@ -30,6 +31,8 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.CheckResult import androidx.annotation.CheckResult
import androidx.core.view.OnReceiveContentListener
import androidx.draganddrop.DropHelper
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
@ -201,6 +204,19 @@ interface Compat {
@Throws(IOException::class) @Throws(IOException::class)
fun contentOfDirectory(directory: File): FileStream 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) * Converts a locale to a 'two letter' code (ISO-639-1 + ISO 3166-1 alpha-2)
* Locale("spa", "MEX", "001") => Locale("es", "MX", "001") * Locale("spa", "MEX", "001") => Locale("es", "MX", "001")

View File

@ -16,6 +16,7 @@
package com.ichi2.compat package com.ichi2.compat
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
@ -31,6 +32,8 @@ import android.os.Vibrator
import android.provider.MediaStore import android.provider.MediaStore
import android.view.View import android.view.View
import androidx.appcompat.widget.TooltipCompat import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.OnReceiveContentListener
import androidx.draganddrop.DropHelper
import com.ichi2.utils.KotlinCleanup import com.ichi2.utils.KotlinCleanup
import timber.log.Timber import timber.log.Timber
import java.io.File 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 // Until API 26
@Throws(IOException::class) @Throws(IOException::class)
override fun deleteFile(file: File) { override fun deleteFile(file: File) {

View File

@ -17,9 +17,14 @@
package com.ichi2.compat package com.ichi2.compat
import android.annotation.TargetApi import android.annotation.TargetApi
import android.app.Activity
import android.icu.util.ULocale import android.icu.util.ULocale
import android.view.MotionEvent 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.anki.common.utils.android.isRobolectric
import com.ichi2.utils.ClipboardUtil.MEDIA_MIME_TYPES
import timber.log.Timber import timber.log.Timber
import java.util.Locale 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_X: Int = MotionEvent.AXIS_RELATIVE_X
override val AXIS_RELATIVE_Y: Int = MotionEvent.AXIS_RELATIVE_Y override val AXIS_RELATIVE_Y: Int = MotionEvent.AXIS_RELATIVE_Y
} }

View File

@ -31,13 +31,13 @@ import com.ichi2.anki.snackbar.showSnackbar
import timber.log.Timber import timber.log.Timber
object ClipboardUtil { object ClipboardUtil {
// JPEG is sent via pasted content val IMAGE_MIME_TYPES = arrayOf("image/*")
val IMAGE_MIME_TYPES = arrayOf("image/gif", "image/png", "image/jpg", "image/jpeg") 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 { fun hasImage(clipboard: ClipboardManager?): Boolean {
return clipboard return clipboard?.primaryClip
?.takeIf { it.hasPrimaryClip() }
?.primaryClip
?.let { hasImage(it.description) } ?.let { hasImage(it.description) }
?: false ?: false
} }
@ -48,24 +48,53 @@ object ClipboardUtil {
?: false ?: false
} }
private fun getFirstItem(clipboard: ClipboardManager?) = clipboard fun hasVideo(description: ClipDescription?): Boolean {
?.takeIf { it.hasPrimaryClip() } return description
?.primaryClip ?.run { VIDEO_MIME_TYPES.any { hasMimeType(it) } }
?.takeIf { it.itemCount > 0 } ?: false
?.getItemAt(0) }
private fun ClipboardManager.getFirstItem() =
primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)
fun getUri(clipboard: ClipboardManager?): Uri? { 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 @CheckResult
fun getText(clipboard: ClipboardManager?): CharSequence? { fun getText(clipboard: ClipboardManager?): CharSequence? {
return getFirstItem(clipboard)?.text return clipboard?.getFirstItem()?.text
} }
@CheckResult @CheckResult
fun getPlainText(clipboard: ClipboardManager?, context: Context): CharSequence? { fun getPlainText(clipboard: ClipboardManager?, context: Context): CharSequence? {
return getFirstItem(clipboard)?.coerceToText(context) return clipboard?.getFirstItem()?.coerceToText(context)
} }
} }

View File

@ -240,7 +240,9 @@
<string name="toolbar_item_explain_edit_or_remove">Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it</string> <string name="toolbar_item_explain_edit_or_remove">Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it</string>
<string name="remove_toolbar_item">Remove Toolbar Item?</string> <string name="remove_toolbar_item">Remove Toolbar Item?</string>
<string name="note_editor_paste_too_large">The image is too large to paste, please insert the image manually</string> <string name="note_editor_image_too_large">The image is too large, please insert the image manually</string>
<string name="note_editor_video_too_large">The video file is too large, please insert the video manually</string>
<string name="note_editor_audio_too_large">The audio file is too large, please insert the audio manually</string>
<string name="ankidroid_cannot_open_after_backup_try_again" <string name="ankidroid_cannot_open_after_backup_try_again"
comment="After an Android backup is restored, AnkiDroid opens and shows this message. comment="After an Android backup is restored, AnkiDroid opens and shows this message.
Opening AnkiDroid again will work correctly"> Opening AnkiDroid again will work correctly">

View File

@ -2,13 +2,33 @@
package com.ichi2.utils package com.ichi2.utils
import android.content.ClipData
import android.content.ClipDescription import android.content.ClipDescription
import android.content.ClipboardManager 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.hasImage
import com.ichi2.utils.ClipboardUtil.hasMedia
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ClipboardUtilTest { class ClipboardUtilTest {
private lateinit var clipboardManager: ClipboardManager
@Before
fun setUp() {
clipboardManager = ApplicationProvider.getApplicationContext<Context>().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
}
@Test @Test
fun hasImageClipboardManagerNullTest() { fun hasImageClipboardManagerNullTest() {
val clipboardManager: ClipboardManager? = null val clipboardManager: ClipboardManager? = null
@ -20,4 +40,48 @@ class ClipboardUtilTest {
val clipDescription: ClipDescription? = null val clipDescription: ClipDescription? = null
assertFalse(hasImage(clipDescription)) 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))
}
} }

View File

@ -16,6 +16,8 @@ androidxBrowser = "1.8.0"
androidxConstraintlayout = "2.1.4" androidxConstraintlayout = "2.1.4"
# https://developer.android.com/jetpack/androidx/releases/core # https://developer.android.com/jetpack/androidx/releases/core
androidxCoreKtx = "1.13.1" androidxCoreKtx = "1.13.1"
# https://developer.android.com/jetpack/androidx/releases/draganddrop
androidxDragAndDrop = "1.0.0"
# https://developer.android.com/jetpack/androidx/releases/exifinterface # https://developer.android.com/jetpack/androidx/releases/exifinterface
androidxExifinterface = "1.3.7" androidxExifinterface = "1.3.7"
# https://developer.android.com/jetpack/androidx/releases/fragment # 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-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidxFragmentKtx" }
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidxExifinterface" } androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidxExifinterface" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" } 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-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppCompat" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppCompat" }
androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidxAnnotation" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidxAnnotation" }

View File

@ -1,2 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest /> <manifest xmlns:tools="http://schemas.android.com/tools">
<!--
Testlib's manifest should implicitly imports everything from `AnkiDroid/src/main/AndroidManifest.xml`,
except the imports done with `overrideLibrary` it seems. So we repeat the overridden imports below.
Otherwise, here is the error message we receive:
/Users/davidallison/StudioProjects/Anki-Android/testlib/build/intermediates/tmp/manifest/test/amazon/debug/tempFile1ProcessTestManifest12107165475509574808.xml:5:5-74 Error:
uses-sdk:minSdkVersion 23 cannot be smaller than version 24 declared in library [androidx.draganddrop:draganddrop:1.0.0] /Users/davidallison/.gradle/caches/8.9/transforms/20ebe1ae2c541e0d36502b025cdb232b/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)
-->
<uses-sdk tools:overrideLibrary="androidx.draganddrop"/>
</manifest>