From 2dd02ff42cb6385e2c9c7223cd7a457edcf41c44 Mon Sep 17 00:00:00 2001 From: Herbert Seewann Date: Mon, 17 Nov 2025 17:54:23 +0100 Subject: [PATCH] IDE-295 Refactor SoundRecorder files to Kotlin --- catroid/build.gradle | 11 +- .../test/formulaeditor/SensorHandlerTest.kt | 17 +- .../{RecordButton.java => RecordButton.kt} | 43 +-- .../catroid/soundrecorder/SoundRecorder.java | 100 ------- .../catroid/soundrecorder/SoundRecorder.kt | 90 ++++++ .../soundrecorder/SoundRecorderActivity.java | 169 ------------ .../soundrecorder/SoundRecorderActivity.kt | 169 ++++++++++++ .../test/soundrecorder/SoundRecorderTest.kt | 257 ++++++++++++++++++ 8 files changed, 547 insertions(+), 309 deletions(-) rename catroid/src/main/java/org/catrobat/catroid/soundrecorder/{RecordButton.java => RecordButton.kt} (59%) delete mode 100644 catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorder.java create mode 100644 catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorder.kt delete mode 100644 catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorderActivity.java create mode 100644 catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorderActivity.kt create mode 100644 catroid/src/test/java/org/catrobat/catroid/test/soundrecorder/SoundRecorderTest.kt diff --git a/catroid/build.gradle b/catroid/build.gradle index 182172c3914..72732585206 100644 --- a/catroid/build.gradle +++ b/catroid/build.gradle @@ -1,8 +1,6 @@ -import com.android.build.api.dsl.ManagedVirtualDevice - /* * Catroid: An on-device visual programming system for Android devices - * Copyright (C) 2010-2025 The Catrobat Team + * Copyright (C) 2010-2025 The Catrobat Team * () * * This program is free software: you can redistribute it and/or modify @@ -22,7 +20,6 @@ import com.android.build.api.dsl.ManagedVirtualDevice * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ - buildscript { repositories { google() @@ -52,6 +49,7 @@ ext { projectVersion = "0.9" gdxVersion = "1.9.10" mockitoVersion = "3.12.4" + mockkVersion = "1.12.5" espressoVersion = "3.1.0" playServicesVersion = '17.0.1' cameraXVersion = "1.0.0-beta07" @@ -492,6 +490,9 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation "org.mockito:mockito-core:$mockitoVersion" + + testImplementation "io.mockk:mockk:${mockkVersion}" + testImplementation 'org.hamcrest:hamcrest-library:1.3' testImplementation 'org.powermock:powermock:1.6.6' @@ -512,6 +513,8 @@ dependencies { androidTestImplementation "org.mockito:mockito-android:$mockitoVersion" androidTestImplementation "org.mockito:mockito-core:$mockitoVersion" + androidTestImplementation "io.mockk:mockk-android:${mockkVersion}" + androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' diff --git a/catroid/src/androidTest/java/org/catrobat/catroid/test/formulaeditor/SensorHandlerTest.kt b/catroid/src/androidTest/java/org/catrobat/catroid/test/formulaeditor/SensorHandlerTest.kt index 373edc41b4b..5c8f39ee131 100644 --- a/catroid/src/androidTest/java/org/catrobat/catroid/test/formulaeditor/SensorHandlerTest.kt +++ b/catroid/src/androidTest/java/org/catrobat/catroid/test/formulaeditor/SensorHandlerTest.kt @@ -28,6 +28,9 @@ import android.graphics.Rect import androidx.test.annotation.UiThreadTest import androidx.test.core.app.ApplicationProvider import androidx.test.rule.GrantPermissionRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import org.catrobat.catroid.ProjectManager import org.catrobat.catroid.camera.VisualDetectionHandler.facesForSensors import org.catrobat.catroid.camera.VisualDetectionHandler.updateFaceDetectionStatusSensorValues @@ -44,7 +47,6 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.Mockito class SensorHandlerTest { @get:Rule @@ -118,18 +120,19 @@ class SensorHandlerTest { @UiThreadTest fun testMicRelease() { val loudnessSensor = SensorLoudness() - val soundRecorder = Mockito.mock(SoundRecorder::class.java) + val soundRecorder = mockk(relaxed = true) loudnessSensor.soundRecorder = soundRecorder - Mockito.`when`(soundRecorder.isRecording).thenReturn(false) - SensorHandler.getInstance(ApplicationProvider.getApplicationContext()).setSensorLoudness(loudnessSensor) + every { soundRecorder.isRecording } returns false + SensorHandler.getInstance(ApplicationProvider.getApplicationContext()) + .setSensorLoudness(loudnessSensor) SensorHandler.startSensorListener(ApplicationProvider.getApplicationContext()) - Mockito.`when`(soundRecorder.isRecording).thenReturn(true) - Mockito.verify(soundRecorder).start() + every { soundRecorder.isRecording } returns true + verify { soundRecorder.start() } SensorHandler.stopSensorListeners() - Mockito.verify(soundRecorder).stop() + verify { soundRecorder.stop() } } @After diff --git a/catroid/src/main/java/org/catrobat/catroid/soundrecorder/RecordButton.java b/catroid/src/main/java/org/catrobat/catroid/soundrecorder/RecordButton.kt similarity index 59% rename from catroid/src/main/java/org/catrobat/catroid/soundrecorder/RecordButton.java rename to catroid/src/main/java/org/catrobat/catroid/soundrecorder/RecordButton.kt index d72974ddbfe..67a7ffbb75e 100644 --- a/catroid/src/main/java/org/catrobat/catroid/soundrecorder/RecordButton.java +++ b/catroid/src/main/java/org/catrobat/catroid/soundrecorder/RecordButton.kt @@ -20,38 +20,23 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package org.catrobat.catroid.soundrecorder; +package org.catrobat.catroid.soundrecorder -import android.annotation.SuppressLint; -import android.content.Context; -import android.util.AttributeSet; -import android.widget.ImageButton; +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.widget.ImageButton @SuppressLint("AppCompatCustomView") -public class RecordButton extends ImageButton { - private RecordState state = RecordState.STOP; +class RecordButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : ImageButton(context, attrs, defStyle) { - public RecordButton(Context context) { - super(context); - } + var state: RecordState = RecordState.STOP - public RecordButton(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public RecordButton(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - } - - public RecordState getState() { - return state; - } - - public void setState(RecordState state) { - this.state = state; - } - - public enum RecordState { - RECORD, STOP; - } + enum class RecordState { + RECORD, STOP + } } diff --git a/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorder.java b/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorder.java deleted file mode 100644 index d671cc012f3..00000000000 --- a/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorder.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Catroid: An on-device visual programming system for Android devices - * Copyright (C) 2010-2025 The Catrobat Team - * () - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * An additional term exception under section 7 of the GNU Affero - * General Public License, version 3, is available at - * http://developer.catrobat.org/license_additional_term - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package org.catrobat.catroid.soundrecorder; - -import android.media.MediaRecorder; -import android.util.Log; - -import java.io.File; -import java.io.IOException; - -public class SoundRecorder { - - public static final String RECORDING_EXTENSION = ".m4a"; - private MediaRecorder recorder; - private boolean isRecording; - - public static final String TAG = SoundRecorder.class.getSimpleName(); - - private String path; - - public SoundRecorder(String path) { - this.recorder = new MediaRecorder(); - this.path = path; - } - - public void start() throws IOException, RuntimeException { - File soundFile = new File(path); - if (soundFile.exists()) { - soundFile.delete(); - } - File directory = soundFile.getParentFile(); - if (!directory.exists() && !directory.mkdirs()) { - throw new IOException("Path to file could not be created."); - } - - try { - recorder.reset(); - recorder.setAudioSource(MediaRecorder.AudioSource.MIC); - recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); - recorder.setOutputFile(path); - recorder.prepare(); - recorder.start(); - isRecording = true; - } catch (IllegalStateException e) { - throw e; - } catch (RuntimeException e) { - throw e; - } - } - - public void stop() throws IOException { - try { - recorder.stop(); - } catch (RuntimeException e) { - Log.d(TAG, "Note that a RuntimeException is intentionally " - + "thrown to the application, if no valid audio/video data " - + "has been received when stop() is called. This happens if stop() " - + "is called immediately after start(). The failure lets the application " - + "take action accordingly to clean up the output file " - + "(delete the output file, for instance), since the output file " - + "is not properly constructed when this happens."); - } - recorder.reset(); - recorder.release(); - isRecording = false; - } - - public String getPath() { - return path; - } - - public int getMaxAmplitude() { - return recorder.getMaxAmplitude(); - } - - public boolean isRecording() { - return isRecording; - } -} diff --git a/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorder.kt b/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorder.kt new file mode 100644 index 00000000000..a9473357c27 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorder.kt @@ -0,0 +1,90 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2025 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.catrobat.catroid.soundrecorder + +import android.media.MediaRecorder +import android.util.Log +import java.io.File +import java.io.IOException + +class SoundRecorder @JvmOverloads constructor( + val path: String, + private val recorder: MediaRecorder = MediaRecorder() +) { + var isRecording: Boolean = false + private set + + val maxAmplitude: Int + get() = recorder.maxAmplitude + + companion object { + private val TAG: String = SoundRecorder::class.java.simpleName + } + + @Throws(IOException::class, RuntimeException::class) + fun start() { + val soundFile = File(path) + if (soundFile.exists() && !soundFile.delete()) { + throw IOException("Could not delete existing file at $path") + } + val directory = soundFile.parentFile + if (directory == null || (!directory.exists() && !directory.mkdirs())) { + throw IOException("Path to file could not be created.") + } + + try { + recorder.reset() + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorder.setOutputFile(path) + recorder.prepare() + recorder.start() + isRecording = true + } catch (e: IllegalStateException) { + throw e + } catch (e: RuntimeException) { + throw e + } + } + + @Throws(IOException::class) + fun stop() { + try { + recorder.stop() + } catch (_: RuntimeException) { + Log.d( + TAG, ("Note that a RuntimeException is intentionally " + + "thrown to the application, if no valid audio/video data " + + "has been received when stop() is called. This happens if stop() " + + "is called immediately after start(). The failure lets the application " + + "take action accordingly to clean up the output file " + + "(delete the output file, for instance), since the output file " + + "is not properly constructed when this happens.") + ) + } + recorder.reset() + recorder.release() + isRecording = false + } +} diff --git a/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorderActivity.java b/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorderActivity.java deleted file mode 100644 index 8045b77891c..00000000000 --- a/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorderActivity.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Catroid: An on-device visual programming system for Android devices - * Copyright (C) 2010-2025 The Catrobat Team - * () - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * An additional term exception under section 7 of the GNU Affero - * General Public License, version 3, is available at - * http://developer.catrobat.org/license_additional_term - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package org.catrobat.catroid.soundrecorder; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.os.SystemClock; -import android.util.Log; -import android.view.MenuItem; -import android.view.View; -import android.view.View.OnClickListener; -import android.widget.Chronometer; - -import org.catrobat.catroid.R; -import org.catrobat.catroid.ui.BaseActivity; -import org.catrobat.catroid.ui.runtimepermissions.RequiresPermissionTask; -import org.catrobat.catroid.utils.ToastUtil; - -import java.io.File; -import java.io.IOException; -import java.util.Arrays; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.Toolbar; -import androidx.core.content.FileProvider; - -import static android.Manifest.permission.RECORD_AUDIO; - -import static org.catrobat.catroid.common.Constants.SOUND_RECORDER_CACHE_DIRECTORY; - -public class SoundRecorderActivity extends BaseActivity implements OnClickListener { - - private static final String TAG = SoundRecorderActivity.class.getSimpleName(); - private SoundRecorder soundRecorder; - private Chronometer timeRecorderChronometer; - private RecordButton recordButton; - private static final int REQUEST_PERMISSIONS_RECORD_AUDIO = 401; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setContentView(R.layout.activity_soundrecorder); - setSupportActionBar((Toolbar) findViewById(R.id.toolbar)); - getSupportActionBar().setTitle(R.string.soundrecorder_name); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - - recordButton = findViewById(R.id.soundrecorder_record_button); - timeRecorderChronometer = findViewById(R.id.soundrecorder_chronometer_time_recorded); - recordButton.setOnClickListener(this); - } - - @Override - public void onClick(final View view) { - new RequiresPermissionTask(REQUEST_PERMISSIONS_RECORD_AUDIO, - Arrays.asList(RECORD_AUDIO), - R.string.runtime_permission_general) { - public void task() { - if (view.getId() == R.id.soundrecorder_record_button) { - if (soundRecorder != null && soundRecorder.isRecording()) { - stopRecording(); - timeRecorderChronometer.stop(); - finish(); - } else { - startRecording(); - long currentPlayingBase = SystemClock.elapsedRealtime(); - timeRecorderChronometer.setBase(currentPlayingBase); - timeRecorderChronometer.start(); - } - } - } - }.execute(this); - } - - @Override - public void onBackPressed() { - stopRecording(); - super.onBackPressed(); - } - - private synchronized void startRecording() { - if (soundRecorder != null && soundRecorder.isRecording()) { - return; - } - try { - if (soundRecorder != null) { - soundRecorder.stop(); - } - - SOUND_RECORDER_CACHE_DIRECTORY.mkdirs(); - if (!SOUND_RECORDER_CACHE_DIRECTORY.isDirectory()) { - throw new IOException("Cannot create " + SOUND_RECORDER_CACHE_DIRECTORY); - } - File soundFile = new File(SOUND_RECORDER_CACHE_DIRECTORY, getString(R.string.soundrecorder_recorded_filename)); - soundRecorder = new SoundRecorder(soundFile.getAbsolutePath()); - soundRecorder.start(); - setViewsToRecordingState(); - } catch (IOException e) { - Log.e(TAG, "Error recording sound.", e); - ToastUtil.showError(this, R.string.soundrecorder_error); - } catch (IllegalStateException e) { - Log.e(TAG, "Error recording sound (Other recorder running?).", e); - ToastUtil.showError(this, R.string.soundrecorder_error); - } catch (RuntimeException e) { - Log.e(TAG, "Device does not support audio or video format.", e); - ToastUtil.showError(this, R.string.soundrecorder_error); - } - } - - private void setViewsToRecordingState() { - recordButton.setState(RecordButton.RecordState.RECORD); - recordButton.setImageResource(R.drawable.ic_microphone_active); - } - - private synchronized void stopRecording() { - if (soundRecorder == null || !soundRecorder.isRecording()) { - return; - } - setViewsToNotRecordingState(); - try { - soundRecorder.stop(); - - Uri uri = FileProvider.getUriForFile(this, - getApplicationContext().getPackageName() + ".fileProvider", - new File(soundRecorder.getPath())); - setResult(AppCompatActivity.RESULT_OK, new Intent(Intent.ACTION_PICK, uri)); - } catch (IOException e) { - Log.e(TAG, "Error recording sound.", e); - ToastUtil.showError(this, R.string.soundrecorder_error); - setResult(AppCompatActivity.RESULT_CANCELED); - } - } - - private void setViewsToNotRecordingState() { - recordButton.setState(RecordButton.RecordState.STOP); - recordButton.setImageResource(R.drawable.ic_microphone); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } -} diff --git a/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorderActivity.kt b/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorderActivity.kt new file mode 100644 index 00000000000..52c49249cba --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/soundrecorder/SoundRecorderActivity.kt @@ -0,0 +1,169 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2025 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.catrobat.catroid.soundrecorder + +import android.Manifest.permission.RECORD_AUDIO +import android.content.Intent +import android.os.Bundle +import android.os.SystemClock +import android.util.Log +import android.view.MenuItem +import android.view.View +import android.widget.Chronometer +import androidx.core.content.FileProvider +import org.catrobat.catroid.R +import org.catrobat.catroid.common.Constants.SOUND_RECORDER_CACHE_DIRECTORY +import org.catrobat.catroid.ui.BaseActivity +import org.catrobat.catroid.ui.runtimepermissions.RequiresPermissionTask +import org.catrobat.catroid.utils.ToastUtil +import java.io.File +import java.io.IOException + +class SoundRecorderActivity : BaseActivity(), View.OnClickListener { + + private var soundRecorder: SoundRecorder? = null + private lateinit var timeRecorderChronometer: Chronometer + private lateinit var recordButton: RecordButton + + companion object { + private val TAG: String = SoundRecorderActivity::class.java.simpleName + private const val REQUEST_PERMISSIONS_RECORD_AUDIO = 401 + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_soundrecorder) + setSupportActionBar(findViewById(R.id.toolbar)) + supportActionBar?.setTitle(R.string.soundrecorder_name) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + recordButton = findViewById(R.id.soundrecorder_record_button) + timeRecorderChronometer = findViewById(R.id.soundrecorder_chronometer_time_recorded) + recordButton.setOnClickListener(this) + } + + override fun onClick(view: View) { + object : RequiresPermissionTask( + REQUEST_PERMISSIONS_RECORD_AUDIO, + listOf(RECORD_AUDIO), + R.string.runtime_permission_general + ) { + override fun task() { + if (view.id == R.id.soundrecorder_record_button) { + if (soundRecorder?.isRecording == true) { + stopRecording() + timeRecorderChronometer.stop() + finish() + } else { + startRecording() + val currentPlayingBase = SystemClock.elapsedRealtime() + timeRecorderChronometer.base = currentPlayingBase + timeRecorderChronometer.start() + } + } + } + }.execute(this) + } + + override fun onBackPressed() { + stopRecording() + super.onBackPressed() + } + + @Synchronized + private fun startRecording() { + if (soundRecorder?.isRecording == true) { + return + } + + try { + soundRecorder?.stop() + + SOUND_RECORDER_CACHE_DIRECTORY.mkdirs() + if (!SOUND_RECORDER_CACHE_DIRECTORY.isDirectory) { + throw IOException("Cannot create $SOUND_RECORDER_CACHE_DIRECTORY") + } + + val soundFile = File( + SOUND_RECORDER_CACHE_DIRECTORY, + getString(R.string.soundrecorder_recorded_filename) + ) + soundRecorder = SoundRecorder(soundFile.absolutePath) + soundRecorder?.start() + setViewsToRecordingState() + } catch (e: IOException) { + Log.e(TAG, "Error recording sound.", e) + ToastUtil.showError(this, R.string.soundrecorder_error) + } catch (e: IllegalStateException) { + Log.e(TAG, "Error recording sound (Other recorder running?).", e) + ToastUtil.showError(this, R.string.soundrecorder_error) + } catch (e: RuntimeException) { + Log.e(TAG, "Device does not support audio or video format.", e) + ToastUtil.showError(this, R.string.soundrecorder_error) + } + } + + private fun setViewsToRecordingState() { + recordButton.state = RecordButton.RecordState.RECORD + recordButton.setImageResource(R.drawable.ic_microphone_active) + } + + @Synchronized + private fun stopRecording() { + val recorderSnapshot = soundRecorder ?: return + if (!recorderSnapshot.isRecording) { + return + } + + setViewsToNotRecordingState() + try { + recorderSnapshot.stop() + + val uri = FileProvider.getUriForFile( + this, + "${applicationContext.packageName}.fileProvider", + File(recorderSnapshot.path) + ) + setResult(RESULT_OK, Intent(Intent.ACTION_PICK, uri)) + } catch (e: IOException) { + Log.e(TAG, "Error recording sound.", e) + ToastUtil.showError(this, R.string.soundrecorder_error) + setResult(RESULT_CANCELED) + } + } + + private fun setViewsToNotRecordingState() { + recordButton.state = RecordButton.RecordState.STOP + recordButton.setImageResource(R.drawable.ic_microphone) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressed() + return true + } + + return super.onOptionsItemSelected(item) + } +} diff --git a/catroid/src/test/java/org/catrobat/catroid/test/soundrecorder/SoundRecorderTest.kt b/catroid/src/test/java/org/catrobat/catroid/test/soundrecorder/SoundRecorderTest.kt new file mode 100644 index 00000000000..71eceae14c6 --- /dev/null +++ b/catroid/src/test/java/org/catrobat/catroid/test/soundrecorder/SoundRecorderTest.kt @@ -0,0 +1,257 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2025 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.catrobat.catroid.test.soundrecorder + +import android.media.MediaRecorder +import org.catrobat.catroid.soundrecorder.SoundRecorder +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mockito.clearInvocations +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.io.File +import java.io.IOException + +@RunWith(JUnit4::class) +class SoundRecorderTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var soundRecorder: SoundRecorder + private lateinit var testFile: File + private lateinit var mockRecorder: MediaRecorder + + @Before + fun setUp() { + mockRecorder = mock(MediaRecorder::class.java) + + // Setup default mock behavior for MediaRecorder + doNothing().`when`(mockRecorder).reset() + doNothing().`when`(mockRecorder).setAudioSource(MediaRecorder.AudioSource.MIC) + doNothing().`when`(mockRecorder).setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + doNothing().`when`(mockRecorder).setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + doNothing().`when`(mockRecorder).setOutputFile(org.mockito.ArgumentMatchers.anyString()) + doNothing().`when`(mockRecorder).prepare() + doNothing().`when`(mockRecorder).start() + doNothing().`when`(mockRecorder).stop() + doNothing().`when`(mockRecorder).release() + + testFile = File(tempFolder.root, "test_recording.m4a") + soundRecorder = SoundRecorder(testFile.absolutePath, mockRecorder) + } + + @After + fun tearDown() { + if (soundRecorder.isRecording) { + soundRecorder.stop() + } + } + + @Test + fun testConstructorInitializesCorrectly() { + assertEquals( + "Path should match constructor argument", + testFile.absolutePath, + soundRecorder.path + ) + assertFalse("SoundRecorder should not be recording initially", soundRecorder.isRecording) + } + + @Test + fun testMaxAmplitudeDelegatesToMediaRecorder() { + val expectedAmplitude = 12345 + `when`(mockRecorder.maxAmplitude).thenReturn(expectedAmplitude) + + val actualAmplitude = soundRecorder.maxAmplitude + + assertEquals( + "maxAmplitude should return value from MediaRecorder", + expectedAmplitude, + actualAmplitude + ) + verify(mockRecorder).maxAmplitude + } + + @Test + fun testStartDeletesExistingFile() { + // Create an existing file with content + testFile.createNewFile() + testFile.writeText("old content") + assertTrue("File should exist before start", testFile.exists()) + + soundRecorder.start() + + // Verify that the file was deleted by checking: + // In our test with mock, the file should NOT exist after delete() was called + assertFalse("File should have been deleted by start()", testFile.exists()) + + // Verify setOutputFile was called (MediaRecorder would recreate the file) + verify(mockRecorder).setOutputFile(testFile.absolutePath) + } + + @Test + fun testStartCreatesDirectoryIfNotExists() { + val fileInSubDir = File(tempFolder.root, "subdir/test.m4a") + val subDirectory = fileInSubDir.parentFile + assertFalse("Subdirectory should not exist initially", subDirectory?.exists() ?: true) + + val recorder = SoundRecorder(fileInSubDir.absolutePath, mockRecorder) + + recorder.start() + + assertTrue("Directory should be created", subDirectory?.exists() ?: false) + assertTrue("Recording should be active", recorder.isRecording) + verify(mockRecorder).setOutputFile(fileInSubDir.absolutePath) + + recorder.stop() + } + + @Test(expected = IOException::class) + fun testStartThrowsIOExceptionIfDirectoryCannotBeCreated() { + // Create a file (not directory) at the path where we need a directory + // Then try to create a subdirectory inside it - mkdirs() will fail + val blockingFile = File(tempFolder.root, "blocker") + blockingFile.createNewFile() // Create a FILE named "blocker" + assertTrue("Blocking file should exist", blockingFile.exists()) + assertTrue("Should be a file, not a directory", blockingFile.isFile) + + // Now try to create a recording in "blocker/subdir/test.m4a" + // When mkdirs() tries to create "blocker/subdir", it will fail because + // "blocker" is a file, not a directory + val invalidPath = File(File(blockingFile, "subdir"), "test.m4a").absolutePath + val invalidRecorder = SoundRecorder(invalidPath, mockRecorder) + invalidRecorder.start() // Should throw IOException: "Path to file could not be created." + } + + @Test + fun testStartCallsMediaRecorderMethodsInCorrectOrder() { + soundRecorder.start() + + val inOrder = org.mockito.Mockito.inOrder(mockRecorder) + inOrder.verify(mockRecorder).reset() + inOrder.verify(mockRecorder).setAudioSource(MediaRecorder.AudioSource.MIC) + inOrder.verify(mockRecorder).setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + inOrder.verify(mockRecorder).setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + inOrder.verify(mockRecorder).setOutputFile(testFile.absolutePath) + inOrder.verify(mockRecorder).prepare() + inOrder.verify(mockRecorder).start() + + assertTrue("isRecording should be true after start", soundRecorder.isRecording) + } + + @Test + fun testDoubleStartDoesNotThrow() { + soundRecorder.start() + assertTrue("Should be recording after first start", soundRecorder.isRecording) + + // Second start should not throw + soundRecorder.start() + + // Verify that MediaRecorder methods were called twice + verify(mockRecorder, times(2)).reset() + verify(mockRecorder, times(2)).setAudioSource(MediaRecorder.AudioSource.MIC) + verify(mockRecorder, times(2)).setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + verify(mockRecorder, times(2)).setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + verify(mockRecorder, times(2)).setOutputFile(testFile.absolutePath) + verify(mockRecorder, times(2)).prepare() + verify(mockRecorder, times(2)).start() + + assertTrue("Should still be recording after second start", soundRecorder.isRecording) + } + + @Test + fun testStopCallsMediaRecorderMethodsInCorrectOrder() { + soundRecorder.start() + assertTrue("Should be recording before stop", soundRecorder.isRecording) + clearInvocations(mockRecorder) + + soundRecorder.stop() + + val inOrder = org.mockito.Mockito.inOrder(mockRecorder) + inOrder.verify(mockRecorder).stop() + inOrder.verify(mockRecorder).reset() + inOrder.verify(mockRecorder).release() + + assertFalse("isRecording should be false after stop", soundRecorder.isRecording) + } + + @Test + fun testStopWithoutStartDoesNotThrow() { + // Should not propagate exception + soundRecorder.stop() + + // But should still cleanup + verify(mockRecorder).reset() + verify(mockRecorder).release() + assertFalse("isRecording should be false after stop", soundRecorder.isRecording) + } + + @Test + fun testStopHandlesRuntimeExceptionGracefully() { + soundRecorder.start() + clearInvocations(mockRecorder) + + org.mockito.Mockito.doThrow(RuntimeException("No valid audio data")) + .`when`(mockRecorder).stop() + + // Should not propagate exception + soundRecorder.stop() + + // But should still cleanup + verify(mockRecorder).reset() + verify(mockRecorder).release() + assertFalse("isRecording should be false after stop", soundRecorder.isRecording) + } + + @Test + fun testDoubleStopDoesNotThrow() { + soundRecorder.start() + clearInvocations(mockRecorder) + soundRecorder.stop() + + assertFalse("Should not be recording after stop", soundRecorder.isRecording) + + // Second stop should not throw + soundRecorder.stop() + + // Verify methods were called twice + verify(mockRecorder, times(2)).stop() + verify(mockRecorder, times(2)).reset() + verify(mockRecorder, times(2)).release() + + assertFalse("Should still not be recording after second stop", soundRecorder.isRecording) + } +} +