diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7a589bbc..3a24d24c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "com.dimowner.audiorecorder" minSdk = 26 targetSdk = 37 - versionCode = 949 - versionName = "2.4.0" + versionCode = 951 + versionName = "2.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/audio/AacCodecRecorderInstrumentedTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/audio/AacCodecRecorderInstrumentedTest.kt new file mode 100644 index 00000000..185f7e63 --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/audio/AacCodecRecorderInstrumentedTest.kt @@ -0,0 +1,309 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.audio + +import android.Manifest +import android.content.pm.PackageManager +import android.media.MediaExtractor +import android.media.MediaFormat +import android.media.MediaRecorder +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.dimowner.audiorecorder.audio.AudioDecoder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.RandomAccessFile +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Proves that [AacCodecRecorderV2] records at the bitrate it is asked for. + * + * `MediaRecorder` cannot: `StagefrightRecorder` clips the request to the AAC `maxBitRate` of the + * device media profile, which is still 96000 on many devices, so [recordsAtRequestedBitRate] + * fails on the old pipeline and passes on this one. + * + * Runs on a device or emulator: `./gradlew connectedDebugConfigDebugAndroidTest`. On MIUI the + * install needs Developer options -> "Install via USB" and "USB debugging (Security settings)". + */ +@RunWith(AndroidJUnit4::class) +class AacCodecRecorderInstrumentedTest { + + private lateinit var scope: CoroutineScope + private lateinit var recorder: AacCodecRecorderV2 + private lateinit var outputFile: File + private lateinit var events: CopyOnWriteArrayList + + private var startedLatch = CountDownLatch(1) + private var finishedLatch = CountDownLatch(1) + + @Before + fun setup() { + assumeTrue("RECORD_AUDIO is not granted", grantMicPermission()) + val context = InstrumentationRegistry.getInstrumentation().targetContext + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + recorder = AacCodecRecorderV2(scope) + outputFile = File(context.cacheDir, "codec-recorder-test.m4a").apply { + delete() + createNewFile() + } + events = CopyOnWriteArrayList() + startedLatch = CountDownLatch(1) + finishedLatch = CountDownLatch(1) + scope.launch { + recorder.subscribeRecorderEvents().collect { event -> + events.add(event) + when (event) { + is RecorderEvent.OnStartRecording -> startedLatch.countDown() + is RecorderEvent.OnStopRecording, is RecorderEvent.OnMaxDurationReached -> + finishedLatch.countDown() + else -> Unit + } + } + } + } + + @After + fun tearDown() { + if (::recorder.isInitialized && recorder.isRecording) recorder.stopRecording() + if (::scope.isInitialized) scope.cancel() + if (::outputFile.isInitialized) outputFile.delete() + } + + // ------------------------------------------------------------------------- + // The point of the whole exercise + // ------------------------------------------------------------------------- + + @Test + fun recordsAtRequestedBitRate() { + startRecording(sampleRate = 48000, channelCount = 2, bitrate = 192_000) + awaitStart() + Thread.sleep(RECORDING_MILLS) + stopAndAwait() + + val measured = measureBitRate(outputFile) + assertTrue( + "recorded at $measured bps, still clipped to the media profile cap", + measured > 130_000 + ) + assertEquals(192_000.0, measured, 192_000 * 0.10) + + val format = trackFormat(outputFile) + assertEquals(48000, format.getInteger(MediaFormat.KEY_SAMPLE_RATE)) + assertEquals(2, format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)) + + // The app's own reader has to agree, since that is what ends up in the database. + val info = AudioDecoder.readRecordInfo(outputFile) + assertEquals(192_000, info.bitrate) + assertEquals(RECORDING_MILLS.toDouble(), (info.duration / 1000).toDouble(), 1500.0) + } + + @Test + fun clampsBitratesTheFormatCannotCarryInsteadOfFailing() { + // 8 kHz mono tops out at 6 * 8000 = 48 kbps whatever the encoder is asked for; the + // recording must still succeed rather than leaving the user without a file. + startRecording(sampleRate = 8000, channelCount = 1, bitrate = 288_000) + awaitStart() + Thread.sleep(RECORDING_MILLS) + stopAndAwait() + + assertTrue(outputFile.length() > 0) + val measured = measureBitRate(outputFile) + assertTrue("recorded at $measured bps, above the AAC-LC ceiling", measured < 60_000) + assertNoErrors() + } + + // ------------------------------------------------------------------------- + // Behaviour the service depends on + // ------------------------------------------------------------------------- + + @Test + fun pauseResumeProducesAGaplessTimeline() { + startRecording(sampleRate = 44100, channelCount = 1, bitrate = 128_000) + awaitStart() + Thread.sleep(2000) + recorder.pauseRecording() + Thread.sleep(3000) + recorder.resumeRecording() + Thread.sleep(2000) + stopAndAwait() + + // Only the ~4 s actually captured may appear, not the 3 s spent paused. + val durationMills = trackFormat(outputFile).getLong(MediaFormat.KEY_DURATION) / 1000 + assertEquals(4000.0, durationMills.toDouble(), 1000.0) + + var previous = -1L + var maxGap = 0L + forEachSampleTime(outputFile) { sampleTime -> + assertTrue("timestamps must increase", sampleTime > previous) + if (previous >= 0) maxGap = maxOf(maxGap, sampleTime - previous) + previous = sampleTime + } + assertTrue("gap of ${maxGap}us in the encoded timeline", maxGap < 30_000) + } + + @Test + fun maxDurationStopsAndTheRecorderIsImmediatelyRestartable() { + recorder.startRecording(outputFile, 1, 44100, 128_000, 3000, AudioInput.Mic(MediaRecorder.AudioSource.MIC)) + awaitStart() + assertTrue("max duration was not reached", finishedLatch.await(15, TimeUnit.SECONDS)) + assertTrue(events.any { it is RecorderEvent.OnMaxDurationReached }) + + // AudioRecordingService starts the next part right here, so nothing may still be held. + val nextFile = File(outputFile.parentFile, "codec-recorder-test-2.m4a").apply { + delete() + createNewFile() + } + try { + assertTrue( + recorder.startRecording(nextFile, 1, 44100, 128_000, 0, AudioInput.Mic(MediaRecorder.AudioSource.MIC)) + ) + Thread.sleep(1000) + recorder.stopRecording() + Thread.sleep(1000) + assertTrue(nextFile.length() > 0) + } finally { + nextFile.delete() + } + } + + @Test + fun tagsSurviveMuxerOutput() { + startRecording(sampleRate = 44100, channelCount = 1, bitrate = 128_000) + awaitStart() + Thread.sleep(2000) + stopAndAwait() + + outputFile.writeTags("Test record", "Audio Recorder") + + assertEquals("Audio Recorder", outputFile.readAuthorName()) + // The file has to stay decodable after the tag writer rewrote the container. + assertTrue(AudioDecoder.readRecordInfo(outputFile).duration > 0) + } + + @Test + fun anInterruptedRecordingIsRestorable() { + startRecording(sampleRate = 44100, channelCount = 1, bitrate = 128_000) + awaitStart() + Thread.sleep(3000) + + // Copy the file mid-recording: that copy has no moov, exactly like a file left behind by + // a process death. + val broken = File(outputFile.parentFile, "codec-recorder-broken.m4a") + outputFile.copyTo(broken, overwrite = true) + stopAndAwait() + try { + RandomAccessFile(broken, "rw").use { it.setLength(broken.length()) } + val result = BrokenRecordRestorer().restoreFile(broken.absolutePath, 44100, 1, 128_000) + assertTrue("restore failed: $result", result !is BrokenRecordRestorer.RestoreResult.Failed) + } finally { + broken.delete() + File(broken.parentFile, "codec-recorder-broken_restored.m4a").delete() + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private fun startRecording(sampleRate: Int, channelCount: Int, bitrate: Int) { + assertTrue( + "recorder did not start", + recorder.startRecording( + outputFile, channelCount, sampleRate, bitrate, 0, AudioInput.Mic(MediaRecorder.AudioSource.MIC) + ) + ) + } + + private fun awaitStart() { + assertTrue("no audio was encoded", startedLatch.await(5, TimeUnit.SECONDS)) + } + + private fun stopAndAwait() { + recorder.stopRecording() + assertTrue("recording did not finish", finishedLatch.await(10, TimeUnit.SECONDS)) + } + + private fun assertNoErrors() { + val errors = events.filterIsInstance() + assertTrue("unexpected errors: $errors", errors.isEmpty()) + } + + private fun trackFormat(file: File): MediaFormat { + val extractor = MediaExtractor() + try { + extractor.setDataSource(file.absolutePath) + assertTrue("no track in the output file", extractor.trackCount > 0) + return extractor.getTrackFormat(0) + } finally { + extractor.release() + } + } + + /** Bitrate as an outside observer sees it: bytes on disk over the container duration. */ + private fun measureBitRate(file: File): Double { + val durationUs = trackFormat(file).getLong(MediaFormat.KEY_DURATION) + assertTrue("the file has no duration", durationUs > 0) + return file.length() * 8_000_000.0 / durationUs + } + + private fun forEachSampleTime(file: File, block: (Long) -> Unit) { + val extractor = MediaExtractor() + try { + extractor.setDataSource(file.absolutePath) + extractor.selectTrack(0) + while (extractor.sampleTime >= 0) { + block(extractor.sampleTime) + if (!extractor.advance()) break + } + } finally { + extractor.release() + } + } + + private fun grantMicPermission(): Boolean { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { + return true + } + runCatching { + instrumentation.uiAutomation.grantRuntimePermission( + context.packageName, Manifest.permission.RECORD_AUDIO + ) + } + return context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + } + + private companion object { + const val RECORDING_MILLS = 10_000L + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 00817f0a..d3c49058 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,7 +6,6 @@ - + + + + android:foregroundServiceType="microphone|mediaProjection" /> - diff --git a/app/src/main/java/com/dimowner/audiorecorder/IntArrayList.java b/app/src/main/java/com/dimowner/audiorecorder/IntArrayList.java index 34042778..17f4f5b9 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/IntArrayList.java +++ b/app/src/main/java/com/dimowner/audiorecorder/IntArrayList.java @@ -18,13 +18,14 @@ public class IntArrayList { - private int[] data = new int[100]; + private static final int INITIAL_CAPACITY = 100; + + private int[] data = new int[INITIAL_CAPACITY]; private int size = 0; public void add(int val) { if (data.length == size) { grow(); - add(val); } data[size] = val; size++; @@ -43,7 +44,13 @@ public int[] getData() { } public void clear() { - data = new int[100]; + // Keep the backing array so the recorders, which clear this buffer on every progress + // tick (50 times a second), don't allocate a fresh array each time - over a multi-hour + // recording that alone is hundreds of thousands of throwaway arrays. + // Only an array that grew unusually large is released. + if (data.length > INITIAL_CAPACITY * 16) { + data = new int[INITIAL_CAPACITY]; + } size = 0; } @@ -54,8 +61,6 @@ public int size() { private void grow() { int[] backup = data; data = new int[data.length * 2]; - for (int i = 0; i < backup.length; i++) { - data[i] = backup[i]; - } + System.arraycopy(backup, 0, data, 0, backup.length); } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/RecordingWidget.kt b/app/src/main/java/com/dimowner/audiorecorder/RecordingWidget.kt index 0340264c..32892a2f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/RecordingWidget.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/RecordingWidget.kt @@ -4,9 +4,9 @@ import android.annotation.SuppressLint import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.widget.RemoteViews import com.dimowner.audiorecorder.app.TransparentRecordingActivity @@ -44,16 +44,29 @@ internal fun updateAppWidget( appWidgetManager.updateAppWidget(appWidgetId, views) } +/** + * The tap starts [TransparentRecordingActivity] directly, and must not be routed through a + * BroadcastReceiver that starts the activity itself. The permission to start an activity from the + * background travels with this PendingIntent, granted because the launcher sending it is visible, + * and it does not survive that extra hop: the receiver's own start carries no such grant and the + * system blocks it ("Activity start request from stopped") for every tap outside the short + * grace period that follows the app being in the foreground. That is why only the first tap after + * using the app started a recording. + * + * The flags keep the activity out of the app's own task. Without them it lands on top of the task + * the app is already using, which brings whatever screen was last open - Settings, for example - + * to the front instead of leaving the user where they were; [FLAG_ACTIVITY_CLEAR_TASK] makes each + * tap start a fresh instance, so one left waiting on a permission dialog cannot swallow the next + * tap. The matching empty taskAffinity is declared in the manifest. + */ @SuppressLint("WrongConstant") private fun getRecordingPendingIntent(context: Context): PendingIntent { - val intent = Intent(context, WidgetReceiver::class.java) - return PendingIntent.getBroadcast(context, 11, intent, AppConstants.PENDING_INTENT_FLAGS) -} - -class WidgetReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val activityIntent = Intent(context, TransparentRecordingActivity::class.java) - activityIntent.flags = FLAG_ACTIVITY_NEW_TASK - context.startActivity(activityIntent) - } + val intent = Intent(context, TransparentRecordingActivity::class.java) + intent.flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK + return PendingIntent.getActivity( + context, + 11, + intent, + AppConstants.PENDING_INTENT_FLAGS or PendingIntent.FLAG_UPDATE_CURRENT + ) } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/TransparentRecordingActivity.kt b/app/src/main/java/com/dimowner/audiorecorder/app/TransparentRecordingActivity.kt index ceb284bd..10a2ad20 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/TransparentRecordingActivity.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/app/TransparentRecordingActivity.kt @@ -18,8 +18,11 @@ package com.dimowner.audiorecorder.app import android.Manifest import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.media.projection.MediaProjectionManager import android.os.Build import android.os.Bundle import android.widget.Toast @@ -32,10 +35,14 @@ import com.dimowner.audiorecorder.exception.CantCreateFileException import com.dimowner.audiorecorder.exception.ErrorParser import com.dimowner.audiorecorder.util.AndroidUtils import com.dimowner.audiorecorder.v2.audio.AudioRecordingService +import com.dimowner.audiorecorder.v2.data.model.isSystemAudioCaptureSupported +import com.dimowner.audiorecorder.v2.di.RecordingSettingsEntryPoint +import dagger.hilt.android.EntryPointAccessors import timber.log.Timber const val REQ_CODE_RECORD_AUDIO = 303 const val REQ_CODE_WRITE_EXTERNAL_STORAGE = 404 +private const val REQ_CODE_MEDIA_PROJECTION = 505 class TransparentRecordingActivity : Activity() { @@ -44,6 +51,13 @@ class TransparentRecordingActivity : Activity() { private var recordingRequested = false + // Consent for capturing system audio, collected here because only an Activity can raise the + // dialog and this is the widget/shortcut entry point into recording. + private var projectionRequested = false + private var projectionDenied = false + private var projectionResultCode = RESULT_CANCELED + private var projectionData: Intent? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) prefs = ARApplication.injector.providePrefs(applicationContext) @@ -61,11 +75,74 @@ class TransparentRecordingActivity : Activity() { if (recordingRequested) return if (!checkRecordPermission2()) return if (!prefs.isAppV2 && !checkStoragePermission2()) return + if (projectionDenied) { + Toast.makeText( + applicationContext, R.string.msg_permission_system_audio_denied, Toast.LENGTH_LONG + ).show() + finish() + return + } + // The consent dialog returns through onActivityResult, which runs before this method is + // called again; the recording starts on that second pass. + if (projectionData == null && needsSystemAudioConsent()) { + if (!projectionRequested) { + projectionRequested = true + requestMediaProjectionConsent() + } + return + } recordingRequested = true startRecordingService() finish() } + /** Whether the next V2 recording captures system audio and therefore needs consent. */ + private fun needsSystemAudioConsent(): Boolean { + if (!prefs.isAppV2) return false + return try { + val entryPoint = EntryPointAccessors.fromApplication( + applicationContext, RecordingSettingsEntryPoint::class.java + ) + entryPoint.prefsV2().settingAudioSource.isSystemAudio && isSystemAudioCaptureSupported() + } catch (e: IllegalStateException) { + Timber.e(e, "Failed to read the recording settings") + false + } + } + + /** + * Raises the system-audio consent dialog. A failure here is treated as a denial rather than + * silently recording the microphone, which is not what the user selected. + */ + private fun requestMediaProjectionConsent() { + val manager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as? MediaProjectionManager + if (manager == null) { + Timber.e("MediaProjectionManager is unavailable") + projectionDenied = true + return + } + try { + startActivityForResult(manager.createScreenCaptureIntent(), REQ_CODE_MEDIA_PROJECTION) + } catch (e: ActivityNotFoundException) { + Timber.e(e, "No activity handles the screen capture request") + projectionDenied = true + } + } + + @Deprecated("Kept because this Activity is not a ComponentActivity and has no result registry") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + @Suppress("DEPRECATION") + super.onActivityResult(requestCode, resultCode, data) + if (requestCode != REQ_CODE_MEDIA_PROJECTION) return + // Recording is started from onResume(), which runs right after this callback. + if (resultCode == RESULT_OK && data != null) { + projectionResultCode = resultCode + projectionData = data + } else { + projectionDenied = true + } + } + private fun startRecordingService() { try { if (prefs.isAppV2) { @@ -83,7 +160,9 @@ class TransparentRecordingActivity : Activity() { } private fun startRecordingServiceV2() { - AudioRecordingService.startServiceForeground(applicationContext) + AudioRecordingService.startServiceForeground( + applicationContext, projectionResultCode, projectionData + ) } private fun startLegacyRecordingService() { diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/player/ExoAudioPlayer.kt b/app/src/main/java/com/dimowner/audiorecorder/audio/player/ExoAudioPlayer.kt index 9a37069b..3c0b4c86 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/player/ExoAudioPlayer.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/player/ExoAudioPlayer.kt @@ -19,6 +19,7 @@ import android.content.Context import android.net.Uri import android.os.Handler import android.os.Looper +import android.os.SystemClock import androidx.core.net.toUri import androidx.media3.common.AudioAttributes import androidx.media3.common.C @@ -31,10 +32,21 @@ import com.dimowner.audiorecorder.AppConstants import com.dimowner.audiorecorder.exception.AppException import com.dimowner.audiorecorder.exception.PlayerDataSourceException import com.dimowner.audiorecorder.exception.PlayerInitException +import com.dimowner.audiorecorder.v2.analytics.ANALYTICS_VALUE_NONE +import com.dimowner.audiorecorder.v2.analytics.ANALYTICS_VALUE_UNKNOWN_NUMBER +import com.dimowner.audiorecorder.v2.analytics.AnalyticsTracker +import com.dimowner.audiorecorder.v2.analytics.PlaybackStartFailure +import com.dimowner.audiorecorder.v2.analytics.PlaybackStartFailureReason import timber.log.Timber import java.io.File import java.util.concurrent.CopyOnWriteArrayList +/** Uri scheme of a record stored as a plain file path. */ +private const val SCHEME_FILE = "file" + +/** Longest string still treated as a file extension when reporting a failed playback. */ +private const val MAX_EXTENSION_LENGTH = 5 + /** * [PlayerContractNew.Player] backed by ExoPlayer. * @@ -47,7 +59,10 @@ import java.util.concurrent.CopyOnWriteArrayList * getters expose is mirrored into volatile fields to keep them callable from any thread too. */ @UnstableApi -class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { +class ExoAudioPlayer( + context: Context, + private val analyticsTracker: AnalyticsTracker, +) : PlayerContractNew.Player { private val actionsListeners = CopyOnWriteArrayList() @@ -65,9 +80,23 @@ class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { private var prevPosMills: Long = 0 + /** + * Anchor used to interpolate the playback position between ExoPlayer readings, see + * [currentPositionMills]. [lastRawPosMills] is [C.TIME_UNSET] while there is no anchor yet. + */ + private var lastRawPosMills: Long = C.TIME_UNSET + private var anchorPosMills: Long = 0 + private var anchorRealtimeMills: Long = 0 + /** True between [play] and the moment ExoPlayer reports the source as ready. */ private var isPreparing = false + /** + * Source of the track that is being prepared, kept so a failed start can be reported with the + * details of the file it failed on. Player-thread confined, like the rest of the state above. + */ + private var preparingPath: String? = null + private var isReleased = false private val playerListener = object : Player.Listener { @@ -75,8 +104,10 @@ class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { when (playbackState) { Player.STATE_READY -> if (isPreparing) { isPreparing = false + preparingPath = null pauseTimeMills = 0 prevPosMills = 0 + resetPositionInterpolation() playerState = PlayerState.PLAYING onStartPlay() schedulePlaybackTimeUpdate() @@ -88,20 +119,37 @@ class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { override fun onPlayerError(error: PlaybackException) { Timber.e(error, "ExoPlayer playback error") + val failedToStart = isPreparing + val failedPath = preparingPath isPreparing = false + preparingPath = null stopPlaybackTimeUpdate() playerState = PlayerState.STOPPED pauseTimeMills = 0 prevPosMills = 0 - onError( - if (error.errorCode == PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND || - error.errorCode == PlaybackException.ERROR_CODE_IO_UNSPECIFIED - ) { - PlayerDataSourceException() - } else { - PlayerInitException() - } - ) + resetPositionInterpolation() + val exception = if ( + error.errorCode == PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND || + error.errorCode == PlaybackException.ERROR_CODE_IO_UNSPECIFIED + ) { + PlayerDataSourceException() + } else { + PlayerInitException() + } + // An error raised while the track is already playing is a different problem, and is + // deliberately left out of the start-failure funnel. + if (failedToStart) { + trackStartFailure( + reason = PlaybackStartFailureReason.fromException(exception), + path = failedPath, + // The PlaybackException is what carries the stack trace and the real cause; + // the app level exception above is only a marker for the UI. + error = error, + playerErrorCode = error.errorCode, + playerErrorName = error.errorCodeName, + ) + } + onError(exception) } } @@ -134,13 +182,20 @@ class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { if (playerState == PlayerState.PLAYING) return@runOnPlayerThread val uri = filePath.toPlayableUri() if (uri == null) { - onError(PlayerDataSourceException()) + val exception = PlayerDataSourceException() + trackStartFailure( + reason = PlaybackStartFailureReason.DATA_SOURCE, + path = filePath, + error = exception, + ) + onError(exception) return@runOnPlayerThread } try { stopPlaybackTimeUpdate() playerState = PlayerState.STOPPED isPreparing = true + preparingPath = filePath exoPlayer.setMediaItem(MediaItem.fromUri(uri), pauseTimeMills) exoPlayer.setPlaybackSpeed(playbackSpeed) exoPlayer.playWhenReady = true @@ -148,6 +203,12 @@ class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { } catch (e: IllegalStateException) { Timber.e(e, "Player is not initialized!") isPreparing = false + preparingPath = null + trackStartFailure( + reason = PlaybackStartFailureReason.PLAYER_INIT, + path = filePath, + error = e, + ) onError(PlayerInitException()) } } @@ -157,6 +218,7 @@ class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { runOnPlayerThread { pauseTimeMills = mills prevPosMills = 0 + resetPositionInterpolation() if (playerState == PlayerState.PLAYING || playerState == PlayerState.PAUSED) { exoPlayer.seekTo(mills) onSeek(mills) @@ -168,9 +230,14 @@ class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { runOnPlayerThread { stopPlaybackTimeUpdate() if (playerState == PlayerState.PLAYING) { + // Sampled before pausing and taken no lower than the last reported progress: + // ExoPlayer's own position lags behind what the UI has already shown (see + // [currentPositionMills]), so resuming from it would visibly rewind the waveform. + val positionMills = maxOf(currentPositionMills(), prevPosMills) exoPlayer.pause() - pauseTimeMills = exoPlayer.currentPosition + pauseTimeMills = positionMills prevPosMills = 0 + resetPositionInterpolation() playerState = PlayerState.PAUSED onPausePlay() } @@ -183,6 +250,7 @@ class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { exoPlayer.seekTo(pauseTimeMills) exoPlayer.setPlaybackSpeed(playbackSpeed) exoPlayer.play() + resetPositionInterpolation() pauseTimeMills = 0 playerState = PlayerState.PLAYING onStartPlay() @@ -195,11 +263,13 @@ class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { runOnPlayerThread { stopPlaybackTimeUpdate() isPreparing = false + preparingPath = null exoPlayer.stop() exoPlayer.clearMediaItems() playerState = PlayerState.STOPPED pauseTimeMills = 0 prevPosMills = 0 + resetPositionInterpolation() onStopPlay() } } @@ -209,11 +279,13 @@ class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { if (isReleased) return@runOnPlayerThread stopPlaybackTimeUpdate() isPreparing = false + preparingPath = null exoPlayer.stop() exoPlayer.clearMediaItems() playerState = PlayerState.STOPPED pauseTimeMills = 0 prevPosMills = 0 + resetPositionInterpolation() onStopPlay() exoPlayer.removeListener(playerListener) exoPlayer.release() @@ -250,10 +322,98 @@ class ExoAudioPlayer(context: Context) : PlayerContractNew.Player { return if (uri.scheme == null) Uri.fromFile(File(this)) else uri } + /** + * Reports a playback that never started, together with what can be told about its source. + * + * The file is stat-ed here, on the player (main) thread. That is a single stat on an error + * path that ends in a snackbar anyway, and it answers the first question these reports raise: + * whether the record file was there at all, and whether it was empty - the signature of a + * recording that was interrupted before anything was written to it. + */ + private fun trackStartFailure( + reason: PlaybackStartFailureReason, + path: String?, + error: Throwable?, + playerErrorCode: Int = ANALYTICS_VALUE_UNKNOWN_NUMBER.toInt(), + playerErrorName: String = ANALYTICS_VALUE_NONE, + ) { + val uri = path?.toPlayableUri() + val file = if (uri?.scheme == SCHEME_FILE) uri.path?.let(::File) else null + val exists = file?.exists() + analyticsTracker.trackPlaybackStartFailed( + PlaybackStartFailure( + reason = reason, + format = path.playbackFormat(), + uriScheme = uri?.scheme ?: ANALYTICS_VALUE_NONE, + fileExists = exists, + fileSizeBytes = if (exists == true) file.length() else ANALYTICS_VALUE_UNKNOWN_NUMBER, + playerErrorCode = playerErrorCode, + playerErrorName = playerErrorName, + error = error, + ) + ) + } + + /** + * Lowercase extension of the source, so failures can be told apart per format. Anything that + * does not look like an extension (a name with a dot in it, no dot at all) is not reported. + */ + private fun String?.playbackFormat(): String { + val extension = this?.substringAfterLast('/')?.substringAfterLast('.', "").orEmpty().lowercase() + return if (extension.isNotEmpty() && extension.length <= MAX_EXTENSION_LENGTH) { + extension + } else { + ANALYTICS_VALUE_NONE + } + } + + /** + * Playback position to report to the UI, interpolated with the wall clock. + * + * [ExoPlayer.getCurrentPosition] returns `PlaybackInfo.positionUs` exactly as the playback + * thread last published it to the application thread; media3 only extrapolates it with the + * elapsed real time in the audio offload path (`PlaybackInfo.getEstimatedPositionUs`). Polling + * it therefore returns the same number for a few hundred milliseconds and then jumps: measured + * on device the value changed roughly every 310 ms (in ~253 ms + ~60 ms steps) even though this + * task runs every [AppConstants.PLAYBACK_VISUALIZATION_INTERVAL] ms. That is what made the + * waveform and the timer stutter. + * + * So every reading that actually differs from the previous one becomes an anchor, and between + * anchors the position is advanced from it by the elapsed real time scaled by the playback + * speed - which is what the audio is doing anyway. Interpolation only runs while the player is + * really rendering; while it is buffering or suppressed the raw value is reported as is, so the + * reported position can never run away from the audio that is being heard. + */ + private fun currentPositionMills(): Long { + val raw = exoPlayer.currentPosition + val now = SystemClock.elapsedRealtime() + val isRendering = exoPlayer.isPlaying + if (raw != lastRawPosMills || !isRendering) { + lastRawPosMills = raw + anchorPosMills = raw + anchorRealtimeMills = now + } + if (!isRendering) return raw + val interpolated = anchorPosMills + ((now - anchorRealtimeMills) * playbackSpeed).toLong() + val durationMills = exoPlayer.duration + return if (durationMills == C.TIME_UNSET) { + interpolated + } else { + interpolated.coerceAtMost(durationMills) + } + } + + /** Drops the anchor, so the next tick re-reads the position instead of extrapolating a stale one. */ + private fun resetPositionInterpolation() { + lastRawPosMills = C.TIME_UNSET + anchorPosMills = 0 + anchorRealtimeMills = 0 + } + private val playbackTimeUpdateTask = object : Runnable { override fun run() { if (playerState != PlayerState.PLAYING) return - var pos = exoPlayer.currentPosition + var pos = currentPositionMills() if (pos < prevPosMills) { pos = prevPosMills } else { diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java index f85c4de6..55581969 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java @@ -95,8 +95,15 @@ public void startRecording(@NonNull String outputFile, int channelCount, int sam recorderCallback.onStartRecord(recordFile); } isPaused.set(false); - } catch (IOException | IllegalStateException e) { - Timber.e(e, "prepare() failed"); + } catch (IOException | RuntimeException e) { + // start() reports a microphone held by another app, or a configuration the + // codec rejects, as a plain RuntimeException("start failed.") rather than as + // IllegalStateException. Catching only the subclass let that one reach the + // main looper this runs on and killed the app. The recorder is released here + // as well: a half-started instance keeps holding the microphone and would + // make every following startRecording() fail the same way. + Timber.e(e, "prepare() or start() failed"); + releaseRecorder(); if (recorderCallback != null) { recorderCallback.onError(new RecorderInitException()); } @@ -178,17 +185,43 @@ public void stopRecording() { } } + /** + * Releases a recorder that never reached the started state, so it stops holding the + * microphone, and resets the state a failed start left behind. + */ + private void releaseRecorder() { + if (recorder != null) { + try { + recorder.release(); + } catch (RuntimeException e) { + Timber.e(e, "release() failed"); + } + recorder = null; + } + stopRecordingTimer(); + isRecording.set(false); + isPaused.set(false); + } + private void scheduleRecordingTimeUpdate() { handler.postDelayed(() -> { if (recorderCallback != null && recorder != null) { + long curTime = System.currentTimeMillis(); + durationMills += curTime - updateTime; + updateTime = curTime; + // getMaxAmplitude() throws a plain RuntimeException("getMaxAmplitude failed.") + // once the recorder has been released or the media server dies - not an + // IllegalStateException. This tick runs on the main looper, so an uncaught one + // crashes the app. Give up on the loop instead; the next start or resume + // reschedules it. + int amplitude; try { - long curTime = System.currentTimeMillis(); - durationMills += curTime - updateTime; - updateTime = curTime; - recorderCallback.onRecordProgress(durationMills, recorder.getMaxAmplitude()); - } catch (IllegalStateException e) { - Timber.e(e); + amplitude = recorder.getMaxAmplitude(); + } catch (RuntimeException e) { + Timber.e(e, "Error reading amplitude, stopping progress updates"); + return; } + recorderCallback.onRecordProgress(durationMills, amplitude); scheduleRecordingTimeUpdate(); } }, RECORDING_VISUALIZATION_INTERVAL); diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java index 66d0a047..990b00a3 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java @@ -96,8 +96,15 @@ public void startRecording(@NonNull String outputFile, int channelCount, int sam recorderCallback.onStartRecord(recordFile); } isPaused.set(false); - } catch (IOException | IllegalStateException e) { - Timber.e(e, "prepare() failed"); + } catch (IOException | RuntimeException e) { + // start() reports a microphone held by another app, or a configuration the + // codec rejects, as a plain RuntimeException("start failed.") rather than as + // IllegalStateException. Catching only the subclass let that one reach the + // main looper this runs on and killed the app. The recorder is released here + // as well: a half-started instance keeps holding the microphone and would + // make every following startRecording() fail the same way. + Timber.e(e, "prepare() or start() failed"); + releaseRecorder(); if (recorderCallback != null) { recorderCallback.onError(new RecorderInitException()); } @@ -179,17 +186,43 @@ public void stopRecording() { } } + /** + * Releases a recorder that never reached the started state, so it stops holding the + * microphone, and resets the state a failed start left behind. + */ + private void releaseRecorder() { + if (recorder != null) { + try { + recorder.release(); + } catch (RuntimeException e) { + Timber.e(e, "release() failed"); + } + recorder = null; + } + stopRecordingTimer(); + isRecording.set(false); + isPaused.set(false); + } + private void scheduleRecordingTimeUpdate() { handler.postDelayed(() -> { if (recorderCallback != null && recorder != null) { + long curTime = System.currentTimeMillis(); + durationMills += curTime - updateTime; + updateTime = curTime; + // getMaxAmplitude() throws a plain RuntimeException("getMaxAmplitude failed.") + // once the recorder has been released or the media server dies - not an + // IllegalStateException. This tick runs on the main looper, so an uncaught one + // crashes the app. Give up on the loop instead; the next start or resume + // reschedules it. + int amplitude; try { - long curTime = System.currentTimeMillis(); - durationMills += curTime - updateTime; - updateTime = curTime; - recorderCallback.onRecordProgress(durationMills, recorder.getMaxAmplitude()); - } catch (IllegalStateException e) { - Timber.e(e); + amplitude = recorder.getMaxAmplitude(); + } catch (RuntimeException e) { + Timber.e(e, "Error reading amplitude, stopping progress updates"); + return; } + recorderCallback.onRecordProgress(durationMills, amplitude); scheduleRecordingTimeUpdate(); } }, RECORDING_VISUALIZATION_INTERVAL); diff --git a/app/src/main/java/com/dimowner/audiorecorder/exception/RecordingStopFailedException.kt b/app/src/main/java/com/dimowner/audiorecorder/exception/RecordingStopFailedException.kt new file mode 100644 index 00000000..aebb1e00 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/exception/RecordingStopFailedException.kt @@ -0,0 +1,14 @@ +package com.dimowner.audiorecorder.exception + +/** + * The recorder captured audio but failed while finalising the output file, so the container on + * disk is missing the index that makes it playable. + * + * Reported as a [RECORDING_ERROR] like any other failure mid recording, but kept as its own type + * because the file is worth recovering: it holds everything that was recorded up to the failure. + */ +class RecordingStopFailedException: AppException() { + override fun getType(): Int { + return RECORDING_ERROR + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/AnalyticsTracker.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/AnalyticsTracker.kt index d35bafb2..f090faf4 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/AnalyticsTracker.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/AnalyticsTracker.kt @@ -105,4 +105,24 @@ interface AnalyticsTracker { * @param count Number of lost records discovered in this scan. */ fun trackLostRecordsDetected(count: Int) + + // ── Recording / playback start failures ────────────────────────────────── + + /** + * Fired when a recording could not be started: the recorder failed to initialise, the output + * file could not be created, or there was not enough free space left. It is *not* fired for + * errors raised after the recording has begun. + * + * @param failure The reason plus the recording settings and the exception behind the failure. + */ + fun trackRecordingStartFailed(failure: RecordingStartFailure) + + /** + * Fired when a playback could not be started: the record file is gone, empty or unreadable, + * or the player rejected it. It is *not* fired for errors raised mid-playback. + * + * @param failure The reason plus the details of the source and the exception behind the failure. + */ + fun trackPlaybackStartFailed(failure: PlaybackStartFailure) + } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/FailureAnalytics.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/FailureAnalytics.kt new file mode 100644 index 00000000..e6f540fd --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/FailureAnalytics.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.analytics + +import com.dimowner.audiorecorder.exception.AppException + +/** Value reported for a string detail that is not available in the failing scenario. */ +const val ANALYTICS_VALUE_NONE = "none" + +/** Value reported for a numeric detail that could not be determined. */ +const val ANALYTICS_VALUE_UNKNOWN_NUMBER = -1L + +/** + * Why a recording never started. + * + * [label] is the value sent to the analytics backend: keep the labels stable, renaming one breaks + * the history of the corresponding report in already collected data. + */ +enum class RecordingStartFailureReason(val label: String) { + + /** The platform recorder (MediaRecorder / AudioRecord / MediaCodec) could not be initialised. */ + RECORDER_INIT("recorder_init"), + + /** The output file disappeared or is not a regular file by the time the recorder opened it. */ + INVALID_OUTPUT_FILE("invalid_output_file"), + + /** The output file could not be created in the records directory. */ + CANT_CREATE_FILE("cant_create_file"), + + /** A start was requested while another recording was still running. */ + ALREADY_RECORDING("already_recording"), + + /** Not enough free space left to record for [com.dimowner.audiorecorder.AppConstants.MIN_REMAIN_RECORDING_TIME]. */ + NOT_ENOUGH_SPACE("not_enough_space"), + + /** The encoding pipeline failed before the first frame was written. */ + RECORDING_ERROR("recording_error"), + + /** Reading or writing the record was denied. */ + PERMISSION_DENIED("permission_denied"), + + /** An error that has no dedicated reason yet; check `error_class` on the report. */ + UNKNOWN("unknown"); + + companion object { + + /** Maps the [AppException] a recorder reported to the reason to send with the report. */ + fun fromException(exception: AppException?): RecordingStartFailureReason = + when (exception?.type) { + AppException.RECORDER_INIT_EXCEPTION -> RECORDER_INIT + AppException.INVALID_OUTPUT_FILE -> INVALID_OUTPUT_FILE + AppException.CANT_CREATE_FILE -> CANT_CREATE_FILE + AppException.ALREADY_RECORDING -> ALREADY_RECORDING + AppException.NO_SPACE_AVAILABLE -> NOT_ENOUGH_SPACE + AppException.RECORDING_ERROR -> RECORDING_ERROR + AppException.READ_PERMISSION_DENIED -> PERMISSION_DENIED + else -> UNKNOWN + } + } +} + +/** + * Everything worth knowing about a recording that failed to start, gathered at the moment of the + * failure. Reported by [AnalyticsTracker.trackRecordingStartFailed]. + * + * The settings fields describe what the recorder was asked to do, which is what makes these + * reports actionable: a failure that only ever shows up for one format / sample rate / audio + * source combination points straight at the codec configuration to fix. + * + * @param reason Why the start failed, see [RecordingStartFailureReason]. + * @param format Requested recording format (e.g. "m4a", "wav", "opus"). + * @param sampleRate Requested sample rate in Hz. + * @param bitrate Requested bitrate in bit/s, `0` for formats without one. + * @param channelCount Requested channel count (1 = mono, 2 = stereo). + * @param audioSource Requested capture source (a [com.dimowner.audiorecorder.v2.data.model.AudioSource] name). + * @param availableSpaceBytes Free space in the records directory, or [ANALYTICS_VALUE_UNKNOWN_NUMBER]. + * @param error Exception behind the failure, when there was one. Implementations + * should forward it to a crash reporter as a non-fatal, because the + * analytics event itself can only carry a truncated message. + */ +data class RecordingStartFailure( + val reason: RecordingStartFailureReason, + val format: String, + val sampleRate: Int, + val bitrate: Int, + val channelCount: Int, + val audioSource: String, + val availableSpaceBytes: Long = ANALYTICS_VALUE_UNKNOWN_NUMBER, + val error: Throwable? = null, +) { + /** Simple class name of [error], or [ANALYTICS_VALUE_NONE] when the failure carried no exception. */ + val errorClass: String get() = error.analyticsClassName() + + /** Message of [error], or [ANALYTICS_VALUE_NONE]. Implementations must truncate it if needed. */ + val errorMessage: String get() = error.analyticsMessage() +} + +/** + * Why a playback never started. + * + * [label] is the value sent to the analytics backend, see [RecordingStartFailureReason.label]. + */ +enum class PlaybackStartFailureReason(val label: String) { + + /** The file behind the record could not be read: missing, empty, unreadable or an invalid uri. */ + DATA_SOURCE("data_source"), + + /** The player rejected the source: unsupported container, no decoder, malformed content. */ + PLAYER_INIT("player_init"), + + /** An error that has no dedicated reason yet; check `error_class` on the report. */ + UNKNOWN("unknown"); + + companion object { + + /** Maps the [AppException] the player reported to the reason to send with the report. */ + fun fromException(exception: AppException?): PlaybackStartFailureReason = + when (exception?.type) { + AppException.PLAYER_DATA_SOURCE_EXCEPTION -> DATA_SOURCE + AppException.PLAYER_INIT_EXCEPTION -> PLAYER_INIT + else -> UNKNOWN + } + } +} + +/** + * Everything worth knowing about a playback that failed to start, gathered at the moment of the + * failure. Reported by [AnalyticsTracker.trackPlaybackStartFailed]. + * + * Only failures on the way *into* playback are reported here; an error raised while audio is + * already playing is a different problem and is not part of this funnel. + * + * @param reason Why the start failed, see [PlaybackStartFailureReason]. + * @param format Extension of the file that was about to be played (e.g. "m4a"), lowercase. + * @param uriScheme Scheme of the source: "file" for records stored by path, "content" for + * records in a SAF picked folder, [ANALYTICS_VALUE_NONE] when there is none. + * @param fileExists Whether the file was found on disk, `null` for sources that cannot be + * stat-ed cheaply (content uris). + * @param fileSizeBytes Size of the file on disk, or [ANALYTICS_VALUE_UNKNOWN_NUMBER]. A zero + * here is the signature of a recording that was interrupted before + * anything was written to it. + * @param playerErrorCode Player error code, or `-1` when the failure happened before the + * player itself was involved. + * @param playerErrorName Symbolic name of [playerErrorCode], or [ANALYTICS_VALUE_NONE]. + * @param error Exception behind the failure, when there was one. Implementations should + * forward it to a crash reporter as a non-fatal, see [RecordingStartFailure.error]. + */ +data class PlaybackStartFailure( + val reason: PlaybackStartFailureReason, + val format: String, + val uriScheme: String, + val fileExists: Boolean? = null, + val fileSizeBytes: Long = ANALYTICS_VALUE_UNKNOWN_NUMBER, + val playerErrorCode: Int = ANALYTICS_VALUE_UNKNOWN_NUMBER.toInt(), + val playerErrorName: String = ANALYTICS_VALUE_NONE, + val error: Throwable? = null, +) { + /** Simple class name of [error], or [ANALYTICS_VALUE_NONE] when the failure carried no exception. */ + val errorClass: String get() = error.analyticsClassName() + + /** Message of [error], or [ANALYTICS_VALUE_NONE]. Implementations must truncate it if needed. */ + val errorMessage: String get() = error.analyticsMessage() +} + +/** + * Class name to report for an exception. Anonymous classes have an empty simple name, hence the + * fallback to the full one. + */ +private fun Throwable?.analyticsClassName(): String { + val throwable = this ?: return ANALYTICS_VALUE_NONE + return throwable.javaClass.simpleName.ifEmpty { throwable.javaClass.name } +} + +/** + * Message to report for an exception. The cause is included because the exceptions the recorders + * and the player raise are app level wrappers whose own message is often empty. + */ +private fun Throwable?.analyticsMessage(): String { + val throwable = this ?: return ANALYTICS_VALUE_NONE + val message = throwable.message + val cause = throwable.cause + return when { + !message.isNullOrEmpty() && cause != null -> "$message; caused by ${cause.analyticsClassName()}: ${cause.message}" + !message.isNullOrEmpty() -> message + cause != null -> "${cause.analyticsClassName()}: ${cause.message}" + else -> ANALYTICS_VALUE_NONE + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/NoOpAnalyticsTracker.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/NoOpAnalyticsTracker.kt index 81c27a80..742e0559 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/NoOpAnalyticsTracker.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/NoOpAnalyticsTracker.kt @@ -44,4 +44,40 @@ class NoOpAnalyticsTracker @Inject constructor() : AnalyticsTracker { override fun trackLostRecordsDetected(count: Int) { Timber.v("NoOpAnalyticsTracker: trackLostRecordsDetected: count=$count") } + + override fun trackRecordingStartFailed(failure: RecordingStartFailure) { + Timber.v( + failure.error, + "NoOpAnalyticsTracker: trackRecordingStartFailed: reason=%s, format=%s, sampleRate=%d," + + " bitrate=%d, channelCount=%d, audioSource=%s, availableSpaceBytes=%d," + + " errorClass=%s, errorMessage=%s", + failure.reason.label, + failure.format, + failure.sampleRate, + failure.bitrate, + failure.channelCount, + failure.audioSource, + failure.availableSpaceBytes, + failure.errorClass, + failure.errorMessage, + ) + } + + override fun trackPlaybackStartFailed(failure: PlaybackStartFailure) { + Timber.v( + failure.error, + "NoOpAnalyticsTracker: trackPlaybackStartFailed: reason=%s, format=%s, uriScheme=%s," + + " fileExists=%s, fileSizeBytes=%d, playerErrorCode=%d, playerErrorName=%s," + + " errorClass=%s, errorMessage=%s", + failure.reason.label, + failure.format, + failure.uriScheme, + failure.fileExists, + failure.fileSizeBytes, + failure.playerErrorCode, + failure.playerErrorName, + failure.errorClass, + failure.errorMessage, + ) + } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/BluetoothAudioComponents.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/BluetoothAudioComponents.kt index 2e44c931..996184b9 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/BluetoothAudioComponents.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/BluetoothAudioComponents.kt @@ -329,6 +329,7 @@ private fun getAudioSourceDisplayName(audioSource: AudioSource): String { AudioSource.MIC -> stringResource(R.string.audio_source_mic) AudioSource.VOICE_COMMUNICATION -> stringResource(R.string.audio_source_voice_communication) AudioSource.UNPROCESSED -> stringResource(R.string.audio_source_unprocessed) + AudioSource.SYSTEM_AUDIO -> stringResource(R.string.audio_source_system) } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeScreen.kt index 5a47e640..98beff9b 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeScreen.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeScreen.kt @@ -17,8 +17,11 @@ package com.dimowner.audiorecorder.v2.app.home import android.Manifest +import android.content.Context import android.content.pm.PackageManager import android.net.Uri +import android.app.Activity +import android.media.projection.MediaProjectionManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image @@ -42,8 +45,6 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -63,7 +64,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -76,7 +76,6 @@ import com.dimowner.audiorecorder.util.TimeUtils import com.dimowner.audiorecorder.v2.app.ComposableLifecycle import com.dimowner.audiorecorder.v2.app.DeleteDialog import com.dimowner.audiorecorder.v2.app.EditDescriptionDialog -import com.dimowner.audiorecorder.v2.app.InfoAlertDialog import com.dimowner.audiorecorder.v2.app.RenameAlertDialog import com.dimowner.audiorecorder.v2.app.SaveAsDialog import com.dimowner.audiorecorder.v2.app.UpdateNameAndDescriptionDialog @@ -132,17 +131,55 @@ internal fun HomeScreen( val context = LocalContext.current val msgPermissionDenied = stringResource(R.string.msg_permission_microphone_denied) + val msgSystemAudioDenied = stringResource(R.string.msg_permission_system_audio_denied) val msgCanceled = stringResource(R.string.msg_recording_canceled) val actionUndo = stringResource(R.string.action_undo) val msgRecordMovedToTrashFormat = stringResource(R.string.msg_recording_moved_to_trash) + // Consent for capturing what other apps are playing. The dialog can only be raised from an + // Activity, and from Android 14 the granted token is single-use, so this runs before every + // system-audio recording rather than once. + val mediaProjectionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val data = result.data + if (result.resultCode == Activity.RESULT_OK && data != null) { + onAction(HomeScreenAction.OnStartRecordingClick(result.resultCode, data)) + } else { + scope.launch { + snackbarHostState.showSnackbar( + message = msgSystemAudioDenied, + duration = SnackbarDuration.Long + ) + } + } + } + + // Starts recording, first collecting MediaProjection consent when the selected audio source + // is system audio. RECORD_AUDIO is required for playback capture too, so it is checked first + // either way. + val startRecording: () -> Unit = { + if (uiState.isSystemAudioRecordingSelected) { + val manager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) + as? MediaProjectionManager + if (manager != null) { + mediaProjectionLauncher.launch(manager.createScreenCaptureIntent()) + } else { + Timber.e("MediaProjectionManager is unavailable; recording the microphone") + onAction(HomeScreenAction.OnStartRecordingClick()) + } + } else { + onAction(HomeScreenAction.OnStartRecordingClick()) + } + } + // Permission launcher for audio recording val recordAudioPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { isGranted -> if (isGranted) { // Permission granted - start recording immediately - onAction(HomeScreenAction.OnStartRecordingClick) + startRecording() } else { // Permission denied - show snackbar scope.launch { @@ -159,7 +196,7 @@ internal fun HomeScreen( when (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)) { PackageManager.PERMISSION_GRANTED -> { // Permission already granted - start recording - onAction(HomeScreenAction.OnStartRecordingClick) + startRecording() } else -> { // Permission not granted - request it diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt index eb384ee8..c7cdfe9d 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt @@ -19,6 +19,7 @@ package com.dimowner.audiorecorder.v2.app.home import android.animation.TypeEvaluator import android.animation.ValueAnimator import android.annotation.SuppressLint +import android.app.Activity import android.app.Application import android.content.ComponentName import android.content.Context @@ -76,6 +77,7 @@ import com.dimowner.audiorecorder.v2.data.model.AudioSource import com.dimowner.audiorecorder.v2.data.model.PlaybackSpeed import com.dimowner.audiorecorder.v2.data.model.Record import com.dimowner.audiorecorder.v2.analytics.AnalyticsTracker +import com.dimowner.audiorecorder.v2.data.model.isSystemAudioCaptureSupported import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher import dagger.hilt.android.lifecycle.HiltViewModel @@ -570,6 +572,12 @@ class HomeViewModel @Inject constructor( } } + val audioSource = prefs.settingAudioSource + _state.value = _state.value.copy( + selectedAudioSource = audioSource, + isSystemAudioRecordingSelected = audioSource.isSystemAudio && isSystemAudioCaptureSupported(), + ) + showLoadingProgress(true) viewModelScope.launch(ioDispatcher) { updateState(false) @@ -1156,12 +1164,19 @@ class HomeViewModel @Inject constructor( // - If is playing, stop playback // - Start recording service - fun handleStartRecordingClick() { + /** + * @param projectionResultCode result code of the MediaProjection consent dialog + * @param projectionData its payload, non-null only when the user granted system audio capture + */ + fun handleStartRecordingClick( + projectionResultCode: Int = Activity.RESULT_CANCELED, + projectionData: Intent? = null, + ) { audioPlayer.stop() val context: Context = getApplication().applicationContext // Start the recording service - AudioRecordingService.startServiceForeground(context) + AudioRecordingService.startServiceForeground(context, projectionResultCode, projectionData) } fun handlePauseRecordingClick() { @@ -1283,8 +1298,8 @@ class HomeViewModel @Inject constructor( HomeScreenAction.OnStopClick -> handlePlaybackStopClick() is HomeScreenAction.OnPlaybackSpeedClick -> handlePlaybackSpeedClick(action.speed) //Recording - HomeScreenAction.OnStartRecordingClick -> { - handleStartRecordingClick() + is HomeScreenAction.OnStartRecordingClick -> { + handleStartRecordingClick(action.projectionResultCode, action.projectionData) } HomeScreenAction.OnPauseRecordingClick -> handlePauseRecordingClick() HomeScreenAction.OnResumeRecordingClick -> handleResumeRecordingClick() @@ -1504,6 +1519,12 @@ data class HomeScreenState( val alwaysUseBluetoothMic: Boolean = false, // Audio source selection val selectedAudioSource: AudioSource = AudioSource.MIC, + /** + * Whether the next recording captures system audio rather than a microphone. The screen needs + * this to know it must collect MediaProjection consent before starting, which only an + * Activity can do. + */ + val isSystemAudioRecordingSelected: Boolean = false, // Lost records val showLostRecordsDialog: Boolean = false, val lostRecord: Record? = null, @@ -1549,7 +1570,14 @@ sealed class HomeScreenAction { data object OnPauseClick : HomeScreenAction() data object OnStopClick : HomeScreenAction() data class OnPlaybackSpeedClick(val speed: PlaybackSpeed) : HomeScreenAction() - data object OnStartRecordingClick : HomeScreenAction() + /** + * [projectionData] carries MediaProjection consent and is set only when the recording is to + * capture system audio; microphone recordings leave it null. + */ + data class OnStartRecordingClick( + val projectionResultCode: Int = Activity.RESULT_CANCELED, + val projectionData: Intent? = null, + ) : HomeScreenAction() data object OnPauseRecordingClick : HomeScreenAction() data object OnResumeRecordingClick : HomeScreenAction() data object OnStopRecordingClick : HomeScreenAction() diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsExtensions.kt index 34a8607d..311b3186 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsExtensions.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsExtensions.kt @@ -319,6 +319,7 @@ fun getChannelCounts( } } + fun getBitRates( format: RecordingFormat, selected: BitRate?, diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt index 164969b0..35c25ab0 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt @@ -43,6 +43,7 @@ import com.dimowner.audiorecorder.v2.data.model.BitRate import com.dimowner.audiorecorder.v2.data.model.ChannelCount import com.dimowner.audiorecorder.v2.data.model.RecordingFormat import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.data.model.isSystemAudioCaptureSupported import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher import dagger.hilt.android.lifecycle.HiltViewModel @@ -149,6 +150,8 @@ internal class SettingsViewModel @Inject constructor( maxRecordingDurationMinutes = prefs.maxRecordingDurationMills / 60000, recordAuthorName = prefs.recordAuthorName, isLegacyAppUser = prefs.isLegacyAppUser, + selectedAudioSource = prefs.settingAudioSource, + audioSourceOptions = supportedAudioSources(), ) } @@ -173,7 +176,8 @@ internal class SettingsViewModel @Inject constructor( availableSpaceMills = availableTimeMills, availableSpaceBytes = rawAvailableSpaceBytes, // Load the selected audio source from preferences - selectedAudioSource = prefs.settingAudioSource + selectedAudioSource = prefs.settingAudioSource, + audioSourceOptions = supportedAudioSources(), ) } recordsDataSource.removeOutdatedTrashRecords() @@ -181,10 +185,31 @@ internal class SettingsViewModel @Inject constructor( } fun setAudioSource(audioSource: AudioSource) { + if (audioSource !in supportedAudioSources()) return _state.value = _state.value.copy(selectedAudioSource = audioSource) prefs.settingAudioSource = audioSource + validateFormatForAudioSource(audioSource) } + /** + * System audio is captured with `AudioRecord`, and 3GP is the one format recorded through + * `MediaRecorder` only, so the pair cannot be honoured. Switching the source moves the format + * to the default rather than refusing the switch, because the source is what the user just + * asked for. + */ + private fun validateFormatForAudioSource(audioSource: AudioSource) { + if (audioSource.isSystemAudio && prefs.settingRecordingFormat == RecordingFormat.ThreeGp) { + selectRecordingFormat(DefaultValues.DefaultRecordingFormat) + } + } + + /** + * The audio sources offered on this device. System audio needs Android 10 and the feature + * flag; the microphone sources are always available. + */ + private fun supportedAudioSources(): List = + AudioSource.entries.filter { !it.isSystemAudio || isSystemAudioCaptureSupported() } + fun executeFirstRun() { if (prefs.isFirstRun) { prefs.confirmFirstRunExecuted() @@ -275,6 +300,14 @@ internal class SettingsViewModel @Inject constructor( fun selectRecordingFormat(value: RecordingFormat) { prefs.settingRecordingFormat = value + // The mirror of validateFormatForAudioSource(): 3GP has no AudioRecord-backed recorder, + // so picking it gives up system audio capture. + if (value == RecordingFormat.ThreeGp && prefs.settingAudioSource.isSystemAudio) { + prefs.settingAudioSource = DefaultValues.DefaultAudioSource + _state.value = _state.value.copy( + selectedAudioSource = DefaultValues.DefaultAudioSource + ) + } _state.value = _state.value.copy( recordingSettings = _state.value.recordingSettings.map { item -> item.copy( diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AacCodecRecorderV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AacCodecRecorderV2.kt new file mode 100644 index 00000000..219d61d5 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AacCodecRecorderV2.kt @@ -0,0 +1,753 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.audio + +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaCodecList +import android.media.MediaFormat +import android.media.MediaMuxer +import android.os.SystemClock +import com.dimowner.audiorecorder.AppConstants.RECORDING_VISUALIZATION_INTERVAL_NEW +import com.dimowner.audiorecorder.IntArrayList +import com.dimowner.audiorecorder.audio.sumOfAmplitudes +import com.dimowner.audiorecorder.exception.AlreadyRecordingException +import com.dimowner.audiorecorder.exception.AppException +import com.dimowner.audiorecorder.exception.InvalidOutputFile +import com.dimowner.audiorecorder.exception.RecorderInitException +import com.dimowner.audiorecorder.exception.RecordingException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.nio.ByteBuffer +import java.util.Timer +import java.util.TimerTask +import javax.inject.Inject +import javax.inject.Singleton + +/** Samples carried by one AAC-LC access unit. */ +private const val AAC_FRAME_SAMPLES = 1024 + +/** AAC-LC packs at most 6144 bits per channel into every [AAC_FRAME_SAMPLES] sample frame. */ +private const val AAC_LC_MAX_BITS_PER_SAMPLE = 6 + +private const val BITS_PER_SAMPLE = 16 +private const val MICROS_PER_SECOND = 1_000_000L + +/** The read loop encodes on its own thread, so give AudioRecord room beyond the bare minimum. */ +private const val AUDIO_RECORD_BUFFER_FACTOR = 4 + +private const val INPUT_TIMEOUT_US = 10_000L +private const val DRAIN_TIMEOUT_US = 10_000L + +/** How long the encoder may refuse input before the recording is treated as failed. */ +private const val INPUT_STALL_LIMIT_MS = 2_000L + +/** How long a started pipeline may produce nothing before the caller is told to fall back. */ +private const val STARTUP_TIMEOUT_MS = 1_500L + +private const val EOS_INPUT_TIMEOUT_MS = 500L +private const val EOS_DRAIN_TIMEOUT_MS = 2_000L + +/** Presentation timestamp of the [frameIndex]-th AAC access unit at [sampleRate]. */ +internal fun aacPtsUs(frameIndex: Long, sampleRate: Int): Long = + frameIndex * AAC_FRAME_SAMPLES * MICROS_PER_SECOND / sampleRate + +/** Duration covered by [framesFed] PCM sample frames at [sampleRate]. */ +internal fun pcmDurationMills(framesFed: Long, sampleRate: Int): Long = + framesFed * 1000L / sampleRate + +/** + * The requested bitrate reduced to what an AAC-LC encoder can actually produce: neither above the + * encoder's own range nor above the format's `6 * sampleRate * channelCount` frame ceiling. + */ +internal fun clampAacBitRate(requested: Int, sampleRate: Int, channelCount: Int, codecUpper: Int): Int { + val ceiling = AAC_LC_MAX_BITS_PER_SAMPLE * sampleRate * channelCount + return minOf(requested, ceiling, codecUpper).coerceAtLeast(1) +} + +/** + * Records m4a with `AudioRecord` -> `MediaCodec` -> `MediaMuxer`. + * + * The reason this exists next to [AudioRecorderV2]: `MediaRecorder.setAudioEncodingBitRate()` is + * only a request. `StagefrightRecorder` clips it to the `enc.aud.bps.max` of the device media + * profile - still 96000 on many devices - so a 192 kbps recording quietly comes out at 96 kbps. + * That clipping lives in `StagefrightRecorder` alone; driving the very same encoder through + * `MediaCodec` is bound by `media_codecs.xml` instead, where the AAC encoder typically allows up + * to 960 kbps. + * + * Structure follows [WavRecorderV2]: one IO coroutine owns the read loop, duration is derived from + * the PCM actually captured rather than from wall clock, pausing keeps the hardware running and + * discards reads, and the stop events are emitted only once the file is complete. + * + * Start failures are reported through [StartResult] rather than as events, because + * `AudioRecordingService` deletes the record and its file when it sees certain errors. Only + * [M4aRecorderV2] is meant to call [startRecordingInternal]; it decides whether to fall back to + * `MediaRecorder` instead of surfacing the failure. + */ +@Singleton +@Suppress("TooManyFunctions") +class AacCodecRecorderV2 @Inject constructor( + private val coroutineScope: CoroutineScope, +) : RecorderV2 { + + /** Outcome of the synchronous part of starting, which decides whether a fallback makes sense. */ + sealed interface StartResult { + /** The pipeline is running. */ + object Started : StartResult + + /** The request itself is not satisfiable; `MediaRecorder` would fail the same way. */ + data class Rejected(val exception: AppException) : StartResult + + /** The codec pipeline failed to come up; another backend may still work. */ + data class PipelineFailed(val stage: String, val cause: Throwable?) : StartResult + } + + private var audioRecord: AudioRecord? = null + private var codec: MediaCodec? = null + private var muxer: MediaMuxer? = null + private var recordingJob: Job? = null + + @Volatile private var _isRecording: Boolean = false + @Volatile private var _isPaused: Boolean = false + + override val isRecording: Boolean + get() = _isRecording + override val isPaused: Boolean + get() = _isPaused + + @Volatile private var durationMills: Long = 0 + private var sampleRateConfig: Int = 44100 + private var channelCountConfig: Int = 1 + private var maxDurationMills: Int = 0 + + private var timerProgress: Timer? = null + private val amplitudesBuffer: IntArrayList = IntArrayList() + @Volatile private var lastNonZeroAmplitude: Int = 0 + @Volatile private var lastEmittedDurationMills: Long = -1L + + /** + * Invoked when a pipeline that started cleanly turns out to produce nothing (see + * [STARTUP_TIMEOUT_MS]). No recorder event is emitted in that case, so the owner can still + * switch backends without the service ever having seen this recording begin. + */ + @Volatile var startFailureListener: ((String) -> Unit)? = null + + private val _event = MutableSharedFlow() + override fun subscribeRecorderEvents(): Flow = _event + + override fun startRecording( + outputFile: File, + channelCount: Int, + sampleRate: Int, + bitrate: Int, + maxRecordingDurationMills: Int, + audioInput: AudioInput, + ): Boolean { + return when ( + val result = startRecordingInternal( + outputFile, channelCount, sampleRate, bitrate, maxRecordingDurationMills, audioInput + ) + ) { + is StartResult.Started -> true + is StartResult.Rejected -> { + emitEvent(RecorderEvent.OnError(result.exception)) + false + } + // Deliberately silent: an error event here would make the service delete the record. + is StartResult.PipelineFailed -> false + } + } + + @Suppress("LongParameterList", "ReturnCount", "LongMethod") + fun startRecordingInternal( + outputFile: File, + channelCount: Int, + sampleRate: Int, + bitrate: Int, + maxRecordingDurationMills: Int, + audioInput: AudioInput, + ): StartResult { + Timber.d( + "Start AAC codec recording outputFile: ${outputFile.absolutePath} channelCount:" + + " $channelCount sampleRate: $sampleRate bitrate: $bitrate" + + " maxRecordingDurationMills: $maxRecordingDurationMills audioInput: $audioInput" + ) + if (_isRecording || codec != null) { + Timber.e("Recording is already in progress.") + return StartResult.Rejected(AlreadyRecordingException()) + } + if (!outputFile.exists() || !outputFile.isFile) { + return StartResult.Rejected(InvalidOutputFile()) + } + + amplitudesBuffer.clear() + lastNonZeroAmplitude = 0 + lastEmittedDurationMills = -1L + sampleRateConfig = sampleRate + channelCountConfig = channelCount + maxDurationMills = maxRecordingDurationMills + + val frameSize = channelCount * (BITS_PER_SAMPLE / 8) + val channelConfig = if (channelCount == 1) { + AudioFormat.CHANNEL_IN_MONO + } else { + AudioFormat.CHANNEL_IN_STEREO + } + val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT) + if (minBufferSize == AudioRecord.ERROR_BAD_VALUE || minBufferSize == AudioRecord.ERROR) { + Timber.e("Invalid buffer size: $minBufferSize") + return StartResult.Rejected(RecorderInitException()) + } + val bufferSize = minBufferSize * AUDIO_RECORD_BUFFER_FACTOR + + // Read in ~20 ms chunks so durationMills advances every ~20 ms; frameSize keeps the chunk + // sample aligned, and it never exceeds the AudioRecord buffer. + val readChunkSize = ((sampleRate * RECORDING_VISUALIZATION_INTERVAL_NEW / 1000) * frameSize) + .coerceAtLeast(frameSize) + .coerceAtMost(bufferSize) + + val recorder = try { + AudioRecordFactory.create( + audioInput, sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT, bufferSize + ) + } catch (e: SecurityException) { + Timber.e(e, "AudioRecord creation failed due to missing permission") + return StartResult.Rejected(RecorderInitException()) + } catch (e: IllegalArgumentException) { + Timber.e(e, "AudioRecord creation failed") + return StartResult.Rejected(RecorderInitException()) + } catch (e: UnsupportedOperationException) { + // AudioRecord.Builder rejects a system-playback configuration the device cannot + // honour. Rejected rather than PipelineFailed: MediaRecorder cannot capture playback + // at all, so falling back to it would silently record the microphone instead. + Timber.e(e, "AudioRecord creation failed for $audioInput") + return StartResult.Rejected(RecorderInitException()) + } + if (recorder.state != AudioRecord.STATE_INITIALIZED) { + Timber.e("AudioRecord initialization failed") + recorder.release() + return StartResult.Rejected(RecorderInitException()) + } + audioRecord = recorder + + val encodingBitRate = clampAacBitRate(bitrate, sampleRate, channelCount, aacEncoderMaxBitRate()) + if (encodingBitRate != bitrate) { + Timber.w("Bitrate $bitrate is out of range for this configuration, using $encodingBitRate") + } + val encoder = try { + createEncoder(sampleRate, channelCount, encodingBitRate, readChunkSize) + } catch (e: IOException) { + return releaseAndFail("codec-create", e) + } catch (e: IllegalArgumentException) { + return releaseAndFail("codec-configure", e) + } catch (e: MediaCodec.CodecException) { + return releaseAndFail("codec-start", e) + } catch (e: IllegalStateException) { + return releaseAndFail("codec-start", e) + } + codec = encoder + + muxer = try { + MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + } catch (e: IOException) { + return releaseAndFail("muxer", e) + } catch (e: IllegalArgumentException) { + return releaseAndFail("muxer", e) + } + + try { + recorder.startRecording() + } catch (e: IllegalStateException) { + Timber.e(e, "startRecording() failed") + releaseEverything() + return StartResult.Rejected(RecorderInitException()) + } + + _isRecording = true + _isPaused = false + durationMills = 0 + // OnStartRecording is emitted from the loop, once a frame has actually been muxed. + scheduleRecordingTimeUpdateBuffered() + recordingJob = coroutineScope.launch(Dispatchers.IO) { + runRecordingLoop(outputFile, readChunkSize, frameSize) + } + return StartResult.Started + } + + private fun createEncoder( + sampleRate: Int, + channelCount: Int, + bitrate: Int, + readChunkSize: Int, + ): MediaCodec { + val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount).apply { + setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) + setInteger(MediaFormat.KEY_BIT_RATE, bitrate) + setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, readChunkSize * 2) + setInteger( + MediaFormat.KEY_CHANNEL_MASK, + if (channelCount == 1) AudioFormat.CHANNEL_OUT_MONO else AudioFormat.CHANNEL_OUT_STEREO + ) + } + val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) + try { + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + encoder.start() + } catch (e: Exception) { + encoder.release() + throw e + } + return encoder + } + + /** The most permissive AAC encoder bitrate this device advertises. */ + private fun aacEncoderMaxBitRate(): Int { + return try { + MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos + .filter { info -> + info.isEncoder && info.supportedTypes.any { it.equals(MediaFormat.MIMETYPE_AUDIO_AAC, true) } + } + .mapNotNull { info -> + info.getCapabilitiesForType(MediaFormat.MIMETYPE_AUDIO_AAC).audioCapabilities?.bitrateRange?.upper + } + .maxOrNull() ?: Int.MAX_VALUE + } catch (e: IllegalArgumentException) { + Timber.d("Can't read AAC encoder capabilities: ${e.message}") + Int.MAX_VALUE + } + } + + private fun releaseAndFail(stage: String, cause: Throwable): StartResult { + Timber.w(cause, "MediaCodec recording pipeline failed at stage: $stage") + releaseEverything() + return StartResult.PipelineFailed(stage, cause) + } + + // ------------------------------------------------------------------------- + // Recording loop + // ------------------------------------------------------------------------- + + @Suppress("LongMethod", "NestedBlockDepth", "ComplexMethod") + private fun CoroutineScope.runRecordingLoop(outputFile: File, readChunkSize: Int, frameSize: Int) { + val session = MuxSession() + val pcm = ByteArray(readChunkSize) + val recorder = audioRecord + val encoder = codec + var framesFed = 0L + var maxDurationReached = false + var failure: Throwable? = null + val startupDeadline = SystemClock.elapsedRealtime() + STARTUP_TIMEOUT_MS + + try { + while (isActive && _isRecording) { + if (recorder == null || encoder == null) break + val read = recorder.read(pcm, 0, readChunkSize) + if (_isPaused) { + // Discard the PCM captured while paused: not feeding it is what keeps the + // encoded timeline gapless, since timestamps follow the frames actually fed. + drainEncoder(encoder, session, endOfStream = false) + continue + } + when { + read > 0 -> { + synchronized(amplitudesBuffer) { amplitudesBuffer.add(calculateAmplitude(pcm, read)) } + framesFed = feedEncoder(encoder, pcm, read, framesFed, frameSize, session) + durationMills = pcmDurationMills(framesFed, sampleRateConfig) + drainEncoder(encoder, session, endOfStream = false) + } + read == AudioRecord.ERROR_INVALID_OPERATION -> { + Timber.e("AudioRecord read error: ERROR_INVALID_OPERATION") + break + } + read == AudioRecord.ERROR_BAD_VALUE -> { + Timber.e("AudioRecord read error: ERROR_BAD_VALUE") + break + } + } + + if (maxDurationMills > 0 && durationMills >= maxDurationMills) { + Timber.d("Max recording duration reached. Stop recording") + // Signal the loop to stop; finishRecording() below drains the encoder, + // closes the container and releases the hardware. + maxDurationReached = true + _isRecording = false + _isPaused = false + break + } + if (session.muxedFrameCount == 0L && SystemClock.elapsedRealtime() > startupDeadline) { + Timber.w("Encoder produced no output within ${STARTUP_TIMEOUT_MS}ms") + abandonStartup(outputFile) + return + } + } + } catch (e: MediaCodec.CodecException) { + failure = e.takeIf { _isRecording } + } catch (e: IllegalStateException) { + // AudioRecord and MediaCodec both throw this when they are used after stopRecording() + // released them, which is an ordinary stop rather than a failure. + failure = e.takeIf { _isRecording } + } catch (e: IOException) { + failure = e.takeIf { _isRecording } + } + if (failure != null) { + Timber.e(failure, "Recording failed, finishing the file with what was captured") + } + + finishRecording(session, maxDurationReached, failure, outputFile) + } + + /** + * Hands [size] bytes of PCM to the encoder, splitting across input buffers when needed, and + * returns the new total of sample frames fed. Each piece carries its own timestamp - encoders + * that hand back smaller buffers than asked for would otherwise see repeated ones. + */ + @Suppress("LongParameterList") + private fun feedEncoder( + encoder: MediaCodec, + data: ByteArray, + size: Int, + framesFedSoFar: Long, + frameSize: Int, + session: MuxSession, + ): Long { + var offset = 0 + var framesFed = framesFedSoFar + var starvedSince = 0L + while (offset < size) { + val index = encoder.dequeueInputBuffer(INPUT_TIMEOUT_US) + if (index < 0) { + // The encoder has no room until its output is consumed. + drainEncoder(encoder, session, endOfStream = false) + val now = SystemClock.elapsedRealtime() + if (starvedSince == 0L) { + starvedSince = now + } else if (now - starvedSince > INPUT_STALL_LIMIT_MS) { + throw EncoderStalledException() + } + continue + } + starvedSince = 0L + val buffer = encoder.getInputBuffer(index) ?: continue + buffer.clear() + val chunk = minOf(size - offset, buffer.remaining()) + buffer.put(data, offset, chunk) + encoder.queueInputBuffer(index, 0, chunk, framesFed * MICROS_PER_SECOND / sampleRateConfig, 0) + framesFed += chunk / frameSize + offset += chunk + } + return framesFed + } + + /** + * Moves everything the encoder has ready into the muxer. + * + * Timestamps are recomputed from the muxed frame counter instead of being taken from the + * encoder: every AAC-LC access unit is exactly [AAC_FRAME_SAMPLES] samples and paused audio is + * never fed, so counting frames yields a contiguous, strictly increasing timeline - which is + * what `MediaMuxer` demands and what some encoders fail to provide. + */ + private fun drainEncoder(encoder: MediaCodec, session: MuxSession, endOfStream: Boolean) { + val info = MediaCodec.BufferInfo() + val deadline = SystemClock.elapsedRealtime() + EOS_DRAIN_TIMEOUT_MS + while (true) { + val index = encoder.dequeueOutputBuffer(info, if (endOfStream) DRAIN_TIMEOUT_US else 0L) + when { + index == MediaCodec.INFO_TRY_AGAIN_LATER -> { + if (!endOfStream || SystemClock.elapsedRealtime() > deadline) return + } + index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + if (!session.muxerStarted) { + val activeMuxer = muxer ?: return + session.trackIndex = activeMuxer.addTrack(encoder.outputFormat) + activeMuxer.start() + session.muxerStarted = true + } + } + index >= 0 -> { + writeSample(encoder, index, info, session) + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return + } + } + } + } + + private fun writeSample(encoder: MediaCodec, index: Int, info: MediaCodec.BufferInfo, session: MuxSession) { + val buffer: ByteBuffer? = encoder.getOutputBuffer(index) + // The codec config blob is already carried by the track format added above. + if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + info.size = 0 + } + if (buffer != null && info.size > 0 && session.muxerStarted) { + buffer.position(info.offset) + buffer.limit(info.offset + info.size) + info.presentationTimeUs = aacPtsUs(session.muxedFrameCount, sampleRateConfig) + info.flags = info.flags or MediaCodec.BUFFER_FLAG_KEY_FRAME + muxer?.writeSampleData(session.trackIndex, buffer, info) + session.muxedFrameCount++ + if (session.muxedFrameCount == 1L) { + emitEvent(RecorderEvent.OnStartRecording) + } + } + encoder.releaseOutputBuffer(index, false) + } + + /** + * Tears down a pipeline that started but never produced audio, leaving no trace: no events are + * emitted, so [M4aRecorderV2] can start another backend on the same record. + */ + private fun abandonStartup(outputFile: File) { + _isRecording = false + _isPaused = false + stopRecordingTimer() + stopHardware() + releaseCodecAndMuxer(muxerStarted = false) + durationMills = 0 + runCatching { outputFile.writeBytes(ByteArray(0)) } + startFailureListener?.invoke("no-output") + } + + /** + * Signals end of stream, drains what is left, closes the container and only then reports the + * outcome - a consumer must never see a stop event before the file is complete. + */ + private fun finishRecording( + session: MuxSession, + maxDurationReached: Boolean, + failure: Throwable?, + outputFile: File, + ) { + stopRecordingTimer() + stopHardware() + val encoder = codec + if (encoder != null && failure == null) { + runCatching { signalEndOfStream(encoder, session) } + .onFailure { Timber.e(it, "Failed to flush the encoder") } + } + releaseCodecAndMuxer(session.muxerStarted) + + _isRecording = false + _isPaused = false + durationMills = 0 + + when { + session.muxedFrameCount == 0L -> { + // Nothing was ever written: the file is an unusable stub, so let the service drop + // the empty record along with it. + runCatching { outputFile.delete() } + emitEvent(RecorderEvent.OnError(RecorderInitException())) + } + failure != null -> { + // Keep what was captured: stop first so the record is persisted, then report. + emitEvent(RecorderEvent.OnStopRecording) + emitEvent(RecorderEvent.OnError(RecordingException())) + } + maxDurationReached -> emitEvent(RecorderEvent.OnMaxDurationReached) + else -> emitEvent(RecorderEvent.OnStopRecording) + } + } + + private fun signalEndOfStream(encoder: MediaCodec, session: MuxSession) { + val deadline = SystemClock.elapsedRealtime() + EOS_INPUT_TIMEOUT_MS + while (SystemClock.elapsedRealtime() < deadline) { + val index = encoder.dequeueInputBuffer(INPUT_TIMEOUT_US) + if (index >= 0) { + encoder.queueInputBuffer( + index, 0, 0, + aacPtsUs(session.muxedFrameCount, sampleRateConfig), + MediaCodec.BUFFER_FLAG_END_OF_STREAM + ) + break + } + drainEncoder(encoder, session, endOfStream = false) + } + drainEncoder(encoder, session, endOfStream = true) + } + + // ------------------------------------------------------------------------- + // Transport controls + // ------------------------------------------------------------------------- + + override fun resumeRecording(): Boolean { + if (!_isRecording || !_isPaused) return false + _isPaused = false + emitEvent(RecorderEvent.OnResumeRecording) + scheduleRecordingTimeUpdateBuffered() + return true + } + + override fun pauseRecording(): Boolean { + pauseRecordingTimer() + if (!_isRecording) { + Timber.e("Recording has already stopped or hasn't started") + return false + } + if (_isPaused) { + Timber.e("Recording has already paused") + return false + } + _isPaused = true + emitEvent(RecorderEvent.OnPauseRecording) + return true + } + + override fun stopRecording(): Boolean { + stopRecordingTimer() + if (!_isRecording) { + Timber.e("Recording has already stopped or hasn't started") + return false + } + // Flip the flags only; the recording coroutine finishes its current read, flushes the + // encoder, closes the container, emits OnStopRecording and releases the hardware. No + // native call runs on this thread - this is reached from the main thread (the stop + // button, the notification action and the MediaProjection callback), and + // AudioRecord.stop() blocks there long enough to ANR. + _isRecording = false + _isPaused = false + synchronized(amplitudesBuffer) { amplitudesBuffer.clear() } + return true + } + + /** + * Stops and releases [audioRecord]. Called from the recording coroutine's teardown and from + * the start-up failure paths, never from the main thread. The instance is captured first so + * a rapid stop->start that has already swapped in a new [AudioRecord] is not torn down by + * the previous run; the field is only cleared if it still points at this recorder. + */ + private fun stopHardware(): Boolean { + val recorder = audioRecord ?: return false + return try { + recorder.stop() + true + } catch (e: IllegalStateException) { + Timber.e(e, "stopHardware() problems") + false + } finally { + recorder.release() + if (audioRecord === recorder) audioRecord = null + } + } + + private fun releaseCodecAndMuxer(muxerStarted: Boolean) { + val encoder = codec + codec = null + try { + encoder?.stop() + } catch (e: IllegalStateException) { + Timber.e(e, "codec.stop() problems") + } finally { + encoder?.release() + } + + val activeMuxer = muxer + muxer = null + try { + // stop() on a muxer that was never started throws. + if (muxerStarted) activeMuxer?.stop() + } catch (e: IllegalStateException) { + Timber.e(e, "muxer.stop() problems") + } finally { + activeMuxer?.release() + } + } + + private fun releaseEverything() { + stopHardware() + releaseCodecAndMuxer(muxerStarted = false) + } + + // ------------------------------------------------------------------------- + // Progress + // ------------------------------------------------------------------------- + + private fun calculateAmplitude(buffer: ByteArray, bytesRead: Int): Int { + if (bytesRead <= 0) return 0 + val sum = buffer.sumOfAmplitudes(bytesRead) + return (sum / (bytesRead / 16 + 1)).toInt() + } + + private fun emitEvent(event: RecorderEvent) { + coroutineScope.launch { + _event.emit(event) + } + } + + private fun scheduleRecordingTimeUpdateBuffered() { + stopRecordingTimer() + timerProgress = Timer() + timerProgress?.schedule(object : TimerTask() { + override fun run() { + try { + readBufferedProgress() + } catch (e: IllegalStateException) { + Timber.e(e) + } + } + }, 0, RECORDING_VISUALIZATION_INTERVAL_NEW.toLong()) + } + + private fun stopRecordingTimer() { + timerProgress?.cancel() + timerProgress?.purge() + timerProgress = null + } + + private fun pauseRecordingTimer() { + timerProgress?.cancel() + timerProgress?.purge() + timerProgress = null + } + + private fun readBufferedProgress() { + // Timer.cancel() doesn't prevent an already-scheduled task from running; skip stale + // fires so a late progress event can't flip state back to RECORDING after stop. + if (!_isRecording || _isPaused) return + val currentDuration = durationMills + // Skip if durationMills hasn't changed since the last emission - this prevents duplicate + // events when the timer fires faster than the AudioRecord buffer fills. + if (currentDuration == lastEmittedDurationMills) return + synchronized(amplitudesBuffer) { + val bufferSize = amplitudesBuffer.size() + if (bufferSize > 0) { + lastEmittedDurationMills = currentDuration + var amp = amplitudesBuffer.get(bufferSize - 1) + if (amp == 0) amp = lastNonZeroAmplitude + else lastNonZeroAmplitude = amp + amplitudesBuffer.clear() + amplitudesBuffer.add(amp) + emitEvent(RecorderEvent.OnRecordingProgress(durationMills = currentDuration, amplitude = amp)) + } + } + } + + /** Mutable state of one muxing session, kept together so the loop can pass it around. */ + private class MuxSession { + var trackIndex: Int = -1 + var muxerStarted: Boolean = false + var muxedFrameCount: Long = 0 + } + + private class EncoderStalledException : IOException("The encoder stopped accepting input") +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioInput.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioInput.kt new file mode 100644 index 00000000..15788770 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioInput.kt @@ -0,0 +1,27 @@ +package com.dimowner.audiorecorder.v2.audio + +import android.media.projection.MediaProjection + +/** + * Where a recorder pulls its PCM from. + * + * This replaces the bare `audioSource: Int` the recorders used to take, because system playback + * capture is not expressible as a [android.media.MediaRecorder.AudioSource] constant: it is + * configured with an [android.media.AudioPlaybackCaptureConfiguration] built from a + * [MediaProjection], and that only plugs into [android.media.AudioRecord.Builder]. Making the + * distinction a type keeps the two paths apart at compile time, so the `MediaRecorder`-backed + * recorders can reject what they cannot do instead of silently recording the microphone. + */ +sealed interface AudioInput { + + /** A platform capture source - the microphone, in one of its processing variants. */ + data class Mic(val audioSource: Int) : AudioInput + + /** + * Audio other apps are playing, captured through the AudioPlaybackCapture API (API 29+). + * + * The [mediaProjection] is owned by the caller (the recording service): the recorder only + * reads from it and never stops or releases it. + */ + data class SystemPlayback(val mediaProjection: MediaProjection) : AudioInput +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordFactory.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordFactory.kt new file mode 100644 index 00000000..745eb525 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordFactory.kt @@ -0,0 +1,92 @@ +package com.dimowner.audiorecorder.v2.audio + +import android.annotation.SuppressLint +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioPlaybackCaptureConfiguration +import android.media.AudioRecord +import android.os.Build +import androidx.annotation.RequiresApi + +/** + * Creates the [AudioRecord] that every AudioRecord-backed recorder captures from, so the + * microphone and system-playback configurations live in one place instead of being repeated in + * each recorder. + * + * Callers keep their own error handling: this throws the platform's exceptions unchanged rather + * than swallowing them, because each recorder already maps a failed start onto its own event or + * result type. + */ +internal object AudioRecordFactory { + + /** + * Playback usages worth recording. `MEDIA` and `GAME` are the ones users mean by "system + * sound"; `UNKNOWN` is included because apps that never set [AudioAttributes] explicitly end + * up there and would otherwise be silently missing from the capture. + * + * Everything else is deliberately left out - `VOICE_COMMUNICATION` (calls and VoIP), + * notifications and alarms - and the platform would refuse most of it anyway. + */ + private val CAPTURED_USAGES = intArrayOf( + AudioAttributes.USAGE_MEDIA, + AudioAttributes.USAGE_GAME, + AudioAttributes.USAGE_UNKNOWN, + ) + + /** + * @throws IllegalArgumentException when the platform rejects the requested configuration + * @throws SecurityException when `RECORD_AUDIO` has not been granted + * @throws UnsupportedOperationException when the configuration cannot be satisfied, including + * system playback capture requested below API 29 + */ + @SuppressLint("MissingPermission") + fun create( + input: AudioInput, + sampleRate: Int, + channelConfig: Int, + audioEncoding: Int, + bufferSize: Int, + ): AudioRecord { + return when (input) { + is AudioInput.Mic -> AudioRecord( + input.audioSource, sampleRate, channelConfig, audioEncoding, bufferSize + ) + is AudioInput.SystemPlayback -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + throw UnsupportedOperationException( + "System audio capture requires Android 10 (API 29)" + ) + } + createPlaybackCapture(input, sampleRate, channelConfig, audioEncoding, bufferSize) + } + } + } + + // AudioRecord.Builder.build() carries @RequiresPermission(RECORD_AUDIO). The permission is + // granted before the recording service is started, and every caller already handles the + // SecurityException thrown when it is not. + @SuppressLint("MissingPermission") + @RequiresApi(Build.VERSION_CODES.Q) + private fun createPlaybackCapture( + input: AudioInput.SystemPlayback, + sampleRate: Int, + channelConfig: Int, + audioEncoding: Int, + bufferSize: Int, + ): AudioRecord { + val captureConfig = AudioPlaybackCaptureConfiguration.Builder(input.mediaProjection) + .apply { CAPTURED_USAGES.forEach { addMatchingUsage(it) } } + .build() + return AudioRecord.Builder() + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(audioEncoding) + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .build() + ) + .setBufferSizeInBytes(bufferSize) + .setAudioPlaybackCaptureConfig(captureConfig) + .build() + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderDelegate.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderDelegate.kt index c07e8a94..816cccd8 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderDelegate.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderDelegate.kt @@ -8,14 +8,14 @@ import javax.inject.Singleton @Singleton class AudioRecorderDelegate @Inject constructor( private val prefs: PrefsV2, - private val audioRecorder: AudioRecorderV2, + private val m4aRecorder: M4aRecorderV2, private val threeGpRecorder: ThreeGpRecorderV2, private val wavRecorder: WavRecorderV2, ) { fun provideAudioRecorder(): RecorderV2 { return when (prefs.settingRecordingFormat) { - RecordingFormat.M4a -> audioRecorder + RecordingFormat.M4a -> m4aRecorder RecordingFormat.Wav -> wavRecorder RecordingFormat.ThreeGp -> threeGpRecorder } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt index ffc5d8e6..65b2a8cc 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt @@ -15,6 +15,7 @@ */ package com.dimowner.audiorecorder.v2.audio +import android.app.Activity import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -23,6 +24,8 @@ import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager import android.os.Binder import android.os.Build import android.os.Handler @@ -36,19 +39,29 @@ import com.dimowner.audiorecorder.AppConstantsV2 import com.dimowner.audiorecorder.R import com.dimowner.audiorecorder.app.DecodeService import com.dimowner.audiorecorder.audio.AudioDecoder +import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.exception.AlreadyRecordingException +import com.dimowner.audiorecorder.exception.AppException import com.dimowner.audiorecorder.exception.CantCreateFileException import com.dimowner.audiorecorder.exception.ErrorParser -import com.dimowner.audiorecorder.exception.InvalidOutputFile -import com.dimowner.audiorecorder.exception.RecorderInitException +import com.dimowner.audiorecorder.exception.RecordingStopFailedException import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.analytics.ANALYTICS_VALUE_NONE +import com.dimowner.audiorecorder.v2.analytics.ANALYTICS_VALUE_UNKNOWN_NUMBER +import com.dimowner.audiorecorder.v2.analytics.AnalyticsTracker +import com.dimowner.audiorecorder.v2.analytics.RecordingStartFailure +import com.dimowner.audiorecorder.v2.analytics.RecordingStartFailureReason +import com.dimowner.audiorecorder.v2.DefaultValues import com.dimowner.audiorecorder.v2.app.HomeActivity import com.dimowner.audiorecorder.v2.app.getNewRecordName import com.dimowner.audiorecorder.v2.data.FileDataSource import com.dimowner.audiorecorder.v2.data.PrefsV2 import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.AudioSource import com.dimowner.audiorecorder.v2.data.model.Record import com.dimowner.audiorecorder.v2.data.model.RecordingFormat import com.dimowner.audiorecorder.v2.data.model.convertToRecordingFormat +import com.dimowner.audiorecorder.v2.data.model.isSystemAudioCaptureSupported import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineDispatcher @@ -82,9 +95,29 @@ class AudioRecordingService : Service() { private const val ACTION_PAUSE_RESUME_RECORDING = "com.dimowner.audiorecorder.ACTION_PAUSE_RESUME_RECORDING" private const val ACTION_STOP_RECORDING = "com.dimowner.audiorecorder.ACTION_STOP_RECORDING" - fun startServiceForeground(context: Context) { + private const val EXTRA_PROJECTION_RESULT_CODE = "extra_projection_result_code" + private const val EXTRA_PROJECTION_DATA = "extra_projection_data" + + /** + * Starts a recording. + * + * [projectionData] is the payload of the screen-capture consent dialog, and is required + * only when the selected audio source is [AudioSource.SYSTEM_AUDIO]; pass `null` for + * microphone recording. It has to be obtained by an Activity and handed over here, + * because a service cannot show the consent dialog itself. + */ + @JvmOverloads + fun startServiceForeground( + context: Context, + projectionResultCode: Int = Activity.RESULT_CANCELED, + projectionData: Intent? = null, + ) { val intent = Intent(context, AudioRecordingService::class.java).apply { action = ACTION_START_RECORDING + if (projectionData != null) { + putExtra(EXTRA_PROJECTION_RESULT_CODE, projectionResultCode) + putExtra(EXTRA_PROJECTION_DATA, projectionData) + } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent) @@ -110,6 +143,15 @@ class AudioRecordingService : Service() { @Inject lateinit var prefs: PrefsV2 + @Inject + lateinit var analyticsTracker: AnalyticsTracker + + /** + * The playback player, the same singleton instance [AudioPlaybackService] drives, so that + * stopping it here also tears that service and its notification down. + */ + @Inject + lateinit var audioPlayer: PlayerContractNew.Player @Inject @IoDispatcher @@ -119,6 +161,9 @@ class AudioRecordingService : Service() { private val serviceScope by lazy { CoroutineScope(ioDispatcher + serviceJob) } private val notificationHandler = Handler(Looper.getMainLooper()) + + /** Delivers [MediaProjection.Callback] invocations; separate only to keep the names honest. */ + private val mainHandler = Handler(Looper.getMainLooper()) private var notificationManager: NotificationManager? = null private val _recordingState = MutableStateFlow(RecordingServiceState()) @@ -155,9 +200,34 @@ class AudioRecordingService : Service() { RecordingWaveformBuffer(ARApplication.longWaveformSampleCount) } + /** + * The projection backing an in-progress system-audio recording, plus the callback registered + * on it. Both are null for microphone recordings. + * + * A projection is single-use from Android 14, so it is created per start command and released + * on stop; [MediaProjection.Callback] registration is mandatory there too, and it is what + * tells us the user revoked the capture from the system UI. + */ + private var mediaProjection: MediaProjection? = null + private var mediaProjectionCallback: MediaProjection.Callback? = null + /** Job for the current recorder-events subscription; cancelled before re-subscribing. */ private var subscriptionJob: Job? = null + /** + * True between the moment the recorder is asked to start and the moment it reports the first + * recorded data back. It is what tells a start failure apart from an error raised later on, + * during a recording that is already running - only the former is reported to analytics. + * + * Written from the service scope and read from the recorder-events collector, hence volatile. + */ + @Volatile + private var isStartingRecording: Boolean = false + + /** Capture source of the current start attempt, reported alongside a start failure. */ + @Volatile + private var startingAudioSource: String = ANALYTICS_VALUE_NONE + /** * Timestamp (in ms) of the last available-space check. * Space is checked at most once every [AppConstants.MIN_REMAIN_RECORDING_TIME] / 2 ms @@ -184,9 +254,26 @@ class AudioRecordingService : Service() { subscribeRecorderEvents() when (intent?.action) { ACTION_START_RECORDING -> { + // Recording and playback must never overlap, and the stop belongs here rather + // than at the call sites: this is the one point every entry into recording goes + // through, including the home screen widget, which otherwise records over the + // track that is playing. + stopPlaybackBeforeRecording() + // The projection has to exist before handleStartRecording() picks an input, but + // it can only be created once the service is foreground with the mediaProjection + // type (enforced from Android 14), hence this ordering. + val useSystemAudio = isSystemAudioSelected() && intent.hasProjectionConsent() // Must call startForeground() synchronously before any async work // to satisfy the foreground service contract and avoid ANR. - startForegroundWithNotification() + startForegroundWithNotification(withMediaProjection = useSystemAudio) + if (useSystemAudio) { + createMediaProjection(intent) + if (mediaProjection == null) { + // The recording falls back to the microphone, so drop the type it no + // longer backs rather than running as a projection service holding none. + startForegroundWithNotification(withMediaProjection = false) + } + } serviceScope.launch { val recordName = prefs.settingNamingFormat.getNewRecordName(prefs) resetRecordedRecordPartCounter() @@ -202,6 +289,7 @@ class AudioRecordingService : Service() { override fun onDestroy() { super.onDestroy() + releaseMediaProjection() subscriptionJob?.cancel() serviceJob.cancel() stopNotificationUpdates() @@ -212,6 +300,14 @@ class AudioRecordingService : Service() { return _recordingState.value.durationMills } + /** Stops playback, if any, so the microphone does not open on top of a playing track. */ + private fun stopPlaybackBeforeRecording() { + if (audioPlayer.isPlaying() || audioPlayer.isPaused()) { + Timber.d("AudioRecordingService: stopping playback before recording starts") + audioPlayer.stop() + } + } + private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( @@ -239,6 +335,7 @@ class AudioRecordingService : Service() { Timber.d("AudioRecordingService: event: $event") when (event) { is RecorderEvent.OnStartRecording -> { + isStartingRecording = false recordingAmplitudes.clear() totalRecordingSampleCount = 0 lastAvailableSpaceCheckTime = 0L @@ -248,6 +345,7 @@ class AudioRecordingService : Service() { amplitudes = intArrayOf(), totalSampleCount = 0, waveformDataOffset = 0, + durationMills = 0L, ) startNotificationUpdates() updateNotification() @@ -275,30 +373,82 @@ class AudioRecordingService : Service() { } is RecorderEvent.OnError -> { Timber.e(event.exception, "AudioRecordingService: recorder error") - //Send a user-friendly error message to UI based on the type of error - val errorMessage = applicationContext.getString( - ErrorParser.parseException(event.exception) - ) - emitEvent(AudioRecordingServiceEvent.ShowErrorSnack(errorMessage)) - - val recordedRecordId = prefs.recordedRecordId - prefs.recordedRecordId = -1 - //Recording failed to start. Delete the created record in database and file - // if the error is related to recorder initialization or file creation. - if (event.exception is RecorderInitException - || event.exception is InvalidOutputFile - || event.exception is CantCreateFileException - ) { - recordsDataSource.deleteRecordAndFileForever(recordedRecordId) + // Read before handleRecorderError() below resets the state the report + // describes the failed attempt with. + if (isStartingRecording) { + isStartingRecording = false + analyticsTracker.trackRecordingStartFailed( + buildRecordingStartFailure( + reason = RecordingStartFailureReason.fromException(event.exception), + error = event.exception, + ) + ) } - - stopForegroundService() + handleRecorderError(event.exception) } } } } } + /** + * Decides what to do with the in-flight recording after the recorder reported [exception]. + * + * The distinction that matters is whether any audio made it to disk. A recorder that failed + * before producing anything leaves an empty stub file worth cleaning up; a recorder that fails + * after minutes - or hours - of capture leaves the user's recording, and that file must be + * saved like a normal stop. The previous version deleted the record for every init/file error + * regardless of how long it had been recording, so a mid-session I/O failure (running out of + * space being the obvious one on a long recording) destroyed the whole session. + */ + private suspend fun handleRecorderError(exception: AppException) { + if (exception is AlreadyRecordingException) { + // A rejected duplicate start says nothing about the recording that is already + // running, so reporting it must not tear that recording down. + showRecorderError(exception) + return + } + + if (exception is RecordingStopFailedException) { + // The recorder captured the audio but failed while closing the container, so the + // file still holds everything - only the index that makes it playable is missing. + // That is the same damage a force-kill leaves behind, so run it through the same + // recovery before telling the user the recording is lost. + val recordedRecordId = prefs.recordedRecordId + prefs.recordedRecordId = -1 + if (recordedRecordId < 0 || !recoverUnfinalizedRecord(recordedRecordId)) { + showRecorderError(exception) + } + stopForegroundService() + return + } + + showRecorderError(exception) + + if (_recordingState.value.durationMills > 0 && prefs.recordedRecordId >= 0) { + // Audio is already on disk, so finish the file the way a normal stop does instead + // of discarding what the user recorded. handleRecordingStopped() reads and clears + // prefs.recordedRecordId itself and stops the service once the record is saved. + handleRecordingStopped() + return + } + + // Nothing was captured: the record is an empty stub, so drop it along with its file. + val recordedRecordId = prefs.recordedRecordId + prefs.recordedRecordId = -1 + if (recordedRecordId >= 0) { + recordsDataSource.deleteRecordAndFileForever(recordedRecordId) + } + stopForegroundService() + } + + /** Shows the user-facing message [ErrorParser] maps [exception] to. */ + private fun showRecorderError(exception: AppException) { + emitEvent(AudioRecordingServiceEvent.ShowErrorSnack( + applicationContext.getString(ErrorParser.parseException(exception)) + )) + } + fun handleRecordingProgress(durationMills: Long, amplitude: Int) { _recordingState.value = _recordingState.value.copy( recordingState = RecordingState.PROGRESS, @@ -366,6 +516,31 @@ class AudioRecordingService : Service() { ) } + /** + * Picks what this recording captures. + * + * System audio needs a projection granted for *this* start. Both entry points collect that + * consent before starting the service, so a missing projection here means the token could not + * be redeemed (it is single-use from Android 14) rather than a user choice. The recording + * falls back to the microphone instead of failing outright, and the stored preference is left + * alone so the next attempt still tries system audio. + * + * When a recording is split on [prefs].maxRecordingDurationMills the next part comes through + * here again with no new intent; it reuses the same live projection, which stays valid until + * the service releases it. + */ + private fun resolveAudioInput(): AudioInput { + if (!isSystemAudioSelected()) { + return AudioInput.Mic(prefs.settingAudioSource.value) + } + val projection = mediaProjection + if (projection == null) { + Timber.w("System audio was selected but no MediaProjection was granted; using the mic") + return AudioInput.Mic(DefaultValues.DefaultAudioSource.value) + } + return AudioInput.SystemPlayback(projection) + } + // - Has available space // - Is already recoding // - Create a record file @@ -373,83 +548,252 @@ class AudioRecordingService : Service() { // - Set it as active record // - Start recording private suspend fun handleStartRecording(recordName: String): Long? { - val format = prefs.settingRecordingFormat + val audioInput = resolveAudioInput() + val rawFormat = prefs.settingRecordingFormat + val format = if (rawFormat == RecordingFormat.ThreeGp && audioInput !is AudioInput.Mic) { + prefs.settingRecordingFormat = DefaultValues.DefaultRecordingFormat + DefaultValues.DefaultRecordingFormat + } else { + rawFormat + } val sampleRate = prefs.settingSampleRate.value val bitrate = prefs.settingBitrate.value val channelCount = prefs.settingChannelCount.value + startingAudioSource = audioInput.analyticsLabel() + val availableSpaceBytes = fileDataSource.getAvailableSpace() val availableTimeSeconds = convertSpaceBytesToTimeInSeconds( - spaceBytes = fileDataSource.getAvailableSpace(), + spaceBytes = availableSpaceBytes, recordingFormat = format, sampleRate = sampleRate, bitrate = bitrate, channels = channelCount ) - if (availableTimeSeconds > AppConstants.MIN_REMAIN_RECORDING_TIME && !audioRecorder.isRecording) { - try { - val recordFile = fileDataSource.createRecordFile(addExtension(recordName)) - // Use the actual file name (without extension) in case a suffix was added to avoid collision - val actualRecordName = recordFile.nameWithoutExtension - val record = Record( - id = 0, - name = actualRecordName, - durationMills = 0, - created = recordFile.lastModified(), - added = System.currentTimeMillis(), - removed = Long.MAX_VALUE, - path = recordFile.absolutePath, + if (availableTimeSeconds <= AppConstants.MIN_REMAIN_RECORDING_TIME) { + Timber.e("Not enough space to start recording, available time: $availableTimeSeconds s") + analyticsTracker.trackRecordingStartFailed( + buildRecordingStartFailure( + reason = RecordingStartFailureReason.NOT_ENOUGH_SPACE, format = format.value, - size = 0, sampleRate = sampleRate, + bitrate = bitrate, channelCount = channelCount, - bitrate = if (format.hasBitrate) bitrate else 0, - isBookmarked = false, - isWaveformProcessed = false, - isMovedToRecycle = false, - amps = IntArray(ARApplication.longWaveformSampleCount), - description = "", + availableSpaceBytes = availableSpaceBytes, ) - val id = recordsDataSource.insertRecord(record) - prefs.activeRecordId = -1 - prefs.recordedRecordId = id - prefs.recordedRecordPartCounter += 1 - - _recordingState.value = _recordingState.value.copy( - recordId = id, - recordName = actualRecordName, - recordingFormat = format, + ) + return null + } + if (audioRecorder.isRecording) { + Timber.e("Can't start recording, recording is already in progress") + analyticsTracker.trackRecordingStartFailed( + buildRecordingStartFailure( + reason = RecordingStartFailureReason.ALREADY_RECORDING, + format = format.value, sampleRate = sampleRate, bitrate = bitrate, channelCount = channelCount, + availableSpaceBytes = availableSpaceBytes, ) + ) + return null + } + try { + val recordFile = fileDataSource.createRecordFile(addExtension(recordName)) + // Use the actual file name (without extension) in case a suffix was added to avoid collision + val actualRecordName = recordFile.nameWithoutExtension + val record = Record( + id = 0, + name = actualRecordName, + durationMills = 0, + created = recordFile.lastModified(), + added = System.currentTimeMillis(), + removed = Long.MAX_VALUE, + path = recordFile.absolutePath, + format = format.value, + size = 0, + sampleRate = sampleRate, + channelCount = channelCount, + bitrate = if (format.hasBitrate) bitrate else 0, + isBookmarked = false, + isWaveformProcessed = false, + isMovedToRecycle = false, + amps = IntArray(ARApplication.longWaveformSampleCount), + description = "", + ) + val id = recordsDataSource.insertRecord(record) + prefs.activeRecordId = -1 + prefs.recordedRecordId = id + prefs.recordedRecordPartCounter += 1 + + _recordingState.value = _recordingState.value.copy( + recordId = id, + recordName = actualRecordName, + recordingFormat = format, + sampleRate = sampleRate, + bitrate = bitrate, + channelCount = channelCount, + // Belongs to the record just inserted, so it starts at zero even when the + // previous part left a duration behind: handleRecorderError() reads this to + // tell a recording that captured audio from an empty stub, and a start that + // fails before OnStartRecording never gets to reset it. + durationMills = 0L, + ) - audioRecorder.startRecording( - outputFile = recordFile, - channelCount = channelCount, + // Set before starting, because a recorder can report its failure synchronously. + isStartingRecording = true + audioRecorder.startRecording( + outputFile = recordFile, + channelCount = channelCount, + sampleRate = sampleRate, + bitrate = bitrate, + maxRecordingDurationMills = prefs.maxRecordingDurationMills, + audioInput = audioInput, + ) + return id + } catch (e: CantCreateFileException) { + Timber.e(e, "Failed to start recording with name: $recordName") + isStartingRecording = false + analyticsTracker.trackRecordingStartFailed( + buildRecordingStartFailure( + reason = RecordingStartFailureReason.CANT_CREATE_FILE, + format = format.value, sampleRate = sampleRate, bitrate = bitrate, - maxRecordingDurationMills = prefs.maxRecordingDurationMills, - audioSource = prefs.settingAudioSource.value, + channelCount = channelCount, + availableSpaceBytes = availableSpaceBytes, + error = e, ) - return id - } catch (e: CantCreateFileException) { - Timber.e(e, "Failed to start recording with name: $recordName") - val cantCreateFileMsg = applicationContext.getString(R.string.error_cant_create_file) - val failedToStartRecordingMsg = applicationContext.getString(R.string.error_failed_to_start_recording) - emitEvent(AudioRecordingServiceEvent.ShowErrorSnack( - "$failedToStartRecordingMsg\n$cantCreateFileMsg" - )) - stopForegroundService() - } + ) + val cantCreateFileMsg = applicationContext.getString(R.string.error_cant_create_file) + val failedToStartRecordingMsg = applicationContext.getString(R.string.error_failed_to_start_recording) + emitEvent(AudioRecordingServiceEvent.ShowErrorSnack( + "$failedToStartRecordingMsg\n$cantCreateFileMsg" + )) + stopForegroundService() } return null } - private fun startForegroundWithNotification() { + /** + * Builds the report for a recording that failed to start. + * + * The settings default to the ones in [_recordingState], which [handleStartRecording] fills in + * right before it touches the recorder - so a failure reported from the recorder-events + * collector still describes the attempt that failed, not the preferences as they are now. + * The call sites that fail before that point pass their values explicitly. + */ + @Suppress("LongParameterList") + private fun buildRecordingStartFailure( + reason: RecordingStartFailureReason, + format: String = _recordingState.value.recordingFormat?.value ?: ANALYTICS_VALUE_NONE, + sampleRate: Int = _recordingState.value.sampleRate, + bitrate: Int = _recordingState.value.bitrate, + channelCount: Int = _recordingState.value.channelCount, + availableSpaceBytes: Long = runCatching { fileDataSource.getAvailableSpace() } + .getOrDefault(ANALYTICS_VALUE_UNKNOWN_NUMBER), + error: Throwable? = null, + ) = RecordingStartFailure( + reason = reason, + format = format, + sampleRate = sampleRate, + bitrate = bitrate, + channelCount = channelCount, + audioSource = startingAudioSource, + availableSpaceBytes = availableSpaceBytes, + error = error, + ) + + /** Capture source label reported with a recording start failure. */ + private fun AudioInput.analyticsLabel(): String = when (this) { + is AudioInput.Mic -> AudioSource.fromValue(audioSource).name.lowercase() + is AudioInput.SystemPlayback -> AudioSource.SYSTEM_AUDIO.name.lowercase() + } + + /** Whether the user has chosen to record system audio and this build/device can do it. */ + private fun isSystemAudioSelected(): Boolean = + prefs.settingAudioSource.isSystemAudio && isSystemAudioCaptureSupported() + + private fun Intent.hasProjectionConsent(): Boolean = + getIntExtra(EXTRA_PROJECTION_RESULT_CODE, Activity.RESULT_CANCELED) == Activity.RESULT_OK && + projectionDataExtra() != null + + @Suppress("DEPRECATION") + private fun Intent.projectionDataExtra(): Intent? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(EXTRA_PROJECTION_DATA, Intent::class.java) + } else { + getParcelableExtra(EXTRA_PROJECTION_DATA) + } + + /** + * Turns the consent result carried by [intent] into a live [MediaProjection]. + * + * Failing here is not fatal: [handleStartRecording] sees a null projection and records the + * microphone instead, which is better than refusing to record at all. + */ + private fun createMediaProjection(intent: Intent) { + val data = intent.projectionDataExtra() ?: return + val resultCode = intent.getIntExtra(EXTRA_PROJECTION_RESULT_CODE, Activity.RESULT_CANCELED) + val manager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as? MediaProjectionManager + if (manager == null) { + Timber.e("MediaProjectionManager is unavailable") + return + } + val projection = try { + manager.getMediaProjection(resultCode, data) + } catch (e: IllegalStateException) { + // Thrown when the consent token has already been used - it is single-use from + // Android 14, so a re-delivered start intent lands here. + Timber.e(e, "Failed to obtain a MediaProjection") + null + } catch (e: SecurityException) { + Timber.e(e, "Failed to obtain a MediaProjection") + null + } + if (projection == null) { + Timber.e("MediaProjection was not granted") + return + } + // Registering a callback is mandatory from Android 14, and onStop is how we learn the + // user revoked the capture from the system UI mid-recording. + val callback = object : MediaProjection.Callback() { + override fun onStop() { + Timber.d("MediaProjection stopped by the system or the user") + if (audioRecorder.isRecording) audioRecorder.stopRecording() + } + } + projection.registerCallback(callback, mainHandler) + mediaProjection = projection + mediaProjectionCallback = callback + } + + private fun releaseMediaProjection() { + val projection = mediaProjection ?: return + mediaProjection = null + mediaProjectionCallback?.let { projection.unregisterCallback(it) } + mediaProjectionCallback = null + try { + projection.stop() + } catch (e: IllegalStateException) { + Timber.e(e, "MediaProjection stop failed") + } + } + + private fun startForegroundWithNotification(withMediaProjection: Boolean = false) { val notification = buildNotification() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) + // mediaProjection is added only for a system-audio recording: from Android 14 a + // service claiming that type is expected to hold a projection, and this call is what + // makes getMediaProjection() legal, so it has to come first. microphone stays in both + // cases because playback capture still goes through AudioRecord under RECORD_AUDIO. + val type = if (withMediaProjection) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } + startForeground(NOTIFICATION_ID, notification, type) } else { startForeground(NOTIFICATION_ID, notification) } @@ -549,7 +893,60 @@ class AudioRecordingService : Service() { } } + /** + * Rebuilds a record whose file was left without a readable container by a failed stop. + * + * The audio itself survives such a failure - only the index the player needs is missing - so + * the file goes through the same restoration the broken-record dialog runs after a + * force-kill. On success the record is completed the way [handleRecordingStopped] completes + * a normal one: metadata read back from the recovered file, waveform queued for decoding and + * the record made active. Returns false when nothing playable came back, leaving the caller + * to report the failure to the user. + */ + private suspend fun recoverUnfinalizedRecord(recordId: Long): Boolean { + return withContext(ioDispatcher) { + val record = recordsDataSource.getRecord(recordId) ?: return@withContext false + val recovered = if (recordsDataSource.restoreBrokenRecord(recordId)) { + recordsDataSource.getRecord(recordId) + } else { + null + } + // A restore that came back without a duration left the file as unplayable as it was. + if (recovered == null || recovered.durationMills <= 0) { + Timber.e("Failed to recover the record left by a failed stop: id=$recordId") + analyticsTracker.trackBrokenRecordRestoreFailed(format = record.format) + return@withContext false + } + analyticsTracker.trackBrokenRecordRestoreSuccess(format = recovered.format) + // Keep the waveform captured while recording, same as the normal stop path does - + // it gives the UI something to draw until DecodeService replaces it. + recordsDataSource.updateRecord( + recovered.copy(amps = recordingFullDataBuffer.downsampleToIntArray()) + ) + prefs.activeRecordId = recordId + _recordingState.value = _recordingState.value.copy( + recordingState = RecordingState.STOPPED, + ) + emitEvent(AudioRecordingServiceEvent.ShowInfoSnack( + applicationContext.getString(R.string.msg_recording_saved_with_name, recovered.name) + )) + emitEvent(AudioRecordingServiceEvent.RecordingStopped( + recordId = recordId, + recordName = recovered.name, + )) + decodeRecord( + recordId = recovered.id, + path = recovered.path, + durationMills = recovered.durationMills, + ) + resetRecordedRecordPartCounter() + true + } + } + private fun stopForegroundService() { + isStartingRecording = false + releaseMediaProjection() recordingAmplitudes.clear() totalRecordingSampleCount = 0 recordingFullDataBuffer.reset() diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/M4aRecorderV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/M4aRecorderV2.kt new file mode 100644 index 00000000..f04e6c0c --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/M4aRecorderV2.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.audio + +import com.dimowner.audiorecorder.exception.CantCreateFileException +import com.dimowner.audiorecorder.exception.RecorderInitException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The m4a recorder: [AacCodecRecorderV2] with [AudioRecorderV2] as a fallback. + * + * The codec pipeline is preferred because `MediaRecorder` silently clips the bitrate to the device + * media profile (96 kbps on many devices), but it is the newer of the two paths, so any device + * where it cannot start still records through `MediaRecorder`. + * + * This is a wrapper rather than a branch inside `AudioRecordingService` because the service + * resolves its recorder once per start command and binds its event subscription to that instance. + * Owning the event flow here lets the backend change without the service noticing. + */ +@Singleton +class M4aRecorderV2 @Inject constructor( + private val codecRecorder: AacCodecRecorderV2, + private val mediaRecorder: AudioRecorderV2, + private val coroutineScope: CoroutineScope, +) : RecorderV2 { + + private data class StartParams( + val outputFile: File, + val channelCount: Int, + val sampleRate: Int, + val bitrate: Int, + val maxRecordingDurationMills: Int, + val audioInput: AudioInput, + ) + + @Volatile private var active: RecorderV2? = null + @Volatile private var lastParams: StartParams? = null + + private val _event = MutableSharedFlow() + override fun subscribeRecorderEvents(): Flow = _event + + init { + // Both children are process-lifetime singletons, so relaying permanently is cheaper than + // re-subscribing per recording - and it cannot miss the first event, which subscribing + // right before a start could, since the children emit through their own coroutines. + relay(codecRecorder) + relay(mediaRecorder) + codecRecorder.startFailureListener = { reason -> onAsyncStartFailure(reason) } + } + + private fun relay(recorder: RecorderV2) { + coroutineScope.launch { + recorder.subscribeRecorderEvents().collect { event -> + if (active === recorder) _event.emit(event) + } + } + } + + override fun startRecording( + outputFile: File, + channelCount: Int, + sampleRate: Int, + bitrate: Int, + maxRecordingDurationMills: Int, + audioInput: AudioInput, + ): Boolean { + val params = StartParams( + outputFile, channelCount, sampleRate, bitrate, maxRecordingDurationMills, audioInput + ) + lastParams = params + active = codecRecorder + return when ( + val result = codecRecorder.startRecordingInternal( + outputFile, channelCount, sampleRate, bitrate, maxRecordingDurationMills, audioInput + ) + ) { + is AacCodecRecorderV2.StartResult.Started -> { + true + } + // The request itself is not satisfiable - the microphone is busy, the file is + // unusable - so MediaRecorder would fail the same way. Report it. + is AacCodecRecorderV2.StartResult.Rejected -> { + emitEvent(RecorderEvent.OnError(result.exception)) + false + } + is AacCodecRecorderV2.StartResult.PipelineFailed -> { + Timber.w("MediaCodec pipeline failed at ${result.stage}, falling back to MediaRecorder") + startWithMediaRecorder(params) + } + } + } + + /** + * Falls back to the `MediaRecorder` backend, unless the recording captures system playback - + * `MediaRecorder` has no playback-capture equivalent, so falling back there would record the + * microphone instead of what the user asked for. In that case the failure is surfaced. + */ + private fun startWithMediaRecorder(params: StartParams): Boolean { + if (params.audioInput !is AudioInput.Mic) { + Timber.e("No MediaRecorder fallback for ${params.audioInput}; reporting the failure") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + return false + } + active = mediaRecorder + if (!resetOutputFile(params.outputFile)) { + emitEvent(RecorderEvent.OnError(CantCreateFileException())) + return false + } + return mediaRecorder.startRecording( + params.outputFile, + params.channelCount, + params.sampleRate, + params.bitrate, + params.maxRecordingDurationMills, + params.audioInput, + ) + } + + /** + * Gives MediaRecorder an empty file to append to: the MediaMuxer constructor truncates the + * path and may already have written an ftyp box, and MediaRecorder requires the file to exist. + */ + private fun resetOutputFile(outputFile: File): Boolean { + return try { + outputFile.delete() + outputFile.createNewFile() + } catch (e: IOException) { + Timber.e(e, "Failed to reset the output file before falling back") + false + } catch (e: SecurityException) { + Timber.e(e, "Failed to reset the output file before falling back") + false + } + } + + /** + * A codec pipeline that started but produced nothing. No events reached the service, so the + * same record can still be recorded by MediaRecorder. + */ + private fun onAsyncStartFailure(reason: String) { + val params = lastParams ?: return + if (active !== codecRecorder) return + coroutineScope.launch { + Timber.w("MediaCodec pipeline produced no audio ($reason), falling back to MediaRecorder") + startWithMediaRecorder(params) + } + } + + override fun resumeRecording(): Boolean = active?.resumeRecording() ?: false + + override fun pauseRecording(): Boolean = active?.pauseRecording() ?: false + + // `active` deliberately stays set after a stop so the events the backend emits while + // finalising the file are still relayed. The next start reassigns it. + override fun stopRecording(): Boolean = active?.stopRecording() ?: false + + override val isRecording: Boolean + get() = active?.isRecording ?: false + + override val isPaused: Boolean + get() = active?.isPaused ?: false + + private fun emitEvent(event: RecorderEvent) { + coroutineScope.launch { + _event.emit(event) + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/MediaRecorderBase.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/MediaRecorderBase.kt index 5c7441ba..b8e276d7 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/MediaRecorderBase.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/MediaRecorderBase.kt @@ -27,7 +27,11 @@ import com.dimowner.audiorecorder.IntArrayList import com.dimowner.audiorecorder.exception.AlreadyRecordingException import com.dimowner.audiorecorder.exception.InvalidOutputFile import com.dimowner.audiorecorder.exception.RecorderInitException +import com.dimowner.audiorecorder.exception.RecordingException +import com.dimowner.audiorecorder.exception.RecordingStopFailedException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch @@ -37,6 +41,9 @@ import java.io.IOException import java.util.Timer import java.util.TimerTask +/** Passed to [MediaRecorder.setMaxDuration] to record without a platform side duration limit. */ +private const val NO_MAX_DURATION = -1 + /** * Abstract base class for [MediaRecorder]-based recorder implementations. * @@ -48,6 +55,8 @@ import java.util.TimerTask abstract class MediaRecorderBase( private val applicationContext: Context, private val coroutineScope: CoroutineScope, + /** Where the blocking [MediaRecorder.stop] runs, see [stopRecording]. */ + private val stopDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : RecorderV2 { private var timerProgress: Timer? = null @@ -66,6 +75,9 @@ abstract class MediaRecorderBase( @Volatile private var updateTime: Long = 0 @Volatile private var durationMills: Long = 0 + // The maximum duration is enforced here rather than by MediaRecorder, see startRecording(). + @Volatile private var maxDurationMills: Int = 0 + @Volatile private var _isRecording: Boolean = false @Volatile private var _isPaused: Boolean = false override val isRecording: Boolean @@ -114,13 +126,23 @@ abstract class MediaRecorderBase( sampleRate: Int, bitrate: Int, maxRecordingDurationMills: Int, - audioSource: Int, + audioInput: AudioInput, ): Boolean { Timber.d( "Start ${recordingLogTag}Recording outputFile: ${outputFile.absolutePath}" + " channelCount: $channelCount sampleRate: $sampleRate bitrate: $bitrate" + - " maxRecordingDurationMills: $maxRecordingDurationMills audioSource: $audioSource" + " maxRecordingDurationMills: $maxRecordingDurationMills audioInput: $audioInput" ) + // System playback capture is configured with an AudioPlaybackCaptureConfiguration, which + // only AudioRecord.Builder accepts - MediaRecorder has no equivalent. Refusing here beats + // recording the microphone under a name the user did not ask for. Callers keep this from + // happening by choosing an AudioRecord-backed recorder for that source. + val micInput = audioInput as? AudioInput.Mic + if (micInput == null) { + Timber.e("MediaRecorder cannot capture system audio playback") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + return false + } // _isRecording only flips to true once the first valid amplitude arrives, so it is still // false while the recorder is starting up. Checking the recorder instance as well closes // that window: without it a second start would overwrite (and then release) a live @@ -132,6 +154,7 @@ abstract class MediaRecorderBase( } amplitudesBuffer.clear() lastNonZeroAmplitude = 0 + maxDurationMills = maxRecordingDurationMills return if (outputFile.exists() && outputFile.isFile) { recordFile = outputFile val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -144,15 +167,23 @@ abstract class MediaRecorderBase( try { recorder.apply { - setAudioSource(audioSource) + setAudioSource(micInput.audioSource) configureRecorder(this, channelCount, sampleRate, bitrate) - setMaxDuration(maxRecordingDurationMills) + // MPEG4Writer sizes the moov box it reserves up front from the duration + // limit, and with a limit of hours that reservation maxes out at ~405 KB of + // "free" padding that stays in every finished file - a few seconds of audio + // then weighs several hundred KB. Recording without a platform limit keeps + // the reservation at its 3 KB minimum; the limit is enforced in + // readBufferedProgress() instead, the same way WavRecorderV2 does it. + setMaxDuration(NO_MAX_DURATION) setOnInfoListener { _, what, _ -> handleRecorderInfo(what) } + setOnErrorListener { _, what, extra -> handleRecorderError(what, extra) } setOutputFile(outputFile.absolutePath) } recorder.prepare() recorder.start() _isPaused = false + updateTime = SystemClock.elapsedRealtime() startSamplingThread() scheduleRecordingTimeUpdate() scheduleRecordingTimeUpdateBuffered() @@ -231,58 +262,136 @@ abstract class MediaRecorderBase( } override fun stopRecording(): Boolean { - return stopRecording(skipStopRecordingEventEmit = false) + return stopRecording(RecorderEvent.OnStopRecording) } - private fun stopRecording(skipStopRecordingEventEmit: Boolean): Boolean { + /** + * Ends the recording. Returns as soon as the state has been reset - the container is + * finalised on a background thread, see [finishStop]. + * + * @param completionEvent emitted once the container has been closed successfully. A failed + * stop reports [RecordingStopFailedException] instead, whatever the caller asked for. + */ + private fun stopRecording(completionEvent: RecorderEvent): Boolean { // A recorder that started but hasn't reported an amplitude yet still has _isRecording // false, and it must be released here - otherwise it would keep holding the microphone // and block every subsequent startRecording(). - if (!_isRecording && mediaRecorder == null) { + val recorder = mediaRecorder + if (!_isRecording && recorder == null) { Timber.e("Recording has already stopped or hasn't started") return false } + // Hand the instance to the teardown below right away, so a second stop (the notification + // action racing the stop button, or a max-duration tick landing on top of either) finds + // no recorder and cannot start a second teardown of the same one. + mediaRecorder = null stopRecordingTimer() stopRecordingTimerBuffered() - val isStopSucceed = try { - mediaRecorder?.let { - it.setOnInfoListener(null) - it.stop() - true - } ?: false - } catch (e: IllegalStateException) { - // This can happen if start() failed and stop() is called, or if the recorder - // was never fully prepared/started. - Timber.e(e, "stopRecording() problems") - false - } finally { - // Always release resources - releaseRecorder() - } - - if (!skipStopRecordingEventEmit) { - emitEvent(RecorderEvent.OnStopRecording) - } + stopSamplingThread() // Reset all state durationMills = 0 + maxDurationMills = 0 recordFile = null _isRecording = false _isPaused = false synchronized(amplitudesBuffer) { amplitudesBuffer.clear() } - return isStopSucceed + + if (recorder == null) { + // _isRecording is flipped by the sampling thread, which can land just after the + // instance was released: there is nothing left to finalise and nothing to report. + return false + } + + // MediaRecorder.stop() finalises the container through the media server and does not + // return until that is done, which is seconds rather than milliseconds when the writer + // has a lot to flush or the media server is busy. This method is reached from the main + // thread (the stop button and the notification action), so the blocking part runs on the + // recorder scope instead; everything the caller observes has already been reset above. + coroutineScope.launch(stopDispatcher) { finishStop(recorder, completionEvent) } + return true + } + + /** Closes the container and reports the outcome. Always runs off the caller's thread. */ + private fun finishStop(recorder: MediaRecorder, completionEvent: RecorderEvent) { + var stopFailure: RuntimeException? = null + try { + recorder.setOnInfoListener(null) + // stop() itself can trip the error callback (a media server that dies while the + // container is being finalised). The outcome is already reported from here, so let + // that callback find no listener rather than start a second teardown. + recorder.setOnErrorListener(null) + recorder.stop() + } catch (e: RuntimeException) { + // stop() reports everything as a RuntimeException: IllegalStateException when the + // recorder was never fully prepared/started, and a plain RuntimeException("stop + // failed.") when the writer could not finalise the container - a recording stopped + // before a single frame was muxed, or a media server hiccup. Catching only the + // subclass let the latter reach the caller, and stopRecording() used to run on the + // main thread behind the stop button, so it crashed the app. + Timber.e(e, "stopRecording() problems") + stopFailure = e + } finally { + // Always release resources + releaseRecorder(recorder) + } + + if (stopFailure != null) { + // The container was never closed, so what is on disk cannot be played as it stands. + // Report it instead of a normal stop: the service then tries to recover the captured + // audio rather than saving a record that refuses to open. + emitEvent(RecorderEvent.OnError(RecordingStopFailedException())) + } else { + emitEvent(completionEvent) + } } private fun handleRecorderInfo(what: Int) { - if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) { - Timber.d("Max recording duration reached. Stop recording") - if (stopRecording(skipStopRecordingEventEmit = true)) { - emitEvent(RecorderEvent.OnMaxDurationReached) + when (what) { + // The platform MPEG-4 writer holds its sample tables in memory and addresses the + // file with 32-bit offsets, so it stops the recording by itself once the output + // grows too large. Treating that like a max-duration hit rolls the session over + // into the next numbered part; ignoring it (as before) left the recorder dead while + // the service kept showing an active recording whose timer went on counting. + MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED, + MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED -> { + Timber.d("Recorder reported a limit reached (what: $what). Stop recording") + stopRecording(RecorderEvent.OnMaxDurationReached) } } } + private fun handleMaxDurationReached() { + Timber.d("Max recording duration reached. Stop recording") + stopRecording(RecorderEvent.OnMaxDurationReached) + } + + /** + * Handles a [MediaRecorder] runtime failure - a media-server death or an encoder error, both + * of which get more likely the longer a session runs. + * + * Without this listener such a failure is completely silent: the recorder stops producing + * audio, the file is never finalised, and the service keeps reporting an active recording + * indefinitely. Stopping here finalises whatever was captured so it can still be saved, and + * the error is reported as a [RecordingException] - not a [RecorderInitException], which + * callers read as "start failed, discard the file". + * + * The failure is handed to [stopRecording] as its completion event rather than emitted next + * to it: the service acts on the first event it sees, and it must not act before the + * container has been closed. It also keeps a stop that then fails on its own reported as a + * [RecordingStopFailedException], which is the only event that sends the service down the + * recovery path for an unfinalised file. + */ + private fun handleRecorderError(what: Int, extra: Int) { + Timber.e("MediaRecorder error. what: $what extra: $extra") + if (!stopRecording(RecorderEvent.OnError(RecordingException()))) { + // Nothing was left to finalise - the recorder had already been torn down, and the + // teardown that did it reports its own outcome. + Timber.w("MediaRecorder error arrived with no recording in progress") + } + } + protected fun emitEvent(event: RecorderEvent) { coroutineScope.launch { _event.emit(event) @@ -299,11 +408,16 @@ abstract class MediaRecorderBase( if (!isPaused) { // The recorder can be released on another thread right after the null check // above (a failed start(), or stopRecording() racing with this tick), which - // makes getMaxAmplitude() throw. Give up on the loop instead of crashing - - // the next start/resume reschedules it. + // makes getMaxAmplitude() throw. Like stop(), it reports every failure as a + // RuntimeException - IllegalStateException when the recorder was never + // initialised, and a plain RuntimeException("getMaxAmplitude failed.") when the + // native call fails (already released, or the media server died). Catching only + // the subclass let the latter kill the sampling thread, and an uncaught + // exception there takes down the process. Give up on the loop instead - the + // next start/resume reschedules it. val amplitude = try { currentRecorder.maxAmplitude - } catch (e: IllegalStateException) { + } catch (e: RuntimeException) { Timber.e(e, "Error reading amplitude, stopping progress updates") return@Runnable } @@ -312,7 +426,6 @@ abstract class MediaRecorderBase( //which indicates that recording has actually started. if (amplitude > 0) { _isRecording = true - updateTime = SystemClock.elapsedRealtime() synchronized(amplitudesBuffer) { amplitudesBuffer.add(amplitude) } } } else { @@ -333,6 +446,11 @@ abstract class MediaRecorderBase( private fun releaseRecorder() { val recorder = mediaRecorder mediaRecorder = null + releaseRecorder(recorder) + } + + /** Releases an instance the caller has already detached from [mediaRecorder]. */ + private fun releaseRecorder(recorder: MediaRecorder?) { stopSamplingThread() stopRecordingTimerBuffered() recorder?.release() @@ -375,6 +493,8 @@ abstract class MediaRecorderBase( } private fun scheduleRecordingTimeUpdateBuffered() { + timerProgress?.cancel() + timerProgress?.purge() timerProgress = Timer() timerProgress?.schedule(object : TimerTask() { override fun run() { @@ -402,20 +522,30 @@ abstract class MediaRecorderBase( private fun readBufferedProgress() { // Timer.cancel() doesn't prevent an already-scheduled task from running; skip stale // fires so a late progress event can't flip state back to RECORDING after stop. - if (!_isRecording || _isPaused) return - synchronized(amplitudesBuffer) { - val bufferSize = amplitudesBuffer.size() - if (bufferSize > 0) { - val curTime = SystemClock.elapsedRealtime() - durationMills += curTime - updateTime - updateTime = curTime - var amp = amplitudesBuffer.get(bufferSize - 1) - if (amp == 0) amp = lastNonZeroAmplitude - else lastNonZeroAmplitude = amp - amplitudesBuffer.clear() - emitEvent(RecorderEvent.OnRecordingProgress(durationMills = durationMills, amplitude = amp)) + if (mediaRecorder == null || _isPaused) return + val curTime = SystemClock.elapsedRealtime() + durationMills += curTime - updateTime + updateTime = curTime + + if (_isRecording) { + synchronized(amplitudesBuffer) { + val bufferSize = amplitudesBuffer.size() + if (bufferSize > 0) { + var amp = amplitudesBuffer.get(bufferSize - 1) + if (amp == 0) amp = lastNonZeroAmplitude + else lastNonZeroAmplitude = amp + amplitudesBuffer.clear() + emitEvent(RecorderEvent.OnRecordingProgress(durationMills = durationMills, amplitude = amp)) + } } } + // Stopping outside the lock: stopRecording() clears the same buffer and cancels the timer + // this call runs on. + if (isMaxDurationReached()) { + handleMaxDurationReached() + } } -} + private fun isMaxDurationReached(): Boolean = + maxDurationMills > 0 && durationMills >= maxDurationMills +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecorderV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecorderV2.kt index 84d8587d..c6a85009 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecorderV2.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecorderV2.kt @@ -13,7 +13,7 @@ interface RecorderV2 { sampleRate: Int, bitrate: Int, maxRecordingDurationMills: Int, - audioSource: Int, + audioInput: AudioInput, ): Boolean fun resumeRecording(): Boolean fun pauseRecording(): Boolean diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecordingWaveformBuffer.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecordingWaveformBuffer.kt index d608e233..d399acb7 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecordingWaveformBuffer.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecordingWaveformBuffer.kt @@ -20,31 +20,33 @@ package com.dimowner.audiorecorder.v2.audio * * Unlike the sliding-window [recordingAmplitudes] buffer in [AudioRecordingService] * (used only for the live waveform display), this buffer captures the *entire* recording - * session while staying bounded in memory. When the buffer reaches - * [HALVING_CAP_MULTIPLIER] × [targetSize] samples a [compressUniformly] pass fires, - * resampling the **entire original-sample timeline** down to [cap]/2 slots so every - * slot always represents an equal time fraction of the recording up to that point. + * session while staying bounded in both memory **and** CPU cost. * - * ### Why uniform resampling matters - * A naïve pair-average halve produces slots with non-equal time widths: previously - * compressed slots cover many original samples each, while freshly-added raw slots - * cover only one. Treating them as uniform in [downsampleToIntArray] would shrink - * the early part of the waveform and stretch the recent part. The fix is to always - * sample in **original-sample-index space** (via [getAmplitudeAtOriginalIndex]) rather - * than in buffer-slot space, both during compression and during final downsampling. + * ### How it stays uniform + * Every filled slot represents exactly [samplesPerSlot] original amplitude samples — a running + * average is accumulated in [pendingSum]/[pendingCount] and flushed into a slot once that many + * samples have arrived. When the buffer fills to [cap] slots, [compressUniformly] merges adjacent + * pairs and doubles [samplesPerSlot]. Because every slot has the same time width before *and* + * after a merge, the timeline stays uniform without ever having to resample the whole history. * - * At recording stop, call [downsampleToIntArray] to obtain a [targetSize]-element - * [IntArray] suitable for persisting as the initial `amps` on a [Record]. This gives - * the UI an immediate real waveform to display before [DecodeService] replaces it with - * the fully-decoded version. + * ### Why not resample the full timeline on every compression + * Rebuilding the buffer from the complete original-sample timeline on each pass costs O(total + * samples added), which makes a recording session cost O(N²) overall. At the 20 ms sampling + * interval a 20-hour recording produces ~3.6M samples and ~3000 compression passes — billions of + * operations spent on the waveform preview alone. Pairwise merging is O([cap]) per pass and + * amortises to O(1) per sample, so cost no longer grows with recording length. * - * **Memory bound:** worst case ≈ [HALVING_CAP_MULTIPLIER] × [targetSize] boxed integers. - * For a typical 400 dp screen [targetSize] ≈ 600, so the cap is ≈ 2 400 elements (~38 KB) - * regardless of recording length. + * At recording stop, call [downsampleToIntArray] to obtain an [IntArray] of at most [targetSize] + * elements, suitable for persisting as the initial `amps` on a [Record]. This gives the UI an + * immediate real waveform to display before [DecodeService] replaces it with the fully-decoded + * version. * - * **Thread-safety:** [add], [reset], and [downsampleToIntArray] are individually - * `@Synchronized` and may be called from any thread. [compressUniformly] is internal - * and always called under the same lock (from [add]). + * **Memory bound:** a single [IntArray] of [HALVING_CAP_MULTIPLIER] × [targetSize] elements. + * For a typical 400 dp screen [targetSize] ≈ 600, so ≈ 2 400 ints (~9.6 KB) regardless of + * recording length. + * + * **Thread-safety:** all public methods are `@Synchronized` and may be called from any thread. + * [compressUniformly] is internal and always called under the same lock (from [add]). * * @param targetSize the number of output samples produced by [downsampleToIntArray]. * Usually [ARApplication.longWaveformSampleCount]. Passed explicitly so the class is @@ -54,63 +56,67 @@ class RecordingWaveformBuffer(private val targetSize: Int) { companion object { /** - * When the sample count reaches this multiple of [targetSize] a uniform - * compression pass is triggered. After compression the buffer holds [cap]/2 slots, - * giving headroom for the next batch of raw samples. + * When the slot count reaches this multiple of [targetSize] a compression pass is + * triggered. After compression the buffer holds [cap]/2 slots, giving headroom for the + * next batch of samples. */ internal const val HALVING_CAP_MULTIPLIER = 4 } - private val samples = ArrayList(targetSize * HALVING_CAP_MULTIPLIER) private val cap: Int = targetSize * HALVING_CAP_MULTIPLIER + private val slots = IntArray(cap) - /** Total number of raw amplitude samples ever passed to [add] since the last [reset]. */ - private var totalSamplesAdded: Int = 0 + /** Number of slots currently filled in [slots]. */ + private var slotCount: Int = 0 - /** - * Value of [totalSamplesAdded] captured immediately after the most recent - * [compressUniformly] pass. Zero if no compression has occurred yet. - * - * Together with [cap]/2 this defines the two regions of the buffer: - * - **Compressed region** slots `0..[cap]/2-1`: uniformly cover original samples - * `0..[totalSamplesAtLastCompression]-1`. - * - **Raw region** slots `[cap]/2..[samples.size]-1`: one slot per original sample, - * covering `[totalSamplesAtLastCompression]..[totalSamplesAdded]-1`. - */ - private var totalSamplesAtLastCompression: Int = 0 + /** How many original amplitude samples each filled slot represents. Doubles on every merge. */ + private var samplesPerSlot: Int = 1 + + /** Running average of the slot currently being filled; not yet part of [slots]. */ + private var pendingSum: Long = 0 + private var pendingCount: Int = 0 /** Number of slots currently held in the buffer. */ - fun size(): Int = samples.size + @Synchronized + fun size(): Int = slotCount /** - * Appends [amplitude] (raw 0–32 767 from MediaRecorder.getMaxAmplitude) and triggers - * a [compressUniformly] pass if the buffer reaches [cap]. + * Appends [amplitude] (raw 0–32 767 from MediaRecorder.getMaxAmplitude), folding it into the + * slot currently being filled and triggering a [compressUniformly] pass when the buffer is full. */ @Synchronized fun add(amplitude: Int) { - samples.add(amplitude) - totalSamplesAdded++ - if (samples.size >= cap) { - compressUniformly() + pendingSum += amplitude + pendingCount++ + if (pendingCount >= samplesPerSlot) { + slots[slotCount++] = (pendingSum / pendingCount).toInt() + pendingSum = 0 + pendingCount = 0 + if (slotCount >= cap) { + compressUniformly() + } } } /** Clears all accumulated samples and resets the timeline counters. */ @Synchronized fun reset() { - samples.clear() - totalSamplesAdded = 0 - totalSamplesAtLastCompression = 0 + slotCount = 0 + samplesPerSlot = 1 + pendingSum = 0 + pendingCount = 0 } /** - * Produces a [targetSize]-element [IntArray] by downsampling the accumulated data - * using the **original-sample timeline** as the reference axis: + * Produces an [IntArray] covering the whole recorded timeline, at most [targetSize] elements. * - * - Fewer total samples than [targetSize]: left-aligned, remainder zero-filled. - * - Otherwise: equal-width averaging windows of `totalSamplesAdded / targetSize` - * original samples are mapped to output bins via [getAmplitudeAtOriginalIndex], - * which correctly handles the mixed compressed+raw buffer structure. + * - Fewer slots than [targetSize] while no compression has happened yet (one slot == one + * original sample): the captured slots are returned as-is, *without* padding. Every + * consumer spreads `amps` evenly across the record duration, so padding up to [targetSize] + * would claim a longer timeline than was recorded - a 5 s recording would be squeezed into + * the first `slots / targetSize` of the width with the zero tail drawn as silence. + * - Otherwise: equal-width averaging windows over slot space. Slots all cover the same amount + * of time, so slot space *is* the recording timeline and no index remapping is needed. * * Output values are in the 0–32 767 range, matching * [AppConstantsV2.WAVEFORM_AMPLITUDE_MAX_VALUE]. @@ -118,26 +124,25 @@ class RecordingWaveformBuffer(private val targetSize: Int) { */ @Synchronized fun downsampleToIntArray(): IntArray { - val result = IntArray(targetSize) - if (totalSamplesAdded == 0) return result + // The partially filled slot is included so the tail of a short recording is not lost. + val hasPending = pendingCount > 0 + val pendingAverage = if (hasPending) (pendingSum / pendingCount).toInt() else 0 + val effective = slotCount + if (hasPending) 1 else 0 + if (effective == 0) return IntArray(0) - if (totalSamplesAdded <= targetSize) { - // Short recording: copy the raw slots as-is; rest stays zero. - for (i in 0 until totalSamplesAdded) { - result[i] = getAmplitudeAtOriginalIndex(i) - } - return result + if (samplesPerSlot == 1 && effective <= targetSize) { + return IntArray(effective) { slotAt(it, pendingAverage) } } - // General case: iterate over original-sample-index space so that every output - // bin covers an equal duration of the recording regardless of compression state. - val scale = totalSamplesAdded.toFloat() / targetSize.toFloat() + val result = IntArray(targetSize) + val scale = effective.toFloat() / targetSize.toFloat() + // step is at most HALVING_CAP_MULTIPLIER, so the Int accumulator below cannot overflow. val step = scale.toInt().coerceAtLeast(1) for (i in 0 until targetSize) { var sum = 0 for (j in 0 until step) { - val origIdx = (i * scale + j).toInt().coerceIn(0, totalSamplesAdded - 1) - sum += getAmplitudeAtOriginalIndex(origIdx) + val slotIndex = (i * scale + j).toInt().coerceIn(0, effective - 1) + sum += slotAt(slotIndex, pendingAverage) } result[i] = sum / step } @@ -145,51 +150,28 @@ class RecordingWaveformBuffer(private val targetSize: Int) { } /** - * Resamples the entire buffer — which may be a mix of previously-compressed slots - * and freshly-added raw slots — down to [cap]/2 uniformly-spaced slots by iterating - * in **original-sample-index space** via [getAmplitudeAtOriginalIndex]. + * Halves the buffer by averaging adjacent slot pairs and doubling [samplesPerSlot], so every + * slot keeps covering an equal slice of the recording. O([cap]) — independent of how much has + * been recorded so far. * - * After this call every slot covers an equal `totalSamplesAdded / (cap/2)` fraction - * of the original recording timeline, eliminating the time-width mismatch that a - * naïve buffer-slot-space average would produce. + * An odd trailing slot (only reachable through an explicit call, since [add] compresses at the + * even [cap]) is carried over as-is rather than dropped; it then represents half a slot width, + * which is corrected as soon as the next pair merge covers it. */ + @Synchronized internal fun compressUniformly() { - val newSize = cap / 2 - val scale = totalSamplesAdded.toFloat() / newSize.toFloat() - val step = scale.toInt().coerceAtLeast(1) - val compressed = ArrayList(newSize) + var newSize = slotCount / 2 for (i in 0 until newSize) { - var sum = 0 - for (j in 0 until step) { - val origIdx = (i * scale + j).toInt().coerceIn(0, totalSamplesAdded - 1) - sum += getAmplitudeAtOriginalIndex(origIdx) - } - compressed.add(sum / step) + slots[i] = (slots[2 * i] + slots[2 * i + 1]) / 2 } - samples.clear() - samples.addAll(compressed) - totalSamplesAtLastCompression = totalSamplesAdded - } - - /** - * Returns the amplitude for logical original-sample index [origIdx] by mapping it - * into the correct physical buffer region: - * - * - If [origIdx] falls before [totalSamplesAtLastCompression] it is in the compressed - * region: scale to a slot in `0..[cap]/2-1`. - * - Otherwise it is in the raw region: offset by the number of compressed slots. - */ - private fun getAmplitudeAtOriginalIndex(origIdx: Int): Int { - val compressedSlotCount = if (totalSamplesAtLastCompression > 0) cap / 2 else 0 - return if (totalSamplesAtLastCompression > 0 && origIdx < totalSamplesAtLastCompression) { - val bufIdx = (origIdx.toLong() * compressedSlotCount / totalSamplesAtLastCompression) - .toInt() - .coerceIn(0, compressedSlotCount - 1) - samples[bufIdx] - } else { - val bufIdx = compressedSlotCount + (origIdx - totalSamplesAtLastCompression) - if (bufIdx < samples.size) samples[bufIdx] else 0 + if (slotCount % 2 == 1) { + slots[newSize] = slots[slotCount - 1] + newSize++ } + slotCount = newSize + samplesPerSlot *= 2 } -} + private fun slotAt(index: Int, pendingAverage: Int): Int = + if (index < slotCount) slots[index] else pendingAverage +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/WavRecorderV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/WavRecorderV2.kt index 25923530..680e16b7 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/WavRecorderV2.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/WavRecorderV2.kt @@ -8,10 +8,10 @@ import com.dimowner.audiorecorder.IntArrayList import com.dimowner.audiorecorder.exception.AlreadyRecordingException import com.dimowner.audiorecorder.exception.InvalidOutputFile import com.dimowner.audiorecorder.exception.RecorderInitException +import com.dimowner.audiorecorder.exception.RecordingException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.isActive @@ -26,6 +26,19 @@ import java.util.TimerTask import javax.inject.Inject import javax.inject.Singleton +/** + * RIFF/WAV describes chunk sizes in 32-bit fields, so a WAV file cannot represent more than 4 GiB + * of audio - and plenty of parsers read those fields as *signed* 32-bit, which halves the usable + * range again. Past the limit the header silently wraps around and the whole recording becomes + * unreadable, which is exactly what a multi-hour session runs into: 44.1 kHz 16-bit mono fills + * 2 GiB in ~6.7 h, 48 kHz stereo in ~3.1 h. + * + * When the data chunk approaches this size the current file is closed off exactly like a + * max-duration hit, so [AudioRecordingService] rolls over into the next numbered part and no + * audio is lost. + */ +private const val MAX_WAV_DATA_BYTES = 2_147_000_000L + @Singleton class WavRecorderV2 @Inject constructor( private val coroutineScope: CoroutineScope, @@ -63,12 +76,12 @@ class WavRecorderV2 @Inject constructor( sampleRate: Int, bitrate: Int, maxRecordingDurationMills: Int, - audioSource: Int, + audioInput: AudioInput, ): Boolean { Timber.d( "WavRecorderV2.startRecording outputFile: ${outputFile.absolutePath} channelCount: $channelCount" + " sampleRate: $sampleRate bitrate: $bitrate maxRecordingDurationMills: $maxRecordingDurationMills" + - " audioSource: $audioSource" + " audioInput: $audioInput" ) if (_isRecording) { Timber.e("Recording is already in progress.") @@ -111,7 +124,9 @@ class WavRecorderV2 @Inject constructor( .coerceAtMost(bufferSize) val recorder = try { - AudioRecord(audioSource, sampleRate, channelConfig, audioEncoding, bufferSize) + AudioRecordFactory.create( + audioInput, sampleRate, channelConfig, audioEncoding, bufferSize + ) } catch (e: SecurityException) { Timber.e(e, "AudioRecord creation failed due to missing permission") emitEvent(RecorderEvent.OnError(RecorderInitException())) @@ -120,6 +135,11 @@ class WavRecorderV2 @Inject constructor( Timber.e(e, "AudioRecord creation failed") emitEvent(RecorderEvent.OnError(RecorderInitException())) return false + } catch (e: UnsupportedOperationException) { + // AudioRecord.Builder rejects a system-playback configuration the device cannot honour. + Timber.e(e, "AudioRecord creation failed for $audioInput") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + return false } if (recorder.state != AudioRecord.STATE_INITIALIZED) { @@ -192,35 +212,57 @@ class WavRecorderV2 @Inject constructor( val amplitude = calculateAmplitude(buffer, readResult) synchronized(amplitudesBuffer) { amplitudesBuffer.add(amplitude) } - // Check max duration - if (maxDurationMills > 0 && durationMills >= maxDurationMills) { - Timber.d("Max recording duration reached. Stop recording") - // Signal the loop to stop; hardware teardown happens via stopHardware(). - // OnStopRecording and OnMaxDurationReached are both emitted after - // the WAV header is written in-place, so consumers always see a complete file. + // Check max duration, and the size the WAV container itself can describe + val durationLimitReached = + maxDurationMills > 0 && durationMills >= maxDurationMills + val containerFull = totalBytesWritten >= MAX_WAV_DATA_BYTES + if (durationLimitReached || containerFull) { + if (containerFull) { + Timber.d("WAV container size limit reached. Start a new part") + } else { + Timber.d("Max recording duration reached. Stop recording") + } + // Signal the loop to stop; the hardware is torn down in the finally + // below, once the PCM stream has been closed. OnStopRecording and + // OnMaxDurationReached are both emitted after the WAV header is + // written in-place, so consumers always see a complete file. maxDurationReached = true _isRecording = false _isPaused = false - stopHardware() break } - } else if (readResult == AudioRecord.ERROR_INVALID_OPERATION) { - Timber.e("AudioRecord read error: ERROR_INVALID_OPERATION") - break - } else if (readResult == AudioRecord.ERROR_BAD_VALUE) { - Timber.e("AudioRecord read error: ERROR_BAD_VALUE") + } else if (readResult < 0) { + // Covers ERROR_DEAD_OBJECT / ERROR too: once the AudioRecord is dead, + // read() returns immediately, so anything that doesn't break out here + // spins the loop at full CPU for the rest of the session. + Timber.e("AudioRecord read error: $readResult") break } } } catch (e: IOException) { + // RecordingException, not RecorderInitException: the recorder did start, so this + // must not be treated as a failed start (which discards the file). Timber.e(e, "Error writing PCM data") - emitEvent(RecorderEvent.OnError(RecorderInitException())) + emitEvent(RecorderEvent.OnError(RecordingException())) } finally { try { fos?.close() } catch (e: IOException) { Timber.e(e, "Error closing output file stream") } + // The hardware is released here, on this background thread, and never from + // stopRecording(): AudioRecord.stop() is a synchronous binder call into + // audioserver that does not return until the input stream has been torn down, + // which takes seconds on some devices. The loop has already left `recorder` + // alone by this point, so nothing can read from a released instance. + // The timer is cancelled and the flags are cleared here as well because the + // max-duration and error paths leave the loop without going through + // stopRecording(): the timer would leak, and _isRecording staying true would + // keep the recorder wedged, rejecting every later start as "already recording". + _isRecording = false + _isPaused = false + stopRecordingTimer() + stopHardware(recorder) } // Write the real WAV header in-place now that we know the final audio length. @@ -251,7 +293,7 @@ class WavRecorderV2 @Inject constructor( } } catch (e: IOException) { Timber.e(e, "Error writing WAV header") - emitEvent(RecorderEvent.OnError(RecorderInitException())) + emitEvent(RecorderEvent.OnError(RecordingException())) } } @@ -290,31 +332,31 @@ class WavRecorderV2 @Inject constructor( Timber.e("Recording has already stopped or hasn't started") return false } + // Flip the flags only; the recording coroutine finishes its current read(), flushes the + // PCM data, writes the WAV header in-place, emits OnStopRecording and releases the + // hardware. No native call runs on this thread - this is reached from the main thread + // (the stop button, the notification action and the MediaProjection callback), and + // AudioRecord.stop() blocks there long enough to ANR. _isRecording = false _isPaused = false synchronized(amplitudesBuffer) { amplitudesBuffer.clear() } - // Tear down the hardware; the recording coroutine will finish its current - // read(), flush PCM data, write the WAV header in-place, and then emit OnStopRecording. - return stopHardware() + return true } /** - * Stops and releases [audioRecord]. Safe to call from any thread. - * Returns true if the hardware was stopped successfully. + * Stops and releases the [recorder] this recording coroutine owns. Invoked from the + * coroutine's teardown. Releases the passed instance (not the field) so a rapid stop->start + * that has already swapped in a new [AudioRecord] is not torn down by the previous run; the + * field is only cleared if it still points at this recorder. */ - private fun stopHardware(): Boolean { - return try { - audioRecord?.let { - it.stop() - it.release() - true - } ?: false + private fun stopHardware(recorder: AudioRecord) { + try { + recorder.stop() } catch (e: IllegalStateException) { Timber.e(e, "stopHardware() problems") - audioRecord?.release() - false } finally { - audioRecord = null + recorder.release() + if (audioRecord === recorder) audioRecord = null } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/AudioSource.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/AudioSource.kt index d7f56372..8c5c372f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/AudioSource.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/AudioSource.kt @@ -17,16 +17,49 @@ package com.dimowner.audiorecorder.v2.data.model import android.media.MediaRecorder +import android.os.Build + +/** + * Sentinel stored for [AudioSource.SYSTEM_AUDIO]. + * + * Every other entry persists its [MediaRecorder.AudioSource] constant, and those are small + * non-negative numbers, so a negative value cannot collide with one - now or when the platform + * adds more. It must never reach [android.media.AudioRecord]; the recorders take an + * [com.dimowner.audiorecorder.v2.audio.AudioInput] precisely so that it cannot. + */ +private const val SYSTEM_AUDIO_VALUE = -1000 enum class AudioSource(val value: Int) { DEFAULT(MediaRecorder.AudioSource.DEFAULT), MIC(MediaRecorder.AudioSource.MIC), VOICE_COMMUNICATION(MediaRecorder.AudioSource.VOICE_COMMUNICATION), - UNPROCESSED(MediaRecorder.AudioSource.UNPROCESSED); - + UNPROCESSED(MediaRecorder.AudioSource.UNPROCESSED), + + /** + * Audio played by other apps, captured through the AudioPlaybackCapture API rather than from + * a microphone. Requires Android 10 (API 29) and a user-granted MediaProjection. + */ + SYSTEM_AUDIO(SYSTEM_AUDIO_VALUE); + + /** Whether this source captures other apps' playback instead of a microphone. */ + val isSystemAudio: Boolean get() = this == SYSTEM_AUDIO + companion object { fun fromValue(value: Int): AudioSource { return entries.find { it.value == value } ?: DEFAULT } } } + +/** + * `true` when this device can capture the audio other apps are playing. + * + * The AudioPlaybackCapture API arrived in Android 10 (API 29) and is available on every + * device from that release, so unlike Opus encoding no codec probing is needed. What it + * actually yields still depends on the playing app: only `MEDIA`, `GAME` and `UNKNOWN` + * playback is capturable, and an app can opt out entirely with + * `android:allowAudioPlaybackCapture="false"`, so a recording may legitimately come out + * silent. + */ +fun isSystemAudioCaptureSupported(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/FormatConfig.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/FormatConfig.kt index 81ac2a9e..4c0137c8 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/FormatConfig.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/FormatConfig.kt @@ -51,4 +51,5 @@ data class FormatConfig( fun isChannelCountSupported(channelCount: ChannelCount): Boolean = channelCount in supportedChannelCounts + } \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/AppModule.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/AppModule.kt index 4b843329..eba1a8da 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/di/AppModule.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/AppModule.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.media3.common.util.UnstableApi import com.dimowner.audiorecorder.audio.player.ExoAudioPlayer import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.v2.analytics.AnalyticsTracker import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher import dagger.Module @@ -36,8 +37,11 @@ class AppModule { @UnstableApi @Singleton @Provides - fun providePlayerContractNew(@ApplicationContext context: Context): PlayerContractNew.Player { - return ExoAudioPlayer(context) + fun providePlayerContractNew( + @ApplicationContext context: Context, + analyticsTracker: AnalyticsTracker, + ): PlayerContractNew.Player { + return ExoAudioPlayer(context, analyticsTracker) } /** diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/RecordingSettingsEntryPoint.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/RecordingSettingsEntryPoint.kt new file mode 100644 index 00000000..120f30fe --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/RecordingSettingsEntryPoint.kt @@ -0,0 +1,28 @@ +package com.dimowner.audiorecorder.v2.di + +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Exposes the recording preferences to classes Hilt cannot inject into. + * + * The one caller today is [com.dimowner.audiorecorder.app.TransparentRecordingActivity], the + * widget and shortcut entry point: it is a plain [android.app.Activity], yet it has to know + * whether the next recording captures system audio, because collecting MediaProjection consent + * needs an Activity. + * + * Usage: + * ```kotlin + * val entryPoint = EntryPointAccessors.fromApplication( + * context.applicationContext, + * RecordingSettingsEntryPoint::class.java + * ) + * ``` + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface RecordingSettingsEntryPoint { + fun prefsV2(): PrefsV2 +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 40f5152b..c2f43fc0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -60,6 +60,7 @@ لا يمكن أن يكون الاسم فارغًا إذن الميكروفون مطلوب لتسجيل الصوت. يرجى منح الإذن لاستخدام هذه الميزة. + لم يتم منح إذن التقاط صوت النظام. اسمح به لتسجيل ما تشغّله التطبيقات الأخرى. تراجع @@ -326,6 +327,7 @@

الميكروفون: إعداد الميكروفون القياسي. الأفضل للتسجيل العام. يوازن مستوى الصوت تلقائيًا مع التقاط الأجواء الطبيعية للمحيط.

اتصال صوتي: مصمم خصيصًا لوضوح صوت الإنسان. الأفضل مع البلوتوث لأنه يفعّل إلغاء الضوضاء والصدى.

غير معالج: يلتقط الصوت الخام دون أي مرشحات أو تعديلات من النظام. بدون إزالة ضوضاء الخلفية وبدون موازنة مستوى الصوت. الأفضل للاستخدام الاحترافي وعالي الدقة. +

صوت النظام: يسجّل ما تشغّله التطبيقات الأخرى بدلاً من الميكروفون. يمكن للتطبيقات رفض تسجيلها، لذا يبقى المحتوى المحمي والمكالمات بلا صوت. ]]> 3gp صيغة حاوية وسائط متعددة طُوِّرت لخدمات الاتصالات المتنقلة. استخدمها إذا كنت بحاجة إلى توفير المساحة. صيغة M4a مشفرة بترميز AAC الصوتي وتتميز بجودة جيدة وحجم صغير. (موصى بها) @@ -462,6 +464,7 @@ الميكروفون اتصال صوتي غير معالج + صوت النظام جارٍ تحديث قاعدة البيانات diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index b1121e28..3b097ab1 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -39,6 +39,7 @@ Грешка при операцията с файла. Опитайте отново Името не може да бъде празно Необходимо е разрешение за микрофон за запис на звук. Моля, предоставете разрешението, за да използвате тази функция. + Разрешението за записване на системния звук не е предоставено. Разрешете го, за да записвате какво възпроизвеждат други приложения. ОТМЯНА Операцията е неуспешна @@ -273,6 +274,7 @@

