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:
parent
9d036f61e8
commit
292d662d0b
@ -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
|
||||||
|
@ -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" />
|
||||||
|
@ -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 {
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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")
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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">
|
||||||
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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" }
|
||||||
|
@ -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>
|
Loading…
Reference in New Issue
Block a user