Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c05e27a
Add DeviceRecordingCapabilities to manage device-specific recording b…
Dimowner Aug 24, 2026
0bd95c4
Add DeviceRecordingCapabilities to manage device-specific recording b…
Dimowner Aug 24, 2026
63f3200
Bump version to 2.4.1, update Gradle wrapper to 9.7.1, and upgrade de…
Dimowner Aug 25, 2026
93d26ae
Introduced new M4a recorder that uses MediaCodec instead of MediaReco…
Dimowner Aug 30, 2026
f2b542a
Enhance playback position management by implementing interpolation fo…
Dimowner Sep 3, 2026
d6f4373
Add support for system audio recording and refactor audio input handling
Dimowner Sep 8, 2026
86b9f3e
Implement analytics tracking for recording and playback start failures
Dimowner Sep 8, 2026
21cbecb
Implement recovery for failed recording stops and introduce Recording…
Dimowner Sep 8, 2026
fa5de96
Handle RuntimeException in getMaxAmplitude to prevent crashes during …
Dimowner Sep 8, 2026
43cb3dd
Catch RuntimeException in recorder start and getMaxAmplitude to preve…
Dimowner Sep 8, 2026
198ddfd
Refactor stopHardware methods to accept recorder instances and improv…
Dimowner Sep 8, 2026
2e2111f
Add permission messages for system audio capture in multiple languages
Dimowner Sep 8, 2026
187bae7
Add system audio recording option descriptions in multiple languages
Dimowner Sep 9, 2026
a1ec46c
Refactor error handling in recording process: improve management of r…
Dimowner Sep 10, 2026
5d3d124
Refactor RecordingWaveformBuffer: optimize memory and CPU usage durin…
Dimowner Sep 10, 2026
c55484f
Refactor MediaRecorderBase: enhance error handling during recording t…
Dimowner Sep 10, 2026
80bccf8
Refactor RecordingWaveformBuffer: improve downsampling logic and enha…
Dimowner Sep 12, 2026
f4b166d
Refactor widget interaction: start TransparentRecordingActivity direc…
Dimowner Sep 13, 2026
e571b6b
Prevent overlapping playback during recording: stop playback if activ…
Dimowner Sep 13, 2026
0fd2fea
Bump version to 2.5.0 and increment version code to 951
Dimowner Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RecorderEvent>

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<RecorderEvent.OnError>()
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
}
}
19 changes: 16 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"
Expand All @@ -17,6 +16,11 @@
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
android:minSdkVersion="33" />
<!-- Required to record system audio: playback capture runs under a MediaProjection, which
from Android 14 must be held by a mediaProjection-typed foreground service. -->
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"
android:minSdkVersion="33" />
<uses-permission android:name="android.permission.VIBRATE" />

<!-- Legacy activities are locked to portrait, which implicitly requires this feature.
Expand Down Expand Up @@ -104,9 +108,16 @@
android:name=".app.moverecords.MoveRecordsActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:screenOrientation="portrait" />
<!--
The widget entry point runs in a task of its own: with the default affinity it joins
the app's task and pulls the last used screen to the front instead of just starting a
recording. excludeFromRecents keeps that short-lived task out of the recents list.
-->
<activity
android:name=".app.TransparentRecordingActivity"
android:exported="false"
android:excludeFromRecents="true"
android:taskAffinity=""
android:theme="@style/Theme.Transparent" />

<service
Expand Down Expand Up @@ -139,12 +150,14 @@
android:exported="false"
android:foregroundServiceType="mediaPlayback" />

<!-- mediaProjection is declared alongside microphone because the same service records
both the microphone and, when the user picks the System Audio source, other apps'
playback. The type is chosen per start() from the source actually in use. -->
<service
android:name=".v2.audio.AudioRecordingService"
android:exported="false"
android:foregroundServiceType="microphone" />
android:foregroundServiceType="microphone|mediaProjection" />

<receiver android:name=".WidgetReceiver" android:exported="true" />
<receiver android:name=".app.RecordingService$StopRecordingReceiver" android:exported="false" />
<receiver android:name=".app.PlaybackService$StopPlaybackReceiver" android:exported="false" />
<receiver android:name=".app.DownloadService$StopDownloadReceiver" android:exported="false" />
Expand Down
Loading