Микрофон: Стандартната настройка за микрофон. Най-добра за обща употреба. Балансира автоматично силата на гласа, като улавя естествената атмосфера.

Гласова комуникация: Специално предназначена за яснота на човешкия глас. Най-добра за Bluetooth, тъй като активира шумоподтискане и ехо отмяна.

Необработен: Улавя суров звук без системни филтри или корекции. Без премахване на фоновия шум и без изравняване на силата. Най-добра за висококачествена или професионална употреба. +

Системен звук: Записва това, което възпроизвеждат други приложения, вместо звука от микрофона. Приложенията могат да откажат да бъдат записвани, затова защитеното съдържание и обажданията остават беззвучни. ]]> 3gp е мултимедиен контейнерен формат, разработен за мобилни телекомуникационни услуги. Използвайте го, ако трябва да спестите място. M4a форматът е кодиран с AAC аудио кодек, осигурява добро качество и малък размер. (препоръчан) @@ -411,6 +413,7 @@ Микрофон Гласова комуникация Необработен + Системен звук Обновяване на базата данни diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 200e4668..d2d5e8b2 100755 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -50,6 +50,7 @@ El nom no pot estar buit Es necessita el permís del micròfon per gravar àudio. Concediu el permís per utilitzar aquesta funció. + No s\'ha concedit el permís per capturar l\'àudio del sistema. Concediu-lo per gravar el que reprodueixen altres aplicacions. DESFÉS @@ -285,6 +286,7 @@

