diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index abc005a5ad29..9d9009bab331 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -447,4 +447,8 @@ dependencies { androidTestImplementation libs.kotlin.test androidTestImplementation libs.kotlin.test.junit androidTestImplementation libs.androidx.fragment.testing + + implementation libs.androidx.media3.exoplayer + implementation libs.androidx.media3.exoplayer.dash + implementation libs.androidx.media3.ui } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt index 88133e7b4a5b..7c3ea962073f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt @@ -89,6 +89,7 @@ import com.ichi2.anki.dialogs.tags.TagsDialog import com.ichi2.anki.dialogs.tags.TagsDialogFactory import com.ichi2.anki.dialogs.tags.TagsDialogListener import com.ichi2.anki.model.CardStateFilter +import com.ichi2.anki.multimedia.AudioVideoFragment import com.ichi2.anki.multimedia.MultimediaActivity.Companion.MULTIMEDIA_RESULT import com.ichi2.anki.multimedia.MultimediaActivity.Companion.MULTIMEDIA_RESULT_FIELD_INDEX import com.ichi2.anki.multimedia.MultimediaActivityExtra @@ -275,11 +276,13 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su NoteEditorActivityResultCallback { result -> if (result.resultCode == RESULT_CANCELED) { Timber.d("Multimedia result canceled") + val index = result.data?.extras?.getInt(MULTIMEDIA_RESULT_FIELD_INDEX) ?: return@NoteEditorActivityResultCallback + handleMultimediaActions(index) return@NoteEditorActivityResultCallback } Timber.d("Getting multimedia result") - val extras = result.data!!.extras ?: return@NoteEditorActivityResultCallback + val extras = result.data?.extras ?: return@NoteEditorActivityResultCallback handleMultimediaResult(extras) } ) @@ -1749,7 +1752,16 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su } MultimediaBottomSheet.MultimediaAction.SELECT_AUDIO_FILE -> { - // TODO("Not yet implemented") + Timber.i("Selected audio clip option") + val field = MediaClipField() + note.setField(fieldIndex, field) + val mediaIntent = AudioVideoFragment.getIntent( + requireContext(), + MultimediaActivityExtra(fieldIndex, field, note), + AudioVideoFragment.MediaOption.AUDIO_CLIP + ) + + multimediaFragmentLauncher.launch(mediaIntent) } MultimediaBottomSheet.MultimediaAction.OPEN_DRAWING -> { @@ -1761,7 +1773,16 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su } MultimediaBottomSheet.MultimediaAction.SELECT_VIDEO_FILE -> { - // TODO("Not yet implemented") + Timber.i("Selected video clip option") + val field = MediaClipField() + note.setField(fieldIndex, field) + val mediaIntent = AudioVideoFragment.getIntent( + requireContext(), + MultimediaActivityExtra(fieldIndex, field, note), + AudioVideoFragment.MediaOption.VIDEO_CLIP + ) + + multimediaFragmentLauncher.launch(mediaIntent) } MultimediaBottomSheet.MultimediaAction.OPEN_CAMERA -> { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt new file mode 100644 index 000000000000..13c110052914 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt @@ -0,0 +1,408 @@ +/* + * Copyright (c) 2024 Ashish Yadav + * + * 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.anki.multimedia + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import android.view.View +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.OptIn +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.fragment.app.viewModels +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import com.google.android.material.button.MaterialButton +import com.ichi2.anki.CrashReportService +import com.ichi2.anki.R +import com.ichi2.anki.multimedia.AudioVideoFragment.MediaOption.AUDIO_CLIP +import com.ichi2.anki.multimedia.MultimediaActivity.Companion.EXTRA_MEDIA_OPTIONS +import com.ichi2.anki.multimedia.MultimediaActivity.Companion.MULTIMEDIA_RESULT +import com.ichi2.anki.multimedia.MultimediaActivity.Companion.MULTIMEDIA_RESULT_FIELD_INDEX +import com.ichi2.anki.multimedia.MultimediaUtils.createCachedFile +import com.ichi2.anki.utils.ext.sharedPrefs +import com.ichi2.compat.CompatHelper +import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat +import com.ichi2.utils.ExceptionUtil.executeSafe +import com.ichi2.utils.FileUtil +import timber.log.Timber +import java.io.File + +/** Handles the Multimedia Audio and Video attachment in the NoteEditor */ +class AudioVideoFragment : MultimediaFragment(R.layout.fragment_audio_video) { + private lateinit var selectedMediaOptions: MediaOption + + override val title: String + get() = getTitleForFragment(selectedMediaOptions, requireContext()) + + private val viewModel: MultimediaViewModel by viewModels() + + /** + * Launches an activity to pick audio or video file from the device + */ + private val pickMediaLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + when { + result.resultCode != Activity.RESULT_OK || result.data == null -> { + Timber.d("Uri is empty or Result not OK") + if (viewModel.currentMultimediaUri.value == null) { + val resultData = Intent().apply { + putExtra(MULTIMEDIA_RESULT_FIELD_INDEX, indexValue) + } + requireActivity().setResult(AppCompatActivity.RESULT_CANCELED, resultData) + requireActivity().finish() + } + } + else -> { + executeSafe(requireContext(), "pickMediaLauncher:unhandled") { + handleMediaSelection(result.data!!) + } + } + } + } + + /** + * Lazily initialized instance of MultimediaMenu. + * The instance is created only when first accessed. + */ + private val multimediaMenu by lazy { + MultimediaMenuProvider( + menuResId = R.menu.multimedia_menu, + onCreateMenuCondition = { menu -> + setMenuItemIcon( + menu.findItem(R.id.action_restart), + if (selectedMediaOptions == AUDIO_CLIP) R.drawable.ic_replace_audio else R.drawable.ic_replace_video + ) + menu.findItem(R.id.action_crop).isVisible = false + } + ) { menuItem -> + when (menuItem.itemId) { + R.id.action_restart -> { + handleSelectedMediaOptions() + true + } + + else -> false + } + } + } + + private lateinit var mediaPlayer: ExoPlayer + private lateinit var playerView: PlayerView + private lateinit var mediaFileSize: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ankiCacheDirectory = FileUtil.getAnkiCacheDirectory(requireContext(), "temp-media") + if (ankiCacheDirectory == null) { + showErrorDialog() + Timber.e("createUI() failed to get cache directory") + return + } + + arguments?.let { + selectedMediaOptions = it.getSerializableCompat(EXTRA_MEDIA_OPTIONS) as MediaOption + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupMenu(multimediaMenu) + + setupMediaPlayer() + + handleSelectedMediaOptions() + + setupDoneButton() + } + + private fun handleSelectedMediaOptions() { + when (selectedMediaOptions) { + MediaOption.AUDIO_CLIP -> { + Timber.d("Opening chooser for audio file") + openMediaChooser( + "audio/*", + arrayOf("audio/*", "application/ogg"), // #9226: allows ogg on Android 8 + R.string.multimedia_editor_popup_audio_clip + ) + } + + MediaOption.VIDEO_CLIP -> { + Timber.d("Opening chooser for video file") + openMediaChooser( + "video/*", + emptyArray(), + R.string.multimedia_editor_popup_video_clip + ) + } + } + } + + // defaultArtwork(Unstable API) is required for audio files cover otherwise it shows an empty black screen + @OptIn(UnstableApi::class) + private fun setupMediaPlayer() { + Timber.d("Setting up media player") + playerView = requireView().findViewById(R.id.player_view) + mediaPlayer = ExoPlayer.Builder(requireContext()).build() + playerView.player = mediaPlayer + mediaFileSize = requireView().findViewById(R.id.media_size_textview) + playerView.setControllerAnimationEnabled(true) + + if (selectedMediaOptions == MediaOption.AUDIO_CLIP) { + Timber.d("Media file is of audio type, setting default artwork") + playerView.defaultArtwork = + ContextCompat.getDrawable(requireContext(), R.drawable.round_audio_file_24) + } + } + + private fun setupDoneButton() { + view?.findViewById(R.id.action_done)?.setOnClickListener { + Timber.d("MultimediaImageFragment:: Done button pressed") + if (viewModel.selectedMediaFileSize == 0L) { + Timber.d("Audio or Video length is not valid") + return@setOnClickListener + } + field.mediaPath = viewModel.currentMultimediaPath.value + + field.hasTemporaryMedia = true + + val resultData = Intent().apply { + putExtra(MULTIMEDIA_RESULT, field) + putExtra(MULTIMEDIA_RESULT_FIELD_INDEX, indexValue) + } + requireActivity().setResult(AppCompatActivity.RESULT_OK, resultData) + requireActivity().finish() + } + } + + /** + * Opens a media chooser to allow the user to select a media file. + * + * This method first checks the shared preference identified by the key "mediaImportAllowAllFiles" to determine if the user allows importing all file types. + * Based on this setting, it configures an `Intent` object to specify the desired media type(s). + * If "allowAllFiles" is true, the intent will accept any file type + * + * @param initialMimeType The initial mime type to be used for the media selection. + * @param extraMimeTypes An optional array of additional mime types to accept besides the initial one. + * @param prompt The resource ID of a string to be used as the prompt for the media chooser dialog. This parameter should be annotated with `@StringRes`. + */ + private fun openMediaChooser( + initialMimeType: String, + extraMimeTypes: Array, + @StringRes prompt: Int + ) { + val allowAllFiles = + sharedPrefs().getBoolean("mediaImportAllowAllFiles", false) + val intent = Intent() + intent.type = if (allowAllFiles) "*/*" else initialMimeType + if (!allowAllFiles && extraMimeTypes.any()) { + // application/ogg takes precedence over "*/*" for application/octet-stream + // so don't add it if we're want */* + intent.putExtra(Intent.EXTRA_MIME_TYPES, extraMimeTypes) + } + intent.action = Intent.ACTION_GET_CONTENT + // Only get openable files, to avoid virtual files issues with Android 7+ + intent.addCategory(Intent.CATEGORY_OPENABLE) + val chooserPrompt = resources.getString(prompt) + pickMediaLauncher.launch(Intent.createChooser(intent, chooserPrompt)) + } + + private fun handleMediaSelection(data: Intent) { + Timber.d("Handling media selection") + val selectedMediaClip = data.data + + if (selectedMediaClip == null) { + Timber.w("Media file is null") + showErrorDialog() + return + } + + viewModel.updateCurrentMultimediaUri(selectedMediaClip) + + prepareMediaPlayer(selectedMediaClip) + val mediaClipFullNameParts = getMediaFileDetails(selectedMediaClip) ?: return + val clipCopy = createTempMediaFile(mediaClipFullNameParts) ?: return + copyMediaFileToTemp(selectedMediaClip, clipCopy) + } + + private fun prepareMediaPlayer(uri: Uri) { + Timber.d("Preparing media player") + val mediaItem = MediaItem.fromUri(uri) + mediaPlayer.setMediaItem(mediaItem) + mediaPlayer.prepare() + } + + /** + * Retrieves details about a selected media clip from the MediaStore. + * + * This method takes a `Uri` representing the selected media clip as input. + * + * If the query is successful, it processes the retrieved data as follows: + * * It parses the display name to extract the filename and extension. + * * If the display name contains a single dot (.), it assumes the second half is the extension. + * * If the display name contains multiple dots (.), it extracts the part before the last dot as the filename and the part after the last dot as the extension. + * * If there is no dot (.) in the name, it attempts to use the second part of the MIME type as the extension. + * * In case of any errors during parsing, it sends an exception report. It also displays an error message using `showSomethingWentWrong()`. + * + * The method returns an array of strings containing the filename and extension (if available), or null if any errors occur during processing. + * + * @param selectedMediaClip The Uri representing the selected media clip. + * @return An array of strings containing the filename and extension of the media clip, or null on error. + */ + private fun getMediaFileDetails(selectedMediaClip: Uri): Array? { + val queryColumns = arrayOf( + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.SIZE, + MediaStore.MediaColumns.MIME_TYPE + ) + var mediaClipFullNameParts: Array + requireContext().contentResolver.query(selectedMediaClip, queryColumns, null, null, null) + .use { cursor -> + if (cursor == null) { + showSomethingWentWrong() + return null + } + cursor.moveToFirst() + val mediaClipFullName = cursor.getString(0) + mediaClipFullNameParts = mediaClipFullName.split(".").toTypedArray() + if (mediaClipFullNameParts.size < 2) { + mediaClipFullNameParts = try { + Timber.i("Media clip name does not have extension, using second half of mime type") + arrayOf(mediaClipFullName, cursor.getString(2).split("/").toTypedArray()[1]) + } catch (e: Exception) { + Timber.w(e) + CrashReportService.sendExceptionReport( + e, + "Media Clip addition failed. Name $mediaClipFullName / cursor mime type column type " + cursor.getType( + 2 + ) + ) + showSomethingWentWrong() + return null + } + } else if (mediaClipFullNameParts.size > 2) { + val lastPointIndex = mediaClipFullName.lastIndexOf(".") + mediaClipFullNameParts = arrayOf( + mediaClipFullName.substring(0 until lastPointIndex), + mediaClipFullName.substring(lastPointIndex + 1) + ) + } + } + return mediaClipFullNameParts + } + + /** + * Creates a temporary media file based on the provided filename and extension. + * + * @param mediaClipFullNameParts An array of strings containing the filename and extension of the media clip. + * @return A File object representing the created temporary media file, or null on error. + */ + private fun createTempMediaFile(mediaClipFullNameParts: Array): File? { + return try { + val clipCopy = createCachedFile( + "${mediaClipFullNameParts[0]}.${mediaClipFullNameParts[1]}", + ankiCacheDirectory + ) + Timber.d("media clip picker file path is: %s", clipCopy.absolutePath) + clipCopy + } catch (e: Exception) { + Timber.e(e, "Could not create temporary media file. ") + CrashReportService.sendExceptionReport(e, "handleMediaSelection:tempFile") + showSomethingWentWrong() + null + } + } + + /** + * Copies a selected media clip to a temporary file. + * + * @param selectedMediaClip The Uri representing the selected media clip. + * @param clipCopy The File object representing the temporary file where the media clip will be copied. + */ + private fun copyMediaFileToTemp(selectedMediaClip: Uri, clipCopy: File) { + try { + requireContext().contentResolver.openInputStream(selectedMediaClip).use { inputStream -> + CompatHelper.compat.copyFile(inputStream!!, clipCopy.absolutePath) + + viewModel.updateCurrentMultimediaPath(clipCopy.path) + viewModel.selectedMediaFileSize = clipCopy.length() + mediaFileSize.text = clipCopy.toHumanReadableSize() + } + } catch (e: Exception) { + Timber.e(e, "Unable to copy media file from ContentProvider") + CrashReportService.sendExceptionReport(e, "handleMediaSelection:copyFromProvider") + showSomethingWentWrong() + } + } + + override fun onDestroyView() { + super.onDestroyView() + Timber.d("Releasing media player") + mediaPlayer.release() + } + + override fun onStop() { + super.onStop() + Timber.d("Stopping media player") + mediaPlayer.playWhenReady = false + } + + companion object { + + fun getIntent( + context: Context, + multimediaExtra: MultimediaActivityExtra, + mediaOptions: MediaOption + ): Intent { + return MultimediaActivity.getIntent( + context, + AudioVideoFragment::class, + multimediaExtra, + mediaOptions + ) + } + } + + /** The supported media types that a user choose from the bottom sheet which [AudioVideoFragment] uses */ + enum class MediaOption { + AUDIO_CLIP, + VIDEO_CLIP + } + + /** + * Returns the appropriate title string based on the current media option. + * + * @param mediaOption MediaOption The media option for which the title string is to be returned. + * @param context Context The context to use for accessing string resources. + * @return String The title string corresponding to the current media option. + */ + private fun getTitleForFragment(mediaOption: MediaOption, context: Context): String { + return when (mediaOption) { + MediaOption.AUDIO_CLIP -> context.getString(R.string.multimedia_editor_popup_audio_clip) + MediaOption.VIDEO_CLIP -> context.getString(R.string.multimedia_editor_popup_video_clip) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt index 8cf6e7a10ca3..47c14c460bf2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt @@ -29,6 +29,7 @@ import com.ichi2.anki.R import com.ichi2.anki.multimediacard.IMultimediaEditableNote import com.ichi2.anki.multimediacard.fields.IField import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat +import com.ichi2.compat.CompatHelper.Companion.getSerializableExtraCompat import com.ichi2.themes.setTransparentStatusBar import com.ichi2.utils.getInstanceFromClassName import timber.log.Timber @@ -37,16 +38,14 @@ import kotlin.reflect.KClass import kotlin.reflect.jvm.jvmName /** - * A typealias representing a data structure used to hold extra information for multimedia activities. + * Required information for multimedia activities. * - * This typealias combines three elements into a single unit: Index, IField and IMultimediaEditableNote. + * This combines three elements into a single unit: Index, IField and IMultimediaEditableNote. * - `Int`: The index of the field within the multimedia note that the multimedia content is associated with. * @see IField * @see IMultimediaEditableNote */ // TODO: move it to a better data model (remove IField & IMultimediaEditableNote) -// typealias MultimediaActivityExtra = Triple - data class MultimediaActivityExtra( val index: Int, val field: IField, @@ -58,6 +57,12 @@ data class MultimediaActivityExtra( */ class MultimediaActivity : AnkiActivity() { + private val Intent.multimediaArgsExtra: MultimediaActivityExtra? + get() = extras?.getSerializableCompat(MULTIMEDIA_ARGS_EXTRA) + + private val Intent.mediaOptionsExtra: Serializable? + get() = getSerializableExtraCompat(EXTRA_MEDIA_OPTIONS) + override fun onCreate(savedInstanceState: Bundle?) { if (showedActivityFailedScreen(savedInstanceState)) { return @@ -78,15 +83,10 @@ class MultimediaActivity : AnkiActivity() { "'$MULTIMEDIA_FRAGMENT_NAME_EXTRA' extra should be provided" } -// val fragment = getInstanceFromClassName(fragmentClassName).apply { -// arguments = bundleOf( -// MULTIMEDIA_ARGS_EXTRA to intent.extras?.getSerializableCompat(MULTIMEDIA_ARGS_EXTRA) -// ) -// } - val fragment = getInstanceFromClassName(fragmentClassName).apply { arguments = bundleOf( - MULTIMEDIA_ARGS_EXTRA to intent.extras?.getSerializableCompat(MULTIMEDIA_ARGS_EXTRA) + MULTIMEDIA_ARGS_EXTRA to intent.multimediaArgsExtra, + EXTRA_MEDIA_OPTIONS to intent.mediaOptionsExtra ) } @@ -107,14 +107,19 @@ class MultimediaActivity : AnkiActivity() { const val MULTIMEDIA_RESULT = "multimedia_result" const val MULTIMEDIA_RESULT_FIELD_INDEX = "multimedia_result_index" + /** used in case a fragment supports more than media operations **/ + const val EXTRA_MEDIA_OPTIONS = "extra_media_options" + fun getIntent( context: Context, fragmentClass: KClass, - arguments: MultimediaActivityExtra? = null + arguments: MultimediaActivityExtra? = null, + mediaOptions: Serializable ): Intent { return Intent(context, MultimediaActivity::class.java).apply { putExtra(MULTIMEDIA_ARGS_EXTRA, arguments) putExtra(MULTIMEDIA_FRAGMENT_NAME_EXTRA, fragmentClass.jvmName) + putExtra(EXTRA_MEDIA_OPTIONS, mediaOptions) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt index 50456adc8a2a..4071e36cd36a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt @@ -18,9 +18,15 @@ package com.ichi2.anki.multimedia import android.os.Bundle +import android.text.format.Formatter +import android.view.MenuItem import android.view.View +import androidx.annotation.DrawableRes import androidx.annotation.LayoutRes import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import com.ichi2.anki.AnkiActivity import com.ichi2.anki.R @@ -30,6 +36,7 @@ import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.utils.show import timber.log.Timber +import java.io.File /** * Abstract base class for fragments that handle multimedia operations. @@ -39,6 +46,7 @@ import timber.log.Timber * * @param layout The layout resource ID to be inflated by this fragment. */ +// TODO: show discard dialog in case there are changes abstract class MultimediaFragment(@LayoutRes layout: Int) : Fragment(layout) { abstract val title: String @@ -66,6 +74,22 @@ abstract class MultimediaFragment(@LayoutRes layout: Int) : Fragment(layout) { } } + fun setMenuItemIcon(menuItem: MenuItem, @DrawableRes icon: Int) { + menuItem.icon = ContextCompat.getDrawable(requireContext(), icon) + } + + /** + * Attaches a `MenuProvider` to the activity for creating its menu. + * + * @param menuProvider An instance of the `MenuProvider` class that will be responsible for inflating and configuring the menu. + */ + // TODO: move this to requireAnkiActivity().addMenuProvider() + fun setupMenu(menuProvider: MenuProvider) { + (requireActivity() as MenuHost).addMenuProvider(menuProvider) + } + + fun File.toHumanReadableSize(): String = Formatter.formatFileSize(requireContext(), this.length()) + /** * Shows a Snackbar at the bottom of the screen with a predefined * "Something went wrong" message from the application's resources. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt index d2d06897f1b2..033b39cfb8b9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt @@ -23,10 +23,6 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.MediaStore -import android.text.format.Formatter -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem import android.view.View import android.widget.ImageView import android.widget.TextView @@ -34,13 +30,13 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.FileProvider -import androidx.core.view.MenuHost -import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import com.canhub.cropper.CropException import com.google.android.material.button.MaterialButton import com.ichi2.anki.CrashReportService import com.ichi2.anki.R +import com.ichi2.anki.multimedia.MultimediaActivity.Companion.EXTRA_MEDIA_OPTIONS import com.ichi2.anki.multimedia.MultimediaActivity.Companion.MULTIMEDIA_RESULT import com.ichi2.anki.multimedia.MultimediaActivity.Companion.MULTIMEDIA_RESULT_FIELD_INDEX import com.ichi2.anki.multimedia.MultimediaUtils.createCachedFile @@ -48,12 +44,15 @@ import com.ichi2.anki.multimedia.MultimediaUtils.createImageFile import com.ichi2.anki.multimediacard.activity.MultimediaEditFieldActivity import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.annotations.NeedsTest +import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.utils.FileUtil import com.ichi2.utils.ImageUtils import com.ichi2.utils.message import com.ichi2.utils.negativeButton import com.ichi2.utils.positiveButton import com.ichi2.utils.show +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import timber.log.Timber import java.io.File import java.io.IOException @@ -65,33 +64,110 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ get() = resources.getString(R.string.multimedia_editor_popup_image) private lateinit var imagePreview: ImageView + private lateinit var imageFileSize: TextView private val viewModel: MultimediaViewModel by viewModels() + private lateinit var selectedImageOptions: ImageOptions + /** * Launches an activity to pick an image from the device's gallery. * This launcher is registered using `ActivityResultContracts.StartActivityForResult()`. */ - private val pickImageLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - view?.findViewById(R.id.no_image_textview)?.visibility = - View.GONE - handleSelectImageIntent(result.data) + private val pickImageLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + when (result.resultCode) { + Activity.RESULT_CANCELED -> { + if (viewModel.currentMultimediaUri.value == null) { + val resultData = Intent().apply { + putExtra(MULTIMEDIA_RESULT_FIELD_INDEX, indexValue) + } + requireActivity().setResult(AppCompatActivity.RESULT_CANCELED, resultData) + requireActivity().finish() + } + } + + Activity.RESULT_OK -> { + view?.findViewById(R.id.no_image_textview)?.visibility = View.GONE + handleSelectImageIntent(result.data) + } + } } - } /** * Launches the device's camera to take a picture. * This launcher is registered using `ActivityResultContracts.TakePicture()`. */ - private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { isPictureTaken -> - if (isPictureTaken) { - Timber.d("Image successfully captured") - view?.findViewById(R.id.no_image_textview)?.visibility = - View.GONE - handleTakePictureResult(viewModel.currentImagePath) - } else { - Timber.d("Camera aborted or some interruption") + private val cameraLauncher = + registerForActivityResult(ActivityResultContracts.TakePicture()) { isPictureTaken -> + when { + !isPictureTaken && viewModel.currentMultimediaUri.value == null -> { + val resultData = Intent().apply { + putExtra(MULTIMEDIA_RESULT_FIELD_INDEX, indexValue) + } + requireActivity().setResult(AppCompatActivity.RESULT_CANCELED, resultData) + requireActivity().finish() + } + + isPictureTaken -> { + Timber.d("Image successfully captured") + view?.findViewById(R.id.no_image_textview)?.visibility = View.GONE + handleTakePictureResult(viewModel.currentMultimediaPath.value) + } + + else -> { + Timber.d("Camera aborted or some interruption, restoring multimedia data") + viewModel.restoreMultimedia() + } + } + } + + /** + * Lazily initialized instance of MultimediaMenu. + * The instance is created only when first accessed. + */ + private val multimediaMenu by lazy { + MultimediaMenuProvider( + menuResId = R.menu.multimedia_menu, + onCreateMenuCondition = { menu -> + + setMenuItemIcon(menu.findItem(R.id.action_restart), R.drawable.ic_replace_image) + lifecycleScope.launch { + viewModel.currentMultimediaUri.collectLatest { uri -> + menu.findItem(R.id.action_crop).isVisible = uri != null + } + } + } + ) { menuItem -> + when (menuItem.itemId) { + R.id.action_crop -> { + viewModel.saveMultimediaForRevert( + imagePath = viewModel.currentMultimediaPath.value, + imageUri = viewModel.currentMultimediaUri.value + ) + requestCrop() + true + } + + R.id.action_restart -> { + when (selectedImageOptions) { + ImageOptions.GALLERY -> { + openGallery() + } + + ImageOptions.CAMERA -> { + viewModel.saveMultimediaForRevert( + imagePath = viewModel.currentMultimediaPath.value, + imageUri = viewModel.currentMultimediaUri.value + ) + dispatchCamera() + } + } + true + } + + else -> false + } } } @@ -99,16 +175,21 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ super.onCreate(savedInstanceState) ankiCacheDirectory = FileUtil.getAnkiCacheDirectory(requireContext(), "temp-photos") if (ankiCacheDirectory == null) { - showSomethingWentWrong() + showErrorDialog() Timber.e("createUI() failed to get cache directory") return } + + arguments?.let { + selectedImageOptions = it.getSerializableCompat(EXTRA_MEDIA_OPTIONS) as ImageOptions + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupMenu() + setupMenu(multimediaMenu) imagePreview = view.findViewById(R.id.image_preview) + imageFileSize = view.findViewById(R.id.image_size_textview) handleSelectedImageOptions() setupDoneButton() } @@ -123,26 +204,22 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ dispatchCamera() Timber.d("MultimediaImageFragment:: Launching camera") } - ImageOptions.UNKNOWN -> { - showErrorDialog() - Timber.w("MultimediaImageFragment:: Error occurred, showing error dialog") - } } } private fun setupDoneButton() { view?.findViewById(R.id.action_done)?.setOnClickListener { Timber.d("MultimediaImageFragment:: Done button pressed") - if (viewModel.getImageLength() == 0L) { + if (viewModel.selectedMediaFileSize == 0L) { Timber.d("Image length is not valid") return@setOnClickListener } - if (viewModel.getImageLength() > MultimediaUtils.IMAGE_LIMIT) { - showLargeFileCropDialog((1.0 * viewModel.getImageLength() / MultimediaEditFieldActivity.IMAGE_LIMIT).toFloat()) + if (viewModel.selectedMediaFileSize > MultimediaUtils.IMAGE_LIMIT) { + showLargeFileCropDialog((1.0 * viewModel.selectedMediaFileSize / MultimediaEditFieldActivity.IMAGE_LIMIT).toFloat()) return@setOnClickListener } - field.mediaPath = viewModel.currentImagePath + field.mediaPath = viewModel.currentMultimediaPath.value field.hasTemporaryMedia = true val resultData = Intent().apply { @@ -168,8 +245,7 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ } photoFile?.let { - viewModel.currentImagePath = it.absolutePath - // viewModel.saveCurrentImagePath(it.absolutePath) + viewModel.updateCurrentMultimediaPath(it.absolutePath) val photoURI: Uri = FileProvider.getUriForFile( requireContext(), requireActivity().applicationContext.packageName + ".apkgfileprovider", @@ -186,24 +262,23 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ return } - displayImageSize(imagePath) + updateAndDisplayImageSize(imagePath) val photoFile = File(imagePath) val imageUri: Uri = FileProvider.getUriForFile( requireContext(), requireActivity().applicationContext.packageName + ".apkgfileprovider", photoFile ) - viewModel.currentImageUri = imageUri + viewModel.updateCurrentMultimediaUri(imageUri) imagePreview.setImageURI(imageUri) showCropDialog(getString(R.string.crop_image)) } - private fun displayImageSize(imagePath: String) { + private fun updateAndDisplayImageSize(imagePath: String) { val file = File(imagePath) - viewModel.selectedImageLength = file.length() - val size = Formatter.formatFileSize(requireContext(), file.length()) - view?.findViewById(R.id.image_size_textview)?.text = size + viewModel.selectedMediaFileSize = file.length() + imageFileSize.text = file.toHumanReadableSize() } private fun showLargeFileCropDialog(length: Float) { @@ -214,7 +289,7 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ } private fun showCropDialog(message: String) { - if (viewModel.currentImageUri == null) { + if (viewModel.currentMultimediaUri.value == null) { Timber.w("showCropDialog called with null URI or Path") return } @@ -247,13 +322,13 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ } else { // reset the no preview text view?.findViewById(R.id.no_image_textview)?.apply { - text = resources.getString(R.string.no_image_preview) + text = null visibility = View.GONE } } imagePreview.setImageURI(selectedImage) - viewModel.currentImageUri = selectedImage + viewModel.updateCurrentMultimediaUri(selectedImage) if (selectedImage == null) { Timber.w("handleSelectImageIntent() selectedImage was null") @@ -273,12 +348,12 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ val imagePath = internalizedPick.absolutePath - viewModel.currentImagePath = imagePath - displayImageSize(imagePath) + viewModel.updateCurrentMultimediaPath(imagePath) + updateAndDisplayImageSize(imagePath) } private fun requestCrop() { - val imageUri = viewModel.currentImageUri ?: return + val imageUri = viewModel.currentMultimediaUri.value ?: return ImageUtils.cropImage(requireActivity().activityResultRegistry, imageUri) { cropResult -> if (cropResult == null) { Timber.d("Image crop result was null") @@ -288,10 +363,10 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ if (cropResult.isSuccessful) { cropResult.getUriFilePath(requireActivity(), true) ?.let { path -> - displayImageSize(path) - viewModel.currentImagePath = path + updateAndDisplayImageSize(path) + viewModel.updateCurrentMultimediaPath(path) } - viewModel.currentImageUri = cropResult.uriContent + viewModel.updateCurrentMultimediaUri(cropResult.uriContent) imagePreview.setImageURI(cropResult.uriContent) } else { // cropImage can give us more information. Not sure it is actionable so for now just log it. @@ -349,77 +424,25 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ return uri } - private fun setupMenu() { - (requireActivity() as MenuHost).addMenuProvider(MultimediaMenu()) - } - - /** - * Inner class that implements the MenuProvider interface to provide a menu for multimedia options. - */ - inner class MultimediaMenu : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menu.clear() - menuInflater.inflate(R.menu.multimedia_menu, menu) - - menu.findItem(R.id.action_crop).isVisible = viewModel.currentImageUri != null - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_crop -> { - viewModel.saveImageForRevert( - imagePath = viewModel.currentImagePath, - imageUri = viewModel.currentImageUri - ) - requestCrop() - true - } - - R.id.action_restart -> { - when (selectedImageOptions) { - ImageOptions.GALLERY -> { - openGallery() - } - - ImageOptions.CAMERA -> { - dispatchCamera() - } - - ImageOptions.UNKNOWN -> { - Timber.w("MultimediaImageFragment:: Error occurred, showing error dialog") - showErrorDialog() - } - } - true - } - - else -> false - } - } - } - companion object { - private var selectedImageOptions: ImageOptions = ImageOptions.UNKNOWN - fun getIntent( context: Context, multimediaExtra: MultimediaActivityExtra, imageOptions: ImageOptions ): Intent { - selectedImageOptions = imageOptions return MultimediaActivity.getIntent( context, MultimediaImageFragment::class, - multimediaExtra + multimediaExtra, + imageOptions ) } } - /** Enum class that represents image options that a user choose from the bottom sheet **/ + /** Image options that a user choose from the bottom sheet which [MultimediaImageFragment] uses **/ enum class ImageOptions { GALLERY, - CAMERA, - UNKNOWN + CAMERA } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaMenuProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaMenuProvider.kt new file mode 100644 index 000000000000..163fdf293962 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaMenuProvider.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2024 Ashish Yadav + * + * 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.anki.multimedia + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.core.view.MenuProvider + +/** + * A general-purpose menu provider for multimedia options. + * This class implements the MenuProvider interface and can be used in various fragments or activities. + * + * @property menuResId The resource ID of the menu to inflate. + * @property initialMenuItemsVisibility A map containing the visibility state of specific menu items. + * @property onCreateMenuCondition A lambda function to handle additional conditions when creating the menu. + * @property onMenuItemClicked A lambda function to handle menu item selections. + */ +class MultimediaMenuProvider( + private val menuResId: Int, + private val onCreateMenuCondition: ((Menu) -> Unit)? = null, + private val onMenuItemClicked: (menuItem: MenuItem) -> Boolean +) : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menu.clear() + menuInflater.inflate(menuResId, menu) + + // Apply additional conditions for menu items + onCreateMenuCondition?.invoke(menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return onMenuItemClicked(menuItem) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaViewModel.kt index 1ffcd9535dbe..0e17a103c8f3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaViewModel.kt @@ -21,6 +21,8 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class MultimediaViewModel : ViewModel() { @@ -28,14 +30,16 @@ class MultimediaViewModel : ViewModel() { /** Errors or Warnings related to the edit fields that might occur when trying to save note */ val multimediaAction = MutableSharedFlow() - private var prevImagePath: String? = null - private var prevImageUri: Uri? = null + private var prevMultimediaPath: String? = null + private var prevMultimediaUri: Uri? = null - var currentImageUri: Uri? = null + private val _currentMultimediaUri = MutableStateFlow(null) + val currentMultimediaUri: StateFlow get() = _currentMultimediaUri - var currentImagePath: String? = null + private val _currentMultimediaPath = MutableStateFlow(null) + val currentMultimediaPath: StateFlow get() = _currentMultimediaPath - var selectedImageLength: Long = 0 + var selectedMediaFileSize: Long = 0 fun setMultimediaAction(action: MultimediaBottomSheet.MultimediaAction) { viewModelScope.launch { @@ -43,12 +47,21 @@ class MultimediaViewModel : ViewModel() { } } - fun getImageLength(): Long { - return selectedImageLength + fun saveMultimediaForRevert(imagePath: String?, imageUri: Uri?) { + prevMultimediaPath = imagePath + prevMultimediaUri = imageUri } - fun saveImageForRevert(imagePath: String?, imageUri: Uri?) { - prevImagePath = imagePath - prevImageUri = imageUri + fun restoreMultimedia() { + _currentMultimediaUri.value = prevMultimediaUri + _currentMultimediaPath.value = prevMultimediaPath + } + + fun updateCurrentMultimediaUri(uri: Uri?) { + _currentMultimediaUri.value = uri + } + + fun updateCurrentMultimediaPath(path: String?) { + _currentMultimediaPath.value = path } } diff --git a/AnkiDroid/src/main/res/drawable/ic_add_photo.xml b/AnkiDroid/src/main/res/drawable/ic_add_photo.xml deleted file mode 100644 index 9bf60a41de14..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_add_photo.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/AnkiDroid/src/main/res/drawable/ic_image_not_supported.xml b/AnkiDroid/src/main/res/drawable/ic_image_not_supported.xml new file mode 100644 index 000000000000..5fb3fe664ba0 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_image_not_supported.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/AnkiDroid/src/main/res/drawable/ic_replace_audio.xml b/AnkiDroid/src/main/res/drawable/ic_replace_audio.xml new file mode 100644 index 000000000000..6238f4e702f9 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_replace_audio.xml @@ -0,0 +1,10 @@ + + + diff --git a/AnkiDroid/src/main/res/drawable/ic_replace_image.xml b/AnkiDroid/src/main/res/drawable/ic_replace_image.xml new file mode 100644 index 000000000000..c6eec75e5849 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_replace_image.xml @@ -0,0 +1,10 @@ + + + diff --git a/AnkiDroid/src/main/res/drawable/ic_replace_video.xml b/AnkiDroid/src/main/res/drawable/ic_replace_video.xml new file mode 100644 index 000000000000..b8be57ebcc6a --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_replace_video.xml @@ -0,0 +1,10 @@ + + + diff --git a/AnkiDroid/src/main/res/drawable/ic_restart.xml b/AnkiDroid/src/main/res/drawable/ic_restart.xml deleted file mode 100644 index 60a2935bcc82..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_restart.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - diff --git a/AnkiDroid/src/main/res/drawable/round_audio_file_24.xml b/AnkiDroid/src/main/res/drawable/round_audio_file_24.xml index 30dd37bbb995..56b19cb06be3 100644 --- a/AnkiDroid/src/main/res/drawable/round_audio_file_24.xml +++ b/AnkiDroid/src/main/res/drawable/round_audio_file_24.xml @@ -1,5 +1,10 @@ - - + + diff --git a/AnkiDroid/src/main/res/layout/fragment_audio_video.xml b/AnkiDroid/src/main/res/layout/fragment_audio_video.xml new file mode 100644 index 000000000000..1aef4b108ada --- /dev/null +++ b/AnkiDroid/src/main/res/layout/fragment_audio_video.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/layout/fragment_multimedia_image.xml b/AnkiDroid/src/main/res/layout/fragment_multimedia_image.xml index 8e3046b6c045..207fca38f2f9 100644 --- a/AnkiDroid/src/main/res/layout/fragment_multimedia_image.xml +++ b/AnkiDroid/src/main/res/layout/fragment_multimedia_image.xml @@ -40,11 +40,10 @@ android:gravity="center" android:layout_gravity="bottom" android:layout_width="match_parent" - android:text="@string/no_image_preview" android:layout_height="wrap_content"/> + android:title="@string/reselect" + app:showAsAction="always" /> App returned an unexpected value. You may need to use a different app Field Contents - No image selected, select an image to proceed. - Restart + Reselect diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 38c3af7a1ad7..d4a665b1a637 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,8 @@ [versions] compileSdk = "34" +media3Exoplayer = "1.3.1" +media3ExoplayerDash = "1.3.1" +media3Ui = "1.3.1" minSdk = "23" # also in testlib/build.gradle.kts targetSdk = "34" # also in ../robolectricDownloader.gradle acra = '5.11.3' @@ -87,6 +90,9 @@ androidx-browser = { module = "androidx.browser:browser", version.ref = "android androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppCompat" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidxAnnotation" } androidx-activity = { module = "androidx.activity:activity", version.ref = "androidxActivity" } +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" } +androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "media3ExoplayerDash" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "androidxPreferenceKtx" } androidx-media = { module = "androidx.media:media", version.ref = "androidxMedia" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidxUiautomator" }