Skip to content

Commit

Permalink
enhancement: add audio recorder option in multimedia editor
Browse files Browse the repository at this point in the history
  • Loading branch information
criticalAY authored and david-allison committed Aug 7, 2024
1 parent 1c878bb commit 541ef81
Show file tree
Hide file tree
Showing 9 changed files with 255 additions and 18 deletions.
12 changes: 11 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,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.AudioRecordingFragment
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
Expand All @@ -96,6 +97,7 @@ import com.ichi2.anki.multimedia.MultimediaBottomSheet
import com.ichi2.anki.multimedia.MultimediaImageFragment
import com.ichi2.anki.multimedia.MultimediaUtils.createImageFile
import com.ichi2.anki.multimedia.MultimediaViewModel
import com.ichi2.anki.multimediacard.fields.AudioRecordingField
import com.ichi2.anki.multimediacard.fields.EFieldType
import com.ichi2.anki.multimediacard.fields.IField
import com.ichi2.anki.multimediacard.fields.ImageField
Expand Down Expand Up @@ -1720,7 +1722,15 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su
}

MultimediaBottomSheet.MultimediaAction.SELECT_AUDIO_RECORDING -> {
// TODO("Not yet implemented")
Timber.i("Selected audio recording option")
val field = AudioRecordingField()
note.setField(fieldIndex, field)
val audioRecordingIntent = AudioRecordingFragment.getIntent(
requireContext(),
MultimediaActivityExtra(fieldIndex, field, note)
)

multimediaFragmentLauncher.launch(audioRecordingIntent)
}