Micròfon: La configuració estàndard del micròfon. Millor per a gravació general. Equilibra el volum de la veu automàticament mentre capta l\'atmosfera natural dels voltants.

Comunicació de veu: Dissenyat específicament per a la claredat de la veu humana. Millor per a Bluetooth, ja que activa la cancel·lació de soroll i d\'eco.

Sense processar: Captura l\'àudio en brut sense cap filtre ni ajust del sistema. Sense eliminació de soroll de fons ni anivellament del volum. Millor per a ús professional o d\'alta fidelitat. +

Àudio del sistema: Grava el que reprodueixen altres aplicacions en lloc del micròfon. Les aplicacions poden rebutjar ser gravades, per la qual cosa el contingut protegit i les trucades queden en silenci. ]]> 3gp format de contenidor multimèdia desenvolupat per a serveis de telecomunicacions mòbils. Feu-lo servir si necessiteu estalviar espai. @@ -430,6 +432,7 @@ Micròfon Comunicació de veu Sense processar + Àudio del sistema Actualització de la base de dades en curs diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ce124d6a..855b78fe 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -53,6 +53,7 @@ Name darf nicht leer sein Die Mikrofonberechtigung ist erforderlich, um Audio aufzunehmen. Bitte erteilen Sie die Berechtigung, um diese Funktion zu nutzen. + Die Berechtigung zum Aufnehmen von Systemaudio wurde nicht erteilt. Erteilen Sie sie, um aufzunehmen, was andere Apps wiedergeben. RÜCKGÄNGIG @@ -290,6 +291,7 @@

Mikrofon: Die Standardmikrofoneinstellung. Am besten für allgemeine Aufnahmen. Die Lautstärke der Stimme wird automatisch angepasst, während die natürliche Umgebung erfasst wird.

Sprachkommunikation: Speziell für klare Sprachverständlichkeit ausgelegt. Ideal für Bluetooth, da Rausch- und Echounterdrückung aktiviert werden.

Unverarbeitet: Nimmt Rohaudio ohne Systemfilter oder Anpassungen auf. Keine Hintergrundgeräuschunterdrückung und keine Lautstärkepegelung. Ideal für High-Fidelity- oder professionelle Nutzung. +

Systemaudio: Nimmt anstelle des Mikrofons auf, was andere Apps wiedergeben. Apps können die Aufnahme ablehnen, daher bleiben geschützte Inhalte und Anrufe stumm. ]]> 3gp ist ein Multimedia-Containerformat, das für mobile Telekommunikationsdienste entwickelt wurde. Verwenden Sie es, wenn Sie Speicherplatz sparen müssen. M4a-Format ist mit dem AAC-Audiocodec kodiert und hat gute Qualität bei kleiner Dateigröße. (empfohlen) @@ -432,6 +434,7 @@ Mikrofon Sprachkommunikation Unverarbeitet + Systemaudio Datenbankaktualisierung läuft diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0e741221..14ef46a2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -52,6 +52,7 @@ El nombre no puede estar vacío Se requiere el permiso de micrófono para grabar audio. Por favor conceda el permiso para usar esta función. + No se concedió el permiso para capturar el audio del sistema. Concédalo para grabar lo que reproducen otras aplicaciones. DESHACER @@ -289,6 +290,7 @@