MultimediaBottomSheet.MultimediaAction.SELECT_VIDEO_FILE -> {
Expand Down
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@ open class Reviewer :
setEditorStatus(false)
if (!isAudioUIInitialized) {
try {
audioRecordingController = AudioRecordingController(this)
audioRecordingController = AudioRecordingController(context = this)
audioRecordingController?.createUI(
this,
micToolBarLayer,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* Copyright (c) 2024 Ashish Yadav <[email protected]>
*
* 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 <http://www.gnu.org/licenses/>.
*/

package com.ichi2.anki.multimedia

import android.Manifest
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
import com.ichi2.anki.CrashReportService
import com.ichi2.anki.R
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.audio.AudioRecordingController
import com.ichi2.annotations.NeedsTest
import com.ichi2.utils.FileUtil
import com.ichi2.utils.Permissions
import kotlinx.coroutines.launch
import timber.log.Timber

class AudioRecordingFragment : MultimediaFragment(R.layout.fragment_audio_recording) {
override val title: String
get() = resources.getString(R.string.multimedia_editor_field_editing_audio)

private val viewModel: MultimediaViewModel by viewModels()

private var audioRecordingController: AudioRecordingController? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ankiCacheDirectory = FileUtil.getAnkiCacheDirectory(requireContext(), "temp-media")
if (ankiCacheDirectory == null) {
Timber.e("createUI() failed to get cache directory")
showErrorDialog(errorMessage = resources.getString(R.string.multimedia_editor_failed))
return
}
}

private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
Timber.d("Audio permission granted")
initializeAudioRecorder()
setupDoneButton()
} else {
Timber.d("Audio permission denied")
showErrorDialog(resources.getString(R.string.multimedia_editor_audio_permission_refused))
}
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

if (!hasMicPermission()) {
return
}

initializeAudioRecorder()
setupDoneButton()
}

private fun hasMicPermission(): Boolean {
if (!Permissions.canRecordAudio(requireContext())) {
Timber.i("Requesting Audio Permissions")
requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
return false
}
return true
}

@NeedsTest("Done button is enabled only when the length is not null")
private fun setupDoneButton() {
lifecycleScope.launch {
viewModel.currentMultimediaPath.collect { path ->
view?.findViewById<MaterialButton>(R.id.action_done)?.isEnabled = path != null
}
}
view?.findViewById<MaterialButton>(R.id.action_done)?.setOnClickListener {
Timber.d("AudioRecordingFragment:: Done button pressed")
if (viewModel.selectedMediaFileSize == 0L) {
Timber.d("Audio length 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()
}
}

@NeedsTest("AudioRecordingController is correctly initialized")
private fun initializeAudioRecorder() {
try {
audioRecordingController = AudioRecordingController(
context = requireActivity(),
linearLayout = view?.findViewById(R.id.audio_recorder_layout)!!,
viewModel = viewModel,
note = note
)
} catch (e: Exception) {
Timber.w(e, "unable to add the audio recorder to toolbar")
CrashReportService.sendExceptionReport(e, "Unable to create recorder tool bar")
showErrorDialog()
}
}

override fun onDestroy() {
super.onDestroy()
audioRecordingController?.onDestroy()
}

override fun onDetach() {
super.onDetach()
audioRecordingController?.onFocusLost()
}

companion object {

fun getIntent(
context: Context,
multimediaExtra: MultimediaActivityExtra
): Intent {
return MultimediaActivity.getIntent(
context,
AudioRecordingFragment::class,
multimediaExtra
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ class MultimediaActivity : AnkiActivity() {
context: Context,
fragmentClass: KClass<out Fragment>,
arguments: MultimediaActivityExtra? = null,
mediaOptions: Serializable
mediaOptions: Serializable? = null
): Intent {
return Intent(context, MultimediaActivity::class.java).apply {
putExtra(MULTIMEDIA_ARGS_EXTRA, arguments)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,8 @@ abstract class MultimediaFragment(@LayoutRes layout: Int) : Fragment(layout) {
* when clicked, finishes the current activity.
*/
fun showErrorDialog(errorMessage: String? = null) {
val message = errorMessage ?: resources.getString(R.string.something_wrong)
AlertDialog.Builder(requireContext()).show {
setMessage(message)
setMessage(errorMessage ?: resources.getString(R.string.something_wrong))
setPositiveButton(getString(R.string.dialog_ok)) { _, _ ->
requireActivity().finish()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ class MultimediaViewModel : ViewModel() {
_currentMultimediaPath.value = prevMultimediaPath
}

fun updateMediaFileLength(length: Long) {
selectedMediaFileSize = length
}

fun updateCurrentMultimediaUri(uri: Uri?) {
_currentMultimediaUri.value = uri
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import kotlin.time.Duration.Companion.milliseconds
// TODO : stop audio time view flickering
class AudioRecordingController(
val context: Context,
val layout: LinearLayout? = null,
val linearLayout: LinearLayout? = null,
val viewModel: MultimediaViewModel? = null,
val note: IMultimediaEditableNote? = null
) :
Expand Down Expand Up @@ -105,15 +105,12 @@ class AudioRecordingController(
private var orientationEventListener: OrientationEventListener? = null

init {
if (layout != null) {
createUI(context, layout)
Timber.d("Initializing the audio recorder UI")
if (linearLayout != null) {
createUI(context, linearLayout, AppendToRecording.CLEARED, R.layout.activity_audio_recording)
}
}

private fun createUI(context: Context, layout: LinearLayout) {
createUI(context, layout, AppendToRecording.CLEARED, R.layout.activity_audio_recording)
}

fun createUI(
context: Context,
layout: LinearLayout,
Expand Down Expand Up @@ -441,6 +438,7 @@ class AudioRecordingController(

private fun discardAudio() {
vibrate(20.milliseconds)
viewModel?.updateCurrentMultimediaPath(null)
setUiState(state.clear())
tempAudioPath = generateTempAudioFile(context).also { tempAudioPath = it }
stopAudioPlayer()
Expand Down Expand Up @@ -474,6 +472,7 @@ class AudioRecordingController(
Timber.i("recording completed")
if (vibrate) vibrate(20.milliseconds)
stopAndSaveRecording()
// TODO: discuss if we want to keep the snackbar here
// show this snackbar only in the edit field/multimedia activity
if (inEditField) (context as Activity).showSnackbar(context.resources.getString(R.string.audio_saved))
prepareAudioPlayer()
Expand Down Expand Up @@ -596,6 +595,10 @@ class AudioRecordingController(

private fun saveRecording() {
viewModel?.updateCurrentMultimediaPath(tempAudioPath)
val file = tempAudioPath?.let { File(it) }
if (file != null) {
viewModel?.updateMediaFileLength(file.length())
}
}

fun stopAndSaveRecording() {
Expand Down Expand Up @@ -634,6 +637,7 @@ class AudioRecordingController(
audioTimer.stop()
setUiState(state.clear())
audioRecorder.stopRecording()
viewModel?.updateCurrentMultimediaPath(null)
tempAudioPath = generateTempAudioFile(context).also { tempAudioPath = it }
isRecording = false
}
Expand Down Expand Up @@ -717,7 +721,7 @@ class AudioRecordingController(
}

fun setEditorStatus(inEditField: Boolean) {
Companion.inEditField = inEditField
this.inEditField = inEditField
}

/** File of the temporary mic record */
Expand Down
63 changes: 63 additions & 0 deletions AnkiDroid/src/main/res/layout/fragment_audio_recording.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2024 Ashish Yadav <[email protected]>
~
~ 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 <http://www.gnu.org/licenses/>.
-->

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/root_layout"
xmlns:app="http://schemas.android.com/apk/res-auto">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">


<com.google.android.material.card.MaterialCardView
android:id="@+id/media_view_container"
android:layout_width="match_parent"
android:layout_marginBottom="8dp"
android:layout_marginHorizontal="8dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/action_done"
style="@style/CardView.PreviewerStyle" >

<LinearLayout
android:id="@+id/audio_recorder_layout"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">

</LinearLayout>

</com.google.android.material.card.MaterialCardView>

<com.google.android.material.button.MaterialButton
android:id="@+id/action_done"
android:layout_width="0dp"
android:layout_marginHorizontal="8dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/media_view_container"
android:text="@string/multimedia_editor_field_editing_done"/>


</androidx.constraintlayout.widget.ConstraintLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@ class AudioRecordingControllerAndroidTest : RobolectricTest() {
@Ignore("does not fail when expected under Robolectric")
fun `Voice Playback handles onPause`() = withVoicePlayback {
Timber.v("start recording")
layout.findViewById<MaterialButton?>(R.id.action_start_recording).performClick()
layout.findViewById<MaterialButton?>(R.id.action_start_recording)?.performClick()
Timber.v("stop recording")
layout.findViewById<MaterialButton?>(R.id.action_start_recording).performClick()
layout.findViewById<MaterialButton?>(R.id.action_start_recording)?.performClick()
Timber.v(" playback recording")
layout.findViewById<MaterialButton?>(R.id.action_start_recording).performClick()
layout.findViewById<MaterialButton?>(R.id.action_start_recording)?.performClick()
onViewFocusChanged()
Timber.v("playback recording again")
layout.findViewById<MaterialButton?>(R.id.action_start_recording).performClick()
layout.findViewById<MaterialButton?>(R.id.action_start_recording)?.performClick()
}

/** Applies [block] to a [AudioRecordingController] generated for the [Reviewer] */
Expand All @@ -65,7 +65,7 @@ class AudioRecordingControllerAndroidTest : RobolectricTest() {
val layout = LinearLayout(targetContext)
Themes.setTheme(targetContext)
this.layout = layout
AudioRecordingController().apply {
AudioRecordingController(targetContext, layout).apply {
// this shouldn't be here
AudioRecordingController.tempAudioPath = AudioRecordingController.generateTempAudioFile(targetContext)
AudioRecordingController.setEditorStatus(inEditField = false)
Expand Down

0 comments on commit 541ef81

Please sign in to comment.