Micrófono: La configuración estándar del micrófono. Ideal para grabaciones generales. Balancea automáticamente el volumen de voz mientras captura el ambiente natural del entorno.

Comunicación de voz: Diseñado específicamente para la claridad de la voz humana. Ideal para Bluetooth, ya que activa la cancelación de ruido y eco.

Sin procesar: Captura audio sin procesar sin ningún filtro o ajuste del sistema. Sin eliminación de ruido de fondo ni nivelación de volumen. Ideal para uso profesional o de alta fidelidad. +

Audio del sistema: Graba lo que reproducen otras aplicaciones en lugar del micrófono. Las aplicaciones pueden rechazar ser grabadas, por lo que el contenido protegido y las llamadas permanecen en silencio. ]]> 3gp es un formato contenedor de multimedia desarrollado para servicios de telecomunicación móvil. Úsalo si necesitas ahorrar espacio. El formato M4a codificado con el códec de audio AAC tiene buena calidad y ocupa poco espacio. (recomendado) @@ -432,6 +434,7 @@ Micrófono Comunicación de voz Sin procesar + Audio del sistema Actualización de base de datos en progreso diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 283a8d43..2981b753 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -38,6 +38,7 @@ Opération de fichier échouée. Veuillez réessayer Le nom ne peut pas être vide La permission du microphone est requise pour enregistrer l\'audio. Veuillez accorder la permission pour utiliser cette fonctionnalité. + L\'autorisation de capturer l\'audio du système n\'a pas été accordée. Accordez-la pour enregistrer ce que diffusent les autres applications. ANNULER L\'opération a échoué @@ -268,6 +269,7 @@

Microphone : Le réglage standard du microphone. Idéal pour un enregistrement général. Il équilibre automatiquement le volume de la voix tout en capturant l\'atmosphère naturelle de l\'environnement.

Communication vocale : Spécialement conçu pour la clarté de la voix humaine. Idéal pour le Bluetooth car il active la suppression du bruit et de l\'écho.

Non traité : Capture l\'audio brut sans aucun filtre ni réglage système. Sans suppression du bruit de fond ni nivellement du volume. Idéal pour une utilisation haute-fidélité ou professionnelle. +

Audio du système : Enregistre ce que diffusent les autres applications au lieu du microphone. Les applications peuvent refuser d\'être enregistrées, le contenu protégé et les appels restent donc silencieux. ]]> 3gp : format multimédia développé pour les communications téléphoniques. Utilisez ceci si vous avez besoin de sauver de l\'espace. M4a : encodé avec le codec audio AAC, possède une bonne qualité pour une petite taille. (recommandé) @@ -399,6 +401,7 @@ Microphone Communication vocale Non traité + Audio du système Mise à jour de la base de données en cours diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 82b72627..59ed3cfd 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -53,6 +53,7 @@ Il nome non può essere vuoto Il permesso per il microfono è necessario per registrare audio. Concedi il permesso per utilizzare questa funzione. + Il permesso per acquisire l\'audio di sistema non è stato concesso. Concedilo per registrare ciò che riproducono le altre app. ANNULLA @@ -290,6 +291,7 @@

Microfono: L\'impostazione standard del microfono. Ottima per la registrazione generale. Bilancia automaticamente il volume della voce catturando l\'atmosfera naturale dell\'ambiente.

Comunicazione vocale: Progettato specificamente per la chiarezza della voce umana. Ottimo per il Bluetooth in quanto attiva la cancellazione del rumore e dell\'eco.

Non elaborato: Cattura l\'audio grezzo senza filtri o regolazioni di sistema. Nessuna rimozione del rumore di fondo e nessun livellamento del volume. Ideale per uso professionale o ad alta fedeltà. +

Audio di sistema: Registra ciò che riproducono le altre app invece del microfono. Le app possono rifiutare di essere registrate, quindi i contenuti protetti e le chiamate restano in silenzio. ]]> 3gp è un formato contenitore multimediale sviluppato per i servizi di telecomunicazione mobile. Usalo per risparmiare spazio. M4a è codificato con il codec audio AAC, ha buona qualità e dimensioni ridotte. (consigliato) @@ -432,6 +434,7 @@ Microfono Comunicazione vocale Non elaborato + Audio di sistema Aggiornamento database in corso diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e445d55e..a900e12e 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -54,6 +54,7 @@ 名前を入力してください 音声を録音するにはマイクの権限が必要です。この機能を使用するには権限を許可してください。 + システム音声を取得する権限が許可されていません。他のアプリが再生している音を録音するには権限を許可してください。 元に戻す @@ -291,6 +292,7 @@

マイク:標準的なマイク設定です。一般的な録音に最適です。音声の音量を自動的に調整しながら、周囲の自然な雰囲気を捉えます。

音声通話:人の声の明瞭さを重視した設定です。ノイズキャンセルとエコーキャンセルが有効になるため、Bluetoothに最適です。

未処理:システムのフィルターや調整なしに生のオーディオを録音します。バックグラウンドノイズの除去や音量の均一化は行われません。高品質またはプロフェッショナルな用途に最適です。 +

システム音声:マイクの代わりに、他のアプリが再生している音を録音します。アプリは録音を拒否できるため、保護されたコンテンツや通話は無音になります。 ]]> 3gpはモバイル通信サービスのために開発されたマルチメディアコンテナフォーマットです。容量を節約したい場合に使用してください。 M4aフォーマットはAACオーディオコーデックでエンコードされており、高音質かつファイルサイズが小さいです。(推奨) @@ -433,6 +435,7 @@ マイク 音声通話 未処理 + システム音声 データベースを更新中です diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 20d21855..b3187368 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -52,6 +52,7 @@ 이름을 입력해 주세요 오디오를 녹음하려면 마이크 권한이 필요합니다. 이 기능을 사용하려면 권한을 허용해 주세요. + 시스템 오디오를 캡처할 권한이 허용되지 않았습니다. 다른 앱에서 재생 중인 소리를 녹음하려면 권한을 허용해 주세요. 실행 취소 @@ -282,6 +283,7 @@

마이크: 표준 마이크 설정입니다. 일반적인 녹음에 가장 적합합니다. 음성 볼륨을 자동으로 조절하면서 주변 환경의 자연스러운 분위기를 포착합니다.

음성 통신: 사람의 목소리 명료성을 위해 특별히 설계되었습니다. 노이즈 및 에코 제거 기능이 활성화되므로 블루투스에 가장 적합합니다.

미처리: 시스템 필터나 조정 없이 원시 오디오를 녹음합니다. 배경 소음 제거나 볼륨 균일화가 없습니다. 고음질 또는 전문적인 용도에 가장 적합합니다. +

시스템 오디오: 마이크 대신 다른 앱에서 재생 중인 소리를 녹음합니다. 앱이 녹음을 거부할 수 있으므로 보호된 콘텐츠와 통화는 소리가 녹음되지 않습니다. ]]> 3gp는 모바일 통신 서비스를 위해 개발된 멀티미디어 컨테이너 형식입니다. 공간을 절약해야 할 때 사용하세요. M4a 형식은 AAC 오디오 코덱으로 인코딩되어 음질이 좋고 파일 크기가 작습니다. (추천) @@ -424,6 +426,7 @@ 마이크 음성 통신 미처리 + 시스템 오디오 데이터베이스 업데이트 중입니다 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6b9c2b66..bda083e0 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -58,6 +58,7 @@ Nazwa nie może być pusta Uprawnienie do mikrofonu jest wymagane do nagrywania dźwięku. Przyznaj uprawnienie, aby korzystać z tej funkcji. + Nie przyznano uprawnienia do przechwytywania dźwięku systemowego. Przyznaj je, aby nagrywać dźwięk odtwarzany przez inne aplikacje. COFNIJ @@ -309,6 +310,7 @@

Mikrofon: Standardowe ustawienie mikrofonu. Najlepsze do nagrywania ogólnego. Automatycznie balansuje głośność głosu, zachowując naturalną atmosferę otoczenia.

Komunikacja głosowa: Zaprojektowane specjalnie dla wyrazistości ludzkiego głosu. Najlepsze dla Bluetooth — aktywuje redukcję szumów i echa.

Bez przetwarzania: Rejestruje surowe audio bez żadnych filtrów ani regulacji systemowych. Brak redukcji szumów tła i wyrównywania głośności. Najlepsze do nagrań wysokiej wierności lub zastosowań profesjonalnych. +

Dźwięk systemowy: Nagrywa dźwięk odtwarzany przez inne aplikacje zamiast dźwięku z mikrofonu. Aplikacje mogą nie zezwolić na nagrywanie, więc treści chronione i połączenia pozostają ciche. ]]> 3gp to format kontenera multimedialnego opracowany dla mobilnych usług telekomunikacyjnych. Użyj go, jeśli potrzebujesz zaoszczędzić miejsce. M4a to format zakodowany z kodekiem audio AAC o dobrej jakości i małym rozmiarze. (zalecane) @@ -451,6 +453,7 @@ Mikrofon Komunikacja głosowa Bez przetwarzania + Dźwięk systemowy Aktualizacja bazy danych w toku diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 24f3abee..5b6e08be 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -51,6 +51,7 @@ O nome não pode ficar vazio A permissão do microfone é necessária para gravar áudio. Por favor, conceda a permissão para usar esse recurso. + A permissão para capturar o áudio do sistema não foi concedida. Conceda-a para gravar o que outros aplicativos estão reproduzindo. DESFAZER @@ -288,6 +289,7 @@

Microfone: A configuração padrão do microfone. Ideal para gravações em geral. Equilibra automaticamente o volume da voz enquanto capta a atmosfera natural do ambiente.

Comunicação de Voz: Desenvolvido especificamente para clareza da voz humana. Ideal para Bluetooth, pois ativa cancelamento de ruído e eco.

Sem processamento: Captura áudio bruto sem filtros ou ajustes do sistema. Sem remoção de ruído de fundo e sem nivelamento de volume. Ideal para uso profissional ou de alta fidelidade. +

Áudio do sistema: Grava o que outros aplicativos estão reproduzindo em vez do microfone. Os aplicativos podem recusar a gravação, então o conteúdo protegido e as chamadas ficam em silêncio. ]]> 3gp é um formato de contêiner multimídia desenvolvido para serviços de telecomunicações móveis. Use quando precisar economizar espaço. M4a é codificado com o codec de áudio AAC, com boa qualidade e tamanho reduzido. (recomendado) @@ -430,6 +432,7 @@ Microfone Comunicação de Voz Sem processamento + Áudio do sistema Atualizando banco de dados… diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 6bf26a07..04027319 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -54,6 +54,7 @@ O nome não pode estar vazio É necessária permissão para aceder ao microfone. Por favor, conceda a permissão para utilizar esta funcionalidade. + A permissão para captar o áudio do sistema não foi concedida. Conceda-a para gravar o que as outras aplicações estão a reproduzir. DESFAZER @@ -291,6 +292,7 @@

Microfone: A configuração de microfone padrão. Ideal para gravação geral. Equilibra automaticamente o volume da voz enquanto capta a atmosfera natural do ambiente.

Comunicação de Voz: Especificamente concebido para a clareza da voz humana. Ideal para Bluetooth, pois ativa o cancelamento de ruído e eco.

Não processado: Capta áudio em bruto sem filtros ou ajustes do sistema. Sem remoção de ruído de fundo nem nivelamento de volume. Ideal para utilização de alta fidelidade ou profissional. +

Áudio do sistema: Grava o que as outras aplicações estão a reproduzir em vez do microfone. As aplicações podem recusar a gravação, por isso o conteúdo protegido e as chamadas ficam em silêncio. ]]> 3gp é um formato de contentor multimédia desenvolvido para serviços de telecomunicações móveis. Use-o se necessitar de poupar espaço. M4a é codificado com o codec de áudio AAC, oferece boa qualidade e tamanho reduzido. (recomendado) @@ -433,6 +435,7 @@ Microfone Comunicação de Voz Não processado + Áudio do sistema Atualização da base de dados em curso diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f75b95ae..b5117874 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -41,6 +41,7 @@ Ошибка операции с файлом. Попробуйте ещё раз Имя не может быть пустым Для записи аудио необходимо разрешение на использование микрофона. Пожалуйста, предоставьте разрешение для использования этой функции. + Разрешение на запись системного звука не предоставлено. Предоставьте его, чтобы записывать звук из других приложений. ОТМЕНИТЬ Операция не выполнена @@ -287,6 +288,7 @@

Микрофон: Стандартная настройка микрофона. Оптимальна для обычной записи. Автоматически балансирует уровень голоса, сохраняя естественную атмосферу окружения.

Голосовая связь: Специально разработана для чёткости передачи человеческого голоса. Лучший выбор для Bluetooth, так как активирует шумо- и эхоподавление.

Без обработки: Захватывает сырой звук без каких-либо системных фильтров или регулировок. Без удаления фонового шума и выравнивания громкости. Лучший выбор для записи высокой точности или профессионального использования. +

Системный звук: Записывает звук из других приложений вместо микрофона. Приложения могут запретить запись, поэтому защищённый контент и звонки останутся беззвучными. ]]> 3gp - мультимедийный контейнер, разработанный для услуг мобильной связи. Используйте его, если вам нужно сэкономить место. M4a формат кодируется аудио кодеком AAC, имеет хорошее качество и небольшой размер. (рекомендовано) @@ -425,6 +427,7 @@ Микрофон Голосовая связь Без обработки + Системный звук Обновление базы данных diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index a65904a7..04e693b6 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -51,6 +51,7 @@ İsim boş bırakılamaz Ses kaydetmek için mikrofon iznine ihtiyaç var. Bu özelliği kullanmak için izni onaylayın. + Sistem sesini yakalama izni verilmedi. Diğer uygulamaların çaldığı sesi kaydetmek için izni onaylayın. GERİ AL @@ -287,6 +288,7 @@

Mikrofon: Standart mikrofon ayarıdır. Genel kayıt için en iyisidir. Çevresindeki doğal atmosferi yakalarken ses seviyesini otomatik olarak dengeler.

Ses İletişimi: İnsan sesinin netliği için özel olarak tasarlanmıştır. Gürültü ve yankı iptalini etkinleştirdiğinden Bluetooth için en iyisidir.

İşlenmemiş: Sistem filtreleri veya ayarlamaları olmadan ham sesi yakalar. Arka plan gürültü giderme ve ses seviyesi dengeleme yoktur. Yüksek kaliteli veya profesyonel kullanım için en iyisidir. +

Sistem sesi: Mikrofon yerine diğer uygulamaların çaldığı sesi kaydeder. Uygulamalar kaydedilmeyi reddedebilir, bu nedenle korumalı içerik ve aramalar sessiz kalır. ]]> 3gp mobil telekomünikasyon servisleri için geliştirilmiş bir multimedya formatıdır. Alandan tasarruf etmeniz gerekiyorsa kullanabilirsiniz. M4a formatı AAC ses çözücüsüyle kodlanmıştır, iyi kalitede ve düşük boyutludur. (önerilen) @@ -425,6 +427,7 @@ Mikrofon Ses İletişimi İşlenmemiş + Sistem sesi Veritabanı güncelleniyor diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 88070a93..52aa2cb6 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -56,6 +56,7 @@ Операція з файлом не вдалася. Спробуйте ще раз Назва не може бути порожньою Для запису звуку необхідний дозвіл на використання мікрофона. Надайте дозвіл, щоб скористатися цією функцією. + Дозвіл на запис системного звуку не надано. Надайте його, щоб записувати звук з інших застосунків. СКАСУВАТИ Операція не вдалася @@ -287,6 +288,7 @@

Мікрофон: Стандартне налаштування мікрофона. Найкраще для звичайного запису. Автоматично балансує гучність голосу, зберігаючи природну атмосферу оточення.

Голосовий зв\'язок: Спеціально розроблено для чіткості людського голосу. Найкраще для Bluetooth, оскільки активує шумо- і ехоподавлення.

Необроблений: Захоплює необроблений звук без будь-яких системних фільтрів або налаштувань. Без видалення фонового шуму та вирівнювання гучності. Найкраще для Hi-Fi або професійного використання. +

Системний звук: Записує звук з інших застосунків замість мікрофона. Застосунки можуть заборонити запис, тому захищений вміст і дзвінки залишаються беззвучними. ]]> 3gp - мультимедійний контейнер, розроблений для послуг мобільного зв\'язку. Використовуйте його, якщо вам потрібно заощадити місце. M4a - формат кодується аудіо кодеком AAC, має хорошу якість і невеликий розмір. (рекомендовано) @@ -420,6 +422,7 @@ Мікрофон Голосовий зв\'язок Необроблений + Системний звук Виконується оновлення бази даних diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f29fa76a..9e18bcf2 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -51,6 +51,7 @@ 名稱不能為空 需要麥克風權限才能錄製音訊,請授予權限以使用此功能。 + 未授予擷取系統音訊的權限。請授予權限以錄製其他應用程式正在播放的聲音。 撤銷 @@ -285,6 +286,7 @@

麥克風:標準麥克風設定,適合一般錄製。自動平衡人聲音量,同時捕捉周圍環境聲。

語音通訊:專為人聲清晰度設計,最適合藍牙裝置,可啟動降噪與回音消除功能。

未處理:捕捉原始音訊,不套用任何系統濾波器或調整,無背景噪音消除及音量調節,適合高保真或專業用途。 +

系統音訊:錄製其他應用程式正在播放的聲音,而不是麥克風的聲音。應用程式可以拒絕被錄製,因此受保護的內容與通話會保持無聲。 ]]> 3gp 為行動電信服務而設的多媒體封裝格式,需要節省空間時使用。 M4a 採用 AAC 音訊編碼,兼具較小體積與較佳品質。(建議) @@ -422,6 +424,7 @@ 麥克風 語音通訊 未處理 + 系統音訊 資料庫更新中 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index eb3fb401..6e6728d4 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -35,6 +35,7 @@ 文件操作失败,请重试 名称不能为空 录音需要麦克风权限。请授予权限以使用此功能。 + 未授予捕获系统音频的权限。请授予权限以录制其他应用正在播放的声音。 撤销 操作失败 @@ -253,6 +254,7 @@

麦克风:标准麦克风设置,适用于一般录音,可自动平衡人声音量同时捕捉周围环境。

语音通话:专为人声清晰度设计,最适合蓝牙使用,可激活噪声和回声消除。

未处理:无任何系统过滤或调整地捕捉原始音频,无背景噪声消除和音量调平,适合高保真或专业用途。 +

系统音频:录制其他应用正在播放的声音,而不是麦克风的声音。应用可以拒绝被录制,因此受保护的内容和通话将保持静音。 ]]> 3gp 是为移动通信服务开发的多媒体格式,如需节省空间请使用此格式。 M4a 格式采用 AAC 音频编解码器编码,质量好,体积小。(推荐) @@ -388,6 +390,7 @@ 麦克风 语音通话 未处理 + 系统音频 数据库更新中 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7666fed8..a9b9798a 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,6 +54,7 @@ Name cannot be empty Microphone permission is required to record audio. Please grant the permission to use this feature. + Permission to capture system audio was not granted. Allow it to record what other apps are playing. UNDO @@ -291,6 +292,7 @@

Mic: The standard microphone setting. Best for general recording. It balances voice volume automatically while capturing the natural atmosphere of surroundings.

Voice Communication: Specifically designed for clarity of the human voice. Best for Bluetooth as it activates noise and echo cancellation.

Unprocessed: Captures raw audio without any system filters or adjustments. No background noise removal and no volume leveling. Best for high-fidelity or professional use. +

System Audio: Records what other apps are playing instead of the microphone. Apps can refuse to be recorded, so protected content and calls stay silent. ]]> 3gp is a multimedia container format developed for mobile telecommunication services. Use it if you need to save space. M4a format is encoded with AAC audio codec has good quality and small size. (recommended) @@ -438,6 +440,7 @@ Mic Voice Communication Unprocessed + System Audio Database update in progress diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/analytics/FailureAnalyticsTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/analytics/FailureAnalyticsTest.kt new file mode 100644 index 00000000..0d6a293b --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/analytics/FailureAnalyticsTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.analytics + +import com.dimowner.audiorecorder.exception.AlreadyRecordingException +import com.dimowner.audiorecorder.exception.CantCreateFileException +import com.dimowner.audiorecorder.exception.CantProcessRecord +import com.dimowner.audiorecorder.exception.InvalidOutputFile +import com.dimowner.audiorecorder.exception.NoSpaceAvailableException +import com.dimowner.audiorecorder.exception.PlayerDataSourceException +import com.dimowner.audiorecorder.exception.PlayerInitException +import com.dimowner.audiorecorder.exception.RecorderInitException +import com.dimowner.audiorecorder.exception.RecordingException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +class FailureAnalyticsTest { + + // ------------------------------------------------------------------------- + // RecordingStartFailureReason.fromException + // ------------------------------------------------------------------------- + + @Test + fun `recording reason maps every exception a recorder can report`() { + assertEquals( + RecordingStartFailureReason.RECORDER_INIT, + RecordingStartFailureReason.fromException(RecorderInitException()) + ) + assertEquals( + RecordingStartFailureReason.INVALID_OUTPUT_FILE, + RecordingStartFailureReason.fromException(InvalidOutputFile()) + ) + assertEquals( + RecordingStartFailureReason.CANT_CREATE_FILE, + RecordingStartFailureReason.fromException(CantCreateFileException()) + ) + assertEquals( + RecordingStartFailureReason.ALREADY_RECORDING, + RecordingStartFailureReason.fromException(AlreadyRecordingException()) + ) + assertEquals( + RecordingStartFailureReason.NOT_ENOUGH_SPACE, + RecordingStartFailureReason.fromException(NoSpaceAvailableException()) + ) + assertEquals( + RecordingStartFailureReason.RECORDING_ERROR, + RecordingStartFailureReason.fromException(RecordingException()) + ) + } + + @Test + fun `recording reason is unknown for an unrelated exception`() { + assertEquals( + RecordingStartFailureReason.UNKNOWN, + RecordingStartFailureReason.fromException(CantProcessRecord()) + ) + } + + @Test + fun `recording reason is unknown when there is no exception`() { + assertEquals( + RecordingStartFailureReason.UNKNOWN, + RecordingStartFailureReason.fromException(null) + ) + } + + // ------------------------------------------------------------------------- + // PlaybackStartFailureReason.fromException + // ------------------------------------------------------------------------- + + @Test + fun `playback reason maps every exception the player can report`() { + assertEquals( + PlaybackStartFailureReason.DATA_SOURCE, + PlaybackStartFailureReason.fromException(PlayerDataSourceException()) + ) + assertEquals( + PlaybackStartFailureReason.PLAYER_INIT, + PlaybackStartFailureReason.fromException(PlayerInitException()) + ) + } + + @Test + fun `playback reason is unknown for an unrelated exception`() { + assertEquals( + PlaybackStartFailureReason.UNKNOWN, + PlaybackStartFailureReason.fromException(RecorderInitException()) + ) + } + + // ------------------------------------------------------------------------- + // Reported error details + // ------------------------------------------------------------------------- + + @Test + fun `error details fall back to none without an exception`() { + val failure = recordingFailure(error = null) + assertEquals(ANALYTICS_VALUE_NONE, failure.errorClass) + assertEquals(ANALYTICS_VALUE_NONE, failure.errorMessage) + } + + @Test + fun `error class is the simple name of the exception`() { + val failure = recordingFailure(error = IOException("prepare failed")) + assertEquals("IOException", failure.errorClass) + assertEquals("prepare failed", failure.errorMessage) + } + + @Test + fun `error message falls back to the cause when the wrapper has none`() { + // The exceptions the recorders and the player raise are markers with no message of + // their own, so without the cause these reports would say nothing at all. + val failure = recordingFailure(error = RuntimeException(IllegalStateException("no codec"))) + val message = failure.errorMessage + assertTrue(message, message.contains("IllegalStateException")) + assertTrue(message, message.contains("no codec")) + } + + @Test + fun `error message keeps both the message and the cause`() { + val error = IOException("start failed", IllegalStateException("no codec")) + val message = recordingFailure(error = error).errorMessage + assertTrue(message, message.contains("start failed")) + assertTrue(message, message.contains("no codec")) + } + + @Test + fun `playback failure reports the details of its source`() { + val failure = PlaybackStartFailure( + reason = PlaybackStartFailureReason.DATA_SOURCE, + format = "m4a", + uriScheme = "file", + fileExists = false, + ) + assertEquals(ANALYTICS_VALUE_UNKNOWN_NUMBER, failure.fileSizeBytes) + assertEquals(ANALYTICS_VALUE_UNKNOWN_NUMBER.toInt(), failure.playerErrorCode) + assertEquals(ANALYTICS_VALUE_NONE, failure.playerErrorName) + assertEquals(ANALYTICS_VALUE_NONE, failure.errorClass) + } + + private fun recordingFailure(error: Throwable?) = RecordingStartFailure( + reason = RecordingStartFailureReason.RECORDER_INIT, + format = "m4a", + sampleRate = 44100, + bitrate = 128000, + channelCount = 2, + audioSource = "mic", + error = error, + ) +} diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModelAudioSourceTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModelAudioSourceTest.kt new file mode 100644 index 00000000..5e7f7445 --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModelAudioSourceTest.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.app.settings + +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.util.TestARApplication +import com.dimowner.audiorecorder.v2.DefaultValues +import com.dimowner.audiorecorder.v2.analytics.AnalyticsTracker +import com.dimowner.audiorecorder.v2.audio.AudioRecorderDelegate +import com.dimowner.audiorecorder.v2.data.FileDataSource +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.AudioSource +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** + * Covers the audio-source rules in [SettingsViewModel]. + * + * System audio is captured through `AudioRecord`, and 3GP is the one recording format that only + * has a `MediaRecorder` backend, so the two can never be selected together. Whichever of the pair + * the user picks last wins, and the other one moves to its default. + */ +@RunWith(AndroidJUnit4::class) +@Config(application = TestARApplication::class, sdk = [36]) +class SettingsViewModelAudioSourceTest { + + private lateinit var prefs: PrefsV2 + private lateinit var recordsDataSource: RecordsDataSource + private lateinit var fileDataSource: FileDataSource + private lateinit var audioPlayer: PlayerContractNew.Player + private lateinit var audioRecorderDelegate: AudioRecorderDelegate + private lateinit var analyticsTracker: AnalyticsTracker + private lateinit var context: Context + + @Before + fun setup() { + prefs = mockk(relaxed = true) + recordsDataSource = mockk(relaxed = true) + fileDataSource = mockk(relaxed = true) + audioPlayer = mockk(relaxed = true) + audioRecorderDelegate = mockk(relaxed = true) + analyticsTracker = mockk(relaxed = true) + context = ApplicationProvider.getApplicationContext() + } + + /** + * Backs the preferences this view model reads in its constructor with in-memory state, so a + * value written by the view model is visible to the next read. + */ + private fun createViewModel( + format: RecordingFormat = RecordingFormat.M4a, + audioSource: AudioSource = DefaultValues.DefaultAudioSource, + ): SettingsViewModel { + var fmt = format + var src = audioSource + var sr = SampleRate.SR44100 + var br = BitRate.BR128 + var cc = ChannelCount.Stereo + every { prefs.settingRecordingFormat } answers { fmt } + every { prefs.settingRecordingFormat = any() } answers { fmt = firstArg() } + every { prefs.settingAudioSource } answers { src } + every { prefs.settingAudioSource = any() } answers { src = firstArg() } + every { prefs.settingSampleRate } answers { sr } + every { prefs.settingSampleRate = any() } answers { sr = firstArg() } + every { prefs.settingBitrate } answers { br } + every { prefs.settingBitrate = any() } answers { br = firstArg() } + every { prefs.settingChannelCount } answers { cc } + every { prefs.settingChannelCount = any() } answers { cc = firstArg() } + return SettingsViewModel( + prefs = prefs, + recordsDataSource = recordsDataSource, + fileDataSource = fileDataSource, + audioPlayer = audioPlayer, + audioRecorderDelegate = audioRecorderDelegate, + analyticsTracker = analyticsTracker, + mainDispatcher = Dispatchers.Unconfined, + ioDispatcher = Dispatchers.Unconfined, + context = context, + ) + } + + @Test + fun `system audio is offered when the feature is supported`() { + val viewModel = createViewModel() + + assertTrue(viewModel.state.value.audioSourceOptions.contains(AudioSource.SYSTEM_AUDIO)) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.P]) + fun `system audio is hidden when the feature is unsupported`() { + val viewModel = createViewModel() + + assertFalse(viewModel.state.value.audioSourceOptions.contains(AudioSource.SYSTEM_AUDIO)) + // The microphone sources stay available on every device. + assertTrue(viewModel.state.value.audioSourceOptions.contains(AudioSource.MIC)) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.P]) + fun `selecting system audio is ignored when the feature is unsupported`() { + val viewModel = createViewModel( + audioSource = AudioSource.MIC, + ) + + viewModel.setAudioSource(AudioSource.SYSTEM_AUDIO) + + assertEquals(AudioSource.MIC, prefs.settingAudioSource) + assertEquals(AudioSource.MIC, viewModel.state.value.selectedAudioSource) + } + + @Test + fun `selecting system audio moves the format off ThreeGp`() { + val viewModel = createViewModel(format = RecordingFormat.ThreeGp) + + viewModel.setAudioSource(AudioSource.SYSTEM_AUDIO) + + assertEquals(AudioSource.SYSTEM_AUDIO, prefs.settingAudioSource) + assertEquals(DefaultValues.DefaultRecordingFormat, prefs.settingRecordingFormat) + } + + @Test + fun `selecting system audio keeps a format that has an AudioRecord backend`() { + val viewModel = createViewModel(format = RecordingFormat.Wav) + + viewModel.setAudioSource(AudioSource.SYSTEM_AUDIO) + + assertEquals(AudioSource.SYSTEM_AUDIO, prefs.settingAudioSource) + assertEquals(RecordingFormat.Wav, prefs.settingRecordingFormat) + } + + @Test + fun `selecting ThreeGp gives up system audio`() { + val viewModel = createViewModel( + format = RecordingFormat.M4a, + audioSource = AudioSource.SYSTEM_AUDIO, + ) + + viewModel.selectRecordingFormat(RecordingFormat.ThreeGp) + + assertEquals(RecordingFormat.ThreeGp, prefs.settingRecordingFormat) + assertEquals(DefaultValues.DefaultAudioSource, prefs.settingAudioSource) + assertEquals(DefaultValues.DefaultAudioSource, viewModel.state.value.selectedAudioSource) + } + + @Test + fun `selecting a format other than ThreeGp keeps system audio`() { + val viewModel = createViewModel( + format = RecordingFormat.M4a, + audioSource = AudioSource.SYSTEM_AUDIO, + ) + + viewModel.selectRecordingFormat(RecordingFormat.Wav) + + assertEquals(RecordingFormat.Wav, prefs.settingRecordingFormat) + assertEquals(AudioSource.SYSTEM_AUDIO, prefs.settingAudioSource) + } +} diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/audio/AacCodecRecorderHelpersTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/audio/AacCodecRecorderHelpersTest.kt new file mode 100644 index 00000000..7b1be6ca --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/audio/AacCodecRecorderHelpersTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.audio + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The arithmetic behind [AacCodecRecorderV2]. The recorder itself needs `MediaCodec` and + * `AudioRecord`, so it is covered by the instrumented tests instead. + */ +class AacCodecRecorderHelpersTest { + + // ------------------------------------------------------------------------- + // aacPtsUs — the timeline written into the container + // ------------------------------------------------------------------------- + + @Test + fun `first frame starts the timeline at zero`() { + assertEquals(0L, aacPtsUs(frameIndex = 0, sampleRate = 48000)) + } + + @Test + fun `one frame covers 1024 samples`() { + // 1024 samples at 48 kHz = 21333.33 us, truncated. + assertEquals(21_333L, aacPtsUs(frameIndex = 1, sampleRate = 48000)) + // 1024 samples at 16 kHz = 64 ms exactly. + assertEquals(64_000L, aacPtsUs(frameIndex = 1, sampleRate = 16000)) + } + + @Test + fun `timestamps land on the second at 48 kHz`() { + // 48000 / 1024 * 1000 s: frame 46875 is exactly 1000 s in. + assertEquals(1_000_000_000L, aacPtsUs(frameIndex = 46_875, sampleRate = 48000)) + } + + @Test + fun `timestamps are strictly monotonic`() { + var previous = -1L + for (frame in 0L until 100_000L) { + val pts = aacPtsUs(frame, sampleRate = 44100) + assertTrue("pts went backwards at frame $frame", pts > previous) + previous = pts + } + } + + @Test + fun `a day of recording does not overflow`() { + // 24 h at 48 kHz is exactly 4.05 million frames, and stays a whole day in microseconds. + val frames = 24L * 60 * 60 * 48000 / 1024 + assertEquals(4_050_000L, frames) + assertEquals(86_400_000_000L, aacPtsUs(frames, sampleRate = 48000)) + } + + // ------------------------------------------------------------------------- + // pcmDurationMills + // ------------------------------------------------------------------------- + + @Test + fun `duration follows the PCM actually captured`() { + assertEquals(0L, pcmDurationMills(framesFed = 0, sampleRate = 48000)) + assertEquals(1000L, pcmDurationMills(framesFed = 48000, sampleRate = 48000)) + assertEquals(500L, pcmDurationMills(framesFed = 22050, sampleRate = 44100)) + } + + // ------------------------------------------------------------------------- + // clampAacBitRate + // ------------------------------------------------------------------------- + + @Test + fun `a reachable bitrate is left alone`() { + assertEquals( + 192_000, + clampAacBitRate(requested = 192_000, sampleRate = 48000, channelCount = 2, codecUpper = 960_000) + ) + } + + @Test + fun `the AAC-LC frame ceiling caps low sample rates`() { + // 6 bits per sample and channel: 16 kHz mono can not exceed 96 kbps. + assertEquals( + 96_000, + clampAacBitRate(requested = 192_000, sampleRate = 16000, channelCount = 1, codecUpper = 960_000) + ) + assertEquals( + 48_000, + clampAacBitRate(requested = 288_000, sampleRate = 8000, channelCount = 1, codecUpper = 960_000) + ) + } + + @Test + fun `the encoder range caps the request too`() { + assertEquals( + 64_000, + clampAacBitRate(requested = 192_000, sampleRate = 48000, channelCount = 2, codecUpper = 64_000) + ) + } + + @Test + fun `the result is never zero or negative`() { + assertTrue(clampAacBitRate(requested = 0, sampleRate = 48000, channelCount = 2, codecUpper = 960_000) > 0) + } +} diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/audio/M4aRecorderV2Test.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/audio/M4aRecorderV2Test.kt new file mode 100644 index 00000000..c2e181b3 --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/audio/M4aRecorderV2Test.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.audio + +import android.media.MediaRecorder +import com.dimowner.audiorecorder.exception.InvalidOutputFile +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +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 java.io.File + +/** + * Covers how [M4aRecorderV2] routes between the codec pipeline and the MediaRecorder fallback. + * + * The distinction that matters: a codec pipeline that fails to come up must fall back silently, + * because `AudioRecordingService` deletes the record and its file when it sees certain errors. + */ +class M4aRecorderV2Test { + + @get:Rule val folder = TemporaryFolder() + + private lateinit var codecRecorder: AacCodecRecorderV2 + private lateinit var mediaRecorder: AudioRecorderV2 + private lateinit var outputFile: File + + private var failedConfig = "" + + @Before + fun setup() { + codecRecorder = mockk(relaxed = true) + mediaRecorder = mockk(relaxed = true) + every { codecRecorder.subscribeRecorderEvents() } returns emptyFlow() + every { mediaRecorder.subscribeRecorderEvents() } returns emptyFlow() + outputFile = folder.newFile("record.m4a") + } + + private val micInput = AudioInput.Mic(MediaRecorder.AudioSource.DEFAULT) + + private fun CoroutineScope.createRecorder() = + M4aRecorderV2(codecRecorder, mediaRecorder, this) + + private fun M4aRecorderV2.start(bitrate: Int = 192_000, sampleRate: Int = 48000, channelCount: Int = 2) = + startRecording(outputFile, channelCount, sampleRate, bitrate, 0, micInput) + + private fun stubCodecStart(result: AacCodecRecorderV2.StartResult) { + every { + codecRecorder.startRecordingInternal(any(), any(), any(), any(), any(), any()) + } returns result + } + + @Test + fun `a working codec pipeline records without touching MediaRecorder`() = runTest { + stubCodecStart(AacCodecRecorderV2.StartResult.Started) + val recorder = createRecorder() + + assertTrue(recorder.start()) + + verify(exactly = 0) { mediaRecorder.startRecording(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `a failed codec pipeline falls back without reporting an error`() = runTest { + stubCodecStart(AacCodecRecorderV2.StartResult.PipelineFailed("codec-configure", null)) + every { mediaRecorder.startRecording(any(), any(), any(), any(), any(), any()) } returns true + val recorder = createRecorder() + val events = mutableListOf() + val collector = launch { recorder.subscribeRecorderEvents().collect { events += it } } + + assertTrue(recorder.start()) + advanceUntilIdle() + + verify(exactly = 1) { mediaRecorder.startRecording(outputFile, 2, 48000, 192_000, 0, micInput) } + assertTrue("the service would delete the record on an error event", events.isEmpty()) + collector.cancel() + } + + @Test + fun `a rejected request is reported and not retried on MediaRecorder`() = runTest { + stubCodecStart(AacCodecRecorderV2.StartResult.Rejected(InvalidOutputFile())) + val recorder = createRecorder() + val events = mutableListOf() + val collector = launch { recorder.subscribeRecorderEvents().collect { events += it } } + + assertFalse(recorder.start()) + advanceUntilIdle() + + verify(exactly = 0) { mediaRecorder.startRecording(any(), any(), any(), any(), any(), any()) } + assertEquals(1, events.size) + assertTrue(events.first() is RecorderEvent.OnError) + collector.cancel() + } + + @Test + fun `another configuration still tries the codec`() = runTest { + stubCodecStart(AacCodecRecorderV2.StartResult.PipelineFailed("codec-start", null)) + every { mediaRecorder.startRecording(any(), any(), any(), any(), any(), any()) } returns true + val recorder = createRecorder() + recorder.start(bitrate = 288_000, sampleRate = 8000, channelCount = 1) + + stubCodecStart(AacCodecRecorderV2.StartResult.Started) + assertTrue(recorder.start(bitrate = 192_000, sampleRate = 48000, channelCount = 2)) + + assertEquals("a successful start clears the remembered failure", "", failedConfig) + verify(exactly = 2) { codecRecorder.startRecordingInternal(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `transport controls follow the active backend`() = runTest { + stubCodecStart(AacCodecRecorderV2.StartResult.PipelineFailed("muxer", null)) + every { mediaRecorder.startRecording(any(), any(), any(), any(), any(), any()) } returns true + every { mediaRecorder.stopRecording() } returns true + val recorder = createRecorder() + recorder.start() + + recorder.pauseRecording() + recorder.resumeRecording() + assertTrue(recorder.stopRecording()) + + verify { mediaRecorder.pauseRecording() } + verify { mediaRecorder.resumeRecording() } + verify { mediaRecorder.stopRecording() } + verify(exactly = 0) { codecRecorder.stopRecording() } + } +} diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/audio/MediaRecorderBaseStopTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/audio/MediaRecorderBaseStopTest.kt new file mode 100644 index 00000000..1280d054 --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/audio/MediaRecorderBaseStopTest.kt @@ -0,0 +1,339 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.audio + +import android.app.Application +import android.content.Context +import android.media.MediaRecorder +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.dimowner.audiorecorder.exception.RecordingException +import com.dimowner.audiorecorder.exception.RecordingStopFailedException +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.unmockkConstructor +import io.mockk.verify +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +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.robolectric.annotation.Config +import java.io.File + +/** + * Covers how [MediaRecorderBase] ends a recording it did not end itself: a failing + * [MediaRecorder.stop], and the two callbacks the platform can raise mid-session. + * + * The platform reports a writer that could not finalise the output file as a plain + * `RuntimeException("stop failed.")`. The call is reached from the main thread behind the stop + * button, so it neither runs there - it is handed to [MediaRecorderBase.stopDispatcher] - nor + * lets the failure escape: the outcome comes back as an event. + */ +@RunWith(AndroidJUnit4::class) +@Config(application = MediaRecorderTestApplication::class, sdk = [36]) +@OptIn(ExperimentalCoroutinesApi::class) +class MediaRecorderBaseStopTest { + + @get:Rule val folder = TemporaryFolder() + + private lateinit var outputFile: File + + /** The listeners [MediaRecorderBase] installs, so the tests can raise the callbacks. */ + private var infoListener: MediaRecorder.OnInfoListener? = null + private var errorListener: MediaRecorder.OnErrorListener? = null + + /** Minimal concrete recorder: the format setup has no bearing on how a stop failure behaves. */ + private class TestRecorder( + context: Context, + scope: CoroutineScope, + stopDispatcher: CoroutineDispatcher, + ) : MediaRecorderBase(context, scope, stopDispatcher) { + override fun configureRecorder( + recorder: MediaRecorder, + channelCount: Int, + sampleRate: Int, + bitrate: Int, + ) = Unit + } + + @Before + fun setup() { + outputFile = folder.newFile("record.m4a") + mockkConstructor(MediaRecorder::class) + every { anyConstructed().setAudioSource(any()) } returns Unit + every { anyConstructed().setOutputFormat(any()) } returns Unit + every { anyConstructed().setAudioEncoder(any()) } returns Unit + every { anyConstructed().setMaxDuration(any()) } returns Unit + every { anyConstructed().setOutputFile(any()) } returns Unit + // Captured rather than ignored: finishStop() clears both listeners, so the argument is + // nullable and the last value seen is what the recorder is actually running with. + every { anyConstructed().setOnInfoListener(any()) } answers { + infoListener = firstArg() + } + every { anyConstructed().setOnErrorListener(any()) } answers { + errorListener = firstArg() + } + every { anyConstructed().prepare() } returns Unit + every { anyConstructed().start() } returns Unit + every { anyConstructed().release() } returns Unit + // Amplitudes stay at 0, so the recorder never leaves its "starting" phase and no + // progress events dilute what the tests assert on. + every { anyConstructed().maxAmplitude } returns 0 + } + + @After + fun tearDown() { + infoListener = null + errorListener = null + unmockkConstructor(MediaRecorder::class) + } + + @Test + fun `stop failure is reported instead of crashing the caller`() = runTest { + every { anyConstructed().stop() } throws RuntimeException("stop failed.") + val recorder = TestRecorder( + ApplicationProvider.getApplicationContext(), + this, + UnconfinedTestDispatcher(testScheduler), + ) + val events = collectEvents(recorder) + + assertTrue(startRecording(recorder)) + // The stop is accepted right away; whether the container closed is reported as an event, + // because the caller is no longer around when the blocking stop() finishes. + assertTrue(recorder.stopRecording()) + advanceUntilIdle() + + assertEquals( + listOf(RecorderEvent.OnStartRecording::class, RecorderEvent.OnError::class), + events.map { it::class }, + ) + val error = events.last() as RecorderEvent.OnError + assertTrue(error.exception is RecordingStopFailedException) + } + + @Test + fun `a clean stop still reports a normal stop`() = runTest { + every { anyConstructed().stop() } returns Unit + val recorder = TestRecorder( + ApplicationProvider.getApplicationContext(), + this, + UnconfinedTestDispatcher(testScheduler), + ) + val events = collectEvents(recorder) + + assertTrue(startRecording(recorder)) + assertTrue(recorder.stopRecording()) + advanceUntilIdle() + + assertEquals( + listOf(RecorderEvent.OnStartRecording, RecorderEvent.OnStopRecording), + events, + ) + } + + /** + * A second stop after a failed one must stay silent: the recorder is already released, and a + * repeated failure would send the service after a record it has already dealt with. + */ + @Test + fun `stopping again after a failure reports nothing`() = runTest { + every { anyConstructed().stop() } throws RuntimeException("stop failed.") + val recorder = TestRecorder( + ApplicationProvider.getApplicationContext(), + this, + UnconfinedTestDispatcher(testScheduler), + ) + + assertTrue(startRecording(recorder)) + recorder.stopRecording() + advanceUntilIdle() + + val events = collectEvents(recorder) + assertFalse(recorder.stopRecording()) + advanceUntilIdle() + + assertTrue(events.isEmpty()) + } + + /** + * The ANR this guards against: MediaRecorder.stop() finalises the container through the media + * server and blocks for as long as that takes, and the stop button reaches stopRecording() on + * the main thread. Nothing native may run before stopRecording() has returned. + */ + @Test + fun `stop button does not run the blocking stop on the caller's thread`() = runTest { + every { anyConstructed().stop() } returns Unit + // Standard, not unconfined: the teardown is queued rather than run eagerly, so what the + // caller sees on return is exactly what the main thread would see. + val recorder = TestRecorder( + ApplicationProvider.getApplicationContext(), + this, + StandardTestDispatcher(testScheduler), + ) + + assertTrue(startRecording(recorder)) + assertTrue(recorder.stopRecording()) + + verify(exactly = 0) { anyConstructed().stop() } + // The recorder still reports itself as stopped, which is what the caller acts on. + assertFalse(recorder.isRecording) + + advanceUntilIdle() + verify(exactly = 1) { anyConstructed().stop() } + } + + /** + * A media server death or an encoder failure leaves the recorder silently dead: no more + * audio, an unfinalised file, and a service that still believes it is recording. The + * failure has to come back as one event, and only once the container has been closed - the + * service saves the captured audio on the strength of it. + */ + @Test + fun `a runtime failure closes the container and reports a recording error`() = runTest { + every { anyConstructed().stop() } returns Unit + val recorder = TestRecorder( + ApplicationProvider.getApplicationContext(), + this, + UnconfinedTestDispatcher(testScheduler), + ) + val events = collectEvents(recorder) + + assertTrue(startRecording(recorder)) + errorListener!!.onError(mockk(), MediaRecorder.MEDIA_ERROR_SERVER_DIED, 0) + advanceUntilIdle() + + verify(exactly = 1) { anyConstructed().stop() } + assertEquals( + listOf(RecorderEvent.OnStartRecording::class, RecorderEvent.OnError::class), + events.map { it::class }, + ) + val error = events.last() as RecorderEvent.OnError + assertTrue(error.exception is RecordingException) + assertFalse(recorder.isRecording) + } + + /** + * When the stop that follows the failure cannot close the container either, the unfinalised + * file is what the user is left with - and only [RecordingStopFailedException] sends the + * service after it, so that has to win over the error the failure would have reported. + */ + @Test + fun `a runtime failure whose stop also fails reports the stop failure`() = runTest { + every { anyConstructed().stop() } throws RuntimeException("stop failed.") + val recorder = TestRecorder( + ApplicationProvider.getApplicationContext(), + this, + UnconfinedTestDispatcher(testScheduler), + ) + val events = collectEvents(recorder) + + assertTrue(startRecording(recorder)) + errorListener!!.onError(mockk(), MediaRecorder.MEDIA_ERROR_SERVER_DIED, 0) + advanceUntilIdle() + + val error = events.last() as RecorderEvent.OnError + assertTrue(error.exception is RecordingStopFailedException) + } + + /** A failure arriving after the recording was already torn down has nothing left to report. */ + @Test + fun `a runtime failure after the recorder was released reports nothing`() = runTest { + every { anyConstructed().stop() } returns Unit + val recorder = TestRecorder( + ApplicationProvider.getApplicationContext(), + this, + UnconfinedTestDispatcher(testScheduler), + ) + val listener = run { + assertTrue(startRecording(recorder)) + errorListener!! + } + recorder.stopRecording() + advanceUntilIdle() + + val events = collectEvents(recorder) + listener.onError(mockk(), MediaRecorder.MEDIA_ERROR_SERVER_DIED, 0) + advanceUntilIdle() + + assertTrue(events.isEmpty()) + verify(exactly = 1) { anyConstructed().stop() } + } + + /** + * The MPEG-4 writer stops on its own once the output outgrows its 32-bit offsets, without + * any file size limit having been set. Left unhandled the recorder is dead while the service + * keeps counting, so it is treated as a max-duration hit and the session rolls over into the + * next part. + */ + @Test + fun `the writer's own file size limit ends the recording like a max duration hit`() = runTest { + every { anyConstructed().stop() } returns Unit + val recorder = TestRecorder( + ApplicationProvider.getApplicationContext(), + this, + UnconfinedTestDispatcher(testScheduler), + ) + val events = collectEvents(recorder) + + assertTrue(startRecording(recorder)) + infoListener!!.onInfo(mockk(), MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED, 0) + advanceUntilIdle() + + assertEquals( + listOf(RecorderEvent.OnStartRecording, RecorderEvent.OnMaxDurationReached), + events, + ) + } + + private fun startRecording(recorder: RecorderV2): Boolean = recorder.startRecording( + outputFile = outputFile, + channelCount = 1, + sampleRate = 44100, + bitrate = 128000, + maxRecordingDurationMills = 0, + audioInput = AudioInput.Mic(MediaRecorder.AudioSource.MIC), + ) + + private fun TestScope.collectEvents(recorder: RecorderV2): List { + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + recorder.subscribeRecorderEvents().collect { events.add(it) } + } + return events + } +} + +class MediaRecorderTestApplication : Application() { + override fun onTerminate() { + // Do nothing - avoid calling Injector.closeTasks() in tests + } +} diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/audio/RecordingWaveformBufferTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/audio/RecordingWaveformBufferTest.kt index 238996bf..08deaea3 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/audio/RecordingWaveformBufferTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/audio/RecordingWaveformBufferTest.kt @@ -65,6 +65,30 @@ class RecordingWaveformBufferTest { } } + @Test + fun `timeline stays uniform over a full-length recording session`() { + // 3.6M samples is what a 20-hour recording produces at the 20 ms sampling interval. + // Doubles as a cost regression test: an implementation that resamples the whole + // timeline on every compression pass cannot finish this in reasonable time. + val targetSize = 600 + val totalSamples = 3_600_000 + val buf = RecordingWaveformBuffer(targetSize = targetSize) + repeat(totalSamples / 2) { buf.add(0) } + repeat(totalSamples / 2) { buf.add(32767) } + + val result = buf.downsampleToIntArray() + + assertEquals(targetSize, result.size) + // The silence/full-amplitude split must still land in the middle, not drift towards + // either end the way non-uniform slot widths would make it. + for (i in 0 until targetSize * 4 / 10) { + assertTrue("Expected near 0 at index $i, got ${result[i]}", result[i] < 3000) + } + for (i in targetSize * 6 / 10 until targetSize) { + assertTrue("Expected near 32767 at index $i, got ${result[i]}", result[i] > 29000) + } + } + @Test fun `downsampleToIntArray output is monotonically increasing for linearly rising signal`() { // Input: amplitude rises linearly 0 → 32767 over many samples. @@ -112,9 +136,9 @@ class RecordingWaveformBufferTest { buf.reset() buf.add(42) val result = buf.downsampleToIntArray() + // Exactly the one sample recorded since the reset; no previous data leaks through. + assertEquals(1, result.size) assertEquals(42, result[0]) - // All other bins should be zero (no previous data leaks through) - for (i in 1 until 10) assertEquals("index $i should be 0", 0, result[i]) } @Test @@ -136,22 +160,38 @@ class RecordingWaveformBufferTest { } @Test - fun `downsampleToIntArray on empty buffer returns zero-filled targetSize array`() { + fun `downsampleToIntArray on empty buffer returns an empty array`() { val buf = RecordingWaveformBuffer(targetSize = 50) - val result = buf.downsampleToIntArray() - assertEquals(50, result.size) - assertTrue(result.all { it == 0 }) + assertEquals(0, buf.downsampleToIntArray().size) } @Test - fun `downsampleToIntArray with fewer samples than targetSize left-aligns and zero-fills`() { + fun `downsampleToIntArray with fewer samples than targetSize returns only captured samples`() { val buf = RecordingWaveformBuffer(targetSize = 100) intArrayOf(10, 20, 30).forEach { buf.add(it) } val result = buf.downsampleToIntArray() - assertEquals(10, result[0]) - assertEquals(20, result[1]) - assertEquals(30, result[2]) - for (i in 3 until 100) assertEquals("index $i should be 0", 0, result[i]) + assertTrue( + "Expected exactly the captured samples, got: ${result.toList()}", + result.contentEquals(intArrayOf(10, 20, 30)) + ) + } + + @Test + fun `short recording is not padded with a silent tail`() { + // Regression test: a 5 s recording produces 5000 / 20 ms = 250 samples, well under the + // ~600-sample targetSize of a typical screen. Padding the result up to targetSize made + // consumers (which spread `amps` across the whole record duration) draw the real + // waveform in the first ~40 % of the width and silence for the rest, until DecodeService + // replaced `amps` with the decoded version. + val targetSize = 600 + val recordedSamples = 250 + val buf = RecordingWaveformBuffer(targetSize = targetSize) + repeat(recordedSamples) { buf.add(20000) } + + val result = buf.downsampleToIntArray() + + assertEquals(recordedSamples, result.size) + assertTrue("No sample may be silent", result.all { it == 20000 }) } @Test @@ -206,7 +246,10 @@ class RecordingWaveformBufferTest { try { val deadline = System.currentTimeMillis() + 500 while (System.currentTimeMillis() < deadline && error.get() == null) { - assertEquals(50, buf.downsampleToIntArray().size) + // A reader that lands just after reset() legitimately sees a partially + // refilled buffer, so only the upper bound is guaranteed here. + val size = buf.downsampleToIntArray().size + assertTrue("size $size should be <= 50", size <= 50) } } catch (t: Throwable) { error.set(t) diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/data/model/AudioSourceTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/data/model/AudioSourceTest.kt new file mode 100644 index 00000000..dbeb3a91 --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/data/model/AudioSourceTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AudioSourceTest { + + @Test + fun `every persisted value is unique`() { + val values = AudioSource.entries.map { it.value } + assertEquals("two sources share a stored value", values.size, values.toSet().size) + } + + /** + * SYSTEM_AUDIO is not a platform capture source, so its stored value is a sentinel. Keeping it + * negative is what guarantees it cannot collide with a `MediaRecorder.AudioSource` constant, + * now or when the platform adds more of them. + */ + @Test + fun `the system audio sentinel cannot collide with a platform source`() { + assertTrue(AudioSource.SYSTEM_AUDIO.value < 0) + AudioSource.entries.filterNot { it.isSystemAudio }.forEach { + assertTrue("${it.name} is not a platform source value", it.value >= 0) + } + } + + @Test + fun `fromValue round-trips every source`() { + AudioSource.entries.forEach { + assertEquals(it, AudioSource.fromValue(it.value)) + } + } + + @Test + fun `an unknown stored value falls back to the default`() { + assertEquals(AudioSource.DEFAULT, AudioSource.fromValue(Int.MIN_VALUE)) + } + + @Test + fun `only SYSTEM_AUDIO reports isSystemAudio`() { + assertTrue(AudioSource.SYSTEM_AUDIO.isSystemAudio) + AudioSource.entries.filter { it != AudioSource.SYSTEM_AUDIO }.forEach { + assertFalse("${it.name} must not report as system audio", it.isSystemAudio) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 50f6742a..1b8b2e31 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -androidGradlePlugin = "9.2.1" +androidGradlePlugin = "9.4.0" # @keep compose-compiler = "1.5.8" -composeBom = "2026.06.01" -composeLatest = "1.11.4" -constraintLayoutCompose = "1.1.1" +composeBom = "2026.08.00" +composeLatest = "1.12.0" +constraintLayoutCompose = "1.1.2" activityCompose = "1.13.0" coreSplashscreen = "1.2.0" hiltNavigationCompose = "1.4.0" @@ -19,7 +19,7 @@ hilt = "2.60.1" junit = "4.13.2" junitVersion = "1.3.0" kotlin = "2.4.10" -ksp = "2.3.2" +ksp = "2.3.6" ktx = "1.19.0" lifecycle = "2.11.0" navigation = "2.9.8" @@ -36,7 +36,7 @@ materialIconsExtended = "1.7.8" jaudiotagger = "3.0.1" mp4parser = "1.9.56" media = "1.8.0" -media3 = "1.10.1" +media3 = "1.11.0" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c1075227..153fe0ea 100755 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip