Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 = 950
versionName = "2.4.1"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ class FileDataSourceImplTest {
@Before
fun setUp() {
val context: Context = ApplicationProvider.getApplicationContext()
fileDataSource = FileDataSourceImpl(context)
val prefs = PrefsV2Impl(context)
fileDataSource = FileDataSourceImpl(context, prefs)
}

@After
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ class PrefsV2ImplTest {
assertEquals(name, prefs.recordedRecordBaseName)
}

@Test
fun test_publicRecordingDirUri() {
assertNull(prefs.publicRecordingDirUri)

val uri = "content://com.android.externalstorage.documents/tree/primary%3ARecords"
prefs.publicRecordingDirUri = uri
assertEquals(uri, prefs.publicRecordingDirUri)

prefs.publicRecordingDirUri = null
assertNull(prefs.publicRecordingDirUri)
}

@Test
fun test_recordCounter() {
assertEquals(1, prefs.recordCounter)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@ class FileExtensionsTest {
@get:Rule
val tempFolder = TemporaryFolder()

@Test
fun test_uniqueFileName() {
val taken = setOf("Record.m4a", "Record-1.m4a", "NoExtension")

assertEquals("Record.m4a", uniqueFileName("Record.m4a") { false })
assertEquals("Record-2.m4a", uniqueFileName("Record.m4a") { it in taken })
assertEquals("NoExtension-1", uniqueFileName("NoExtension") { it in taken })
assertEquals("My.Record-1.m4a", uniqueFileName("My.Record.m4a") { it == "My.Record.m4a" })
}

@Test
fun test_recordNameWithoutExtension() {
assertEquals("Record", "Record.m4a".recordNameWithoutExtension())
assertEquals("Record-1", "Record-1.m4a".recordNameWithoutExtension())
assertEquals("My.Record", "My.Record.m4a".recordNameWithoutExtension())
assertEquals("Record", "Record".recordNameWithoutExtension())
// A DocumentsProvider may resolve a collision after the extension. The suffix is not an
// extension, so the whole name is kept and still matches the file.
assertEquals("Record.m4a (1)", "Record.m4a (1)".recordNameWithoutExtension())
}

@Test
fun test_createFile_Existing_Directory() {
// Create a temporary directory for testing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ class DecodeService : Service() {
var prevTime: Long = 0
val rec = localRepository.getRecord(id.toInt())
if (rec != null && rec.duration / 1000 < DECODE_DURATION) {
waveformVisualization.decodeRecordWaveform(rec.path, object : AudioDecodingListener {
waveformVisualization.decodeRecordWaveform(applicationContext, rec.path, object : AudioDecodingListener {
override fun isCanceled(): Boolean {
return isCancel
}
Expand Down Expand Up @@ -269,7 +269,7 @@ class DecodeService : Service() {
processingTasks.postRunnable {
var prevTime: Long = 0
if (durationMills < DECODE_DURATION) {
waveformVisualization.decodeRecordWaveform(path, object : AudioDecodingListener {
waveformVisualization.decodeRecordWaveform(applicationContext, path, object : AudioDecodingListener {
override fun isCanceled(): Boolean {
return isCancel
}
Expand Down
209 changes: 198 additions & 11 deletions app/src/main/java/com/dimowner/audiorecorder/audio/AudioDecoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@

package com.dimowner.audiorecorder.audio;

import android.content.Context;
import android.content.res.AssetFileDescriptor;
import android.database.Cursor;
import android.media.MediaCodec;
import android.media.MediaExtractor;
import android.media.MediaFormat;
import android.net.Uri;
import android.provider.DocumentsContract;
import android.provider.OpenableColumns;

import com.dimowner.audiorecorder.ARApplication;
import com.dimowner.audiorecorder.AppConstants;
Expand All @@ -33,6 +39,7 @@
import java.util.Arrays;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import timber.log.Timber;

import static com.dimowner.audiorecorder.AppConstants.SUPPORTED_EXT;
Expand Down Expand Up @@ -75,24 +82,53 @@ public static void decode(@NonNull String fileName, @NonNull AudioDecodingListen
throw new IOException();
}
AudioDecoder decoder = new AudioDecoder();
decoder.decodeFile(file, decodeListener, QUEUE_INPUT_BUFFER_EFFECTIVE);
decoder.decodeInternal(null, null, file, decodeListener, QUEUE_INPUT_BUFFER_EFFECTIVE);
} catch (Exception e) {
decodeListener.onError(e);
}
}

/**
* Decodes a record addressed either by an absolute file path or by a content:// document
* Uri (record stored in a user-selected public directory via Storage Access Framework).
*/
public static void decode(@NonNull Context context, @NonNull String pathOrUri,
@NonNull AudioDecodingListener decodeListener) {
if (pathOrUri.startsWith("content://")) {
try {
AudioDecoder decoder = new AudioDecoder();
decoder.decodeInternal(context.getApplicationContext(), Uri.parse(pathOrUri), null,
decodeListener, QUEUE_INPUT_BUFFER_EFFECTIVE);
} catch (Exception e) {
decodeListener.onError(e);
}
} else {
decode(pathOrUri, decodeListener);
}
}

private int calculateSamplesPerFrame() {
return (int)(sampleRate / dpPerSec);
}

private void decodeFile(@NonNull final File mInputFile, @NonNull final AudioDecodingListener decodeListener, final int queueType)
private void decodeInternal(@Nullable final Context context, @Nullable final Uri inputUri,
@Nullable final File inputFile, @NonNull final AudioDecodingListener decodeListener, final int queueType)
throws IOException, OutOfMemoryError, IllegalStateException {
gains = new IntArrayList();
final MediaExtractor extractor = new MediaExtractor();
MediaFormat format = null;
int i;

extractor.setDataSource(mInputFile.getPath());
final String inputName;
if (inputUri != null && context != null) {
extractor.setDataSource(context, inputUri, null);
inputName = inputUri.toString();
} else if (inputFile != null) {
extractor.setDataSource(inputFile.getPath());
inputName = inputFile.getPath();
} else {
throw new IOException("No decode input provided");
}
int numTracks = extractor.getTrackCount();
// find and select the first audio track present in the file.
for (i = 0; i < numTracks; i++) {
Expand All @@ -108,17 +144,17 @@ private void decodeFile(@NonNull final File mInputFile, @NonNull final AudioDeco
}

if (i == numTracks || format == null) {
throw new IOException("No audio track found in " + mInputFile.toString());
throw new IOException("No audio track found in " + inputName);
}
try {
channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
} catch (Exception e) {
throw new IOException("Could not read channel count from " + mInputFile.getName(), e);
throw new IOException("Could not read channel count from " + inputName, e);
}
try {
sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE);
} catch (Exception e) {
throw new IOException("Could not read sample rate from " + mInputFile.getName(), e);
throw new IOException("Could not read sample rate from " + inputName, e);
}
try {
if (format.containsKey(MediaFormat.KEY_DURATION)) {
Expand All @@ -136,11 +172,16 @@ private void decodeFile(@NonNull final File mInputFile, @NonNull final AudioDeco
try {
mimeType = format.getString(MediaFormat.KEY_MIME);
} catch (Exception e) {
throw new IOException("Could not read MIME type from " + mInputFile.getName(), e);
throw new IOException("Could not read MIME type from " + inputName, e);
}
if (mimeType == null || mimeType.isEmpty()) {
throw new IOException("Empty MIME type for " + mInputFile.getName());
throw new IOException("Empty MIME type for " + inputName);
}

final long inputSize = inputFile != null
? inputFile.length()
: getUriSize(context, inputUri);

//Start decoding
MediaCodec decoder = MediaCodec.createDecoderByType(mimeType);

Expand All @@ -150,7 +191,7 @@ private void decodeFile(@NonNull final File mInputFile, @NonNull final AudioDeco
private boolean mOutputEOS = false;
private boolean mInputEOS = false;
private long decoded = 0;
private long totalSize = mInputFile.length();
private long totalSize = Math.max(1, inputSize);
private int percent = 0;

@Override
Expand All @@ -159,7 +200,7 @@ public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecExceptio
if (queueType == QUEUE_INPUT_BUFFER_EFFECTIVE) {
try {
AudioDecoder decoder = new AudioDecoder();
decoder.decodeFile(mInputFile, decodeListener, QUEUE_INPUT_BUFFER_SIMPLE);
decoder.decodeInternal(context, inputUri, inputFile, decodeListener, QUEUE_INPUT_BUFFER_SIMPLE);
} catch (IllegalStateException | IOException | OutOfMemoryError e) {
decodeListener.onError(exception);
}
Expand Down Expand Up @@ -311,6 +352,148 @@ public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNu
decoder.start();
}

private static long getUriSize(@Nullable Context context, @Nullable Uri uri) {
if (context == null || uri == null) {
return 0;
}
try (AssetFileDescriptor afd = context.getContentResolver().openAssetFileDescriptor(uri, "r")) {
if (afd != null && afd.getLength() > 0) {
return afd.getLength();
}
} catch (Exception e) {
Timber.e(e);
}
return 0;
}

/**
* Reads audio metadata of a record addressed by a content:// document Uri
* (record stored in a user-selected public directory via Storage Access Framework).
*/
public static RecordInfo readRecordInfo(@NonNull final Context context, @NonNull final Uri uri)
throws OutOfMemoryError, IllegalStateException {
String displayName = "";
long size = 0;
long lastModified = 0;
try (Cursor cursor = context.getContentResolver().query(uri,
new String[]{
OpenableColumns.DISPLAY_NAME,
OpenableColumns.SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED
}, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
if (!cursor.isNull(0)) displayName = cursor.getString(0);
if (!cursor.isNull(1)) size = cursor.getLong(1);
if (!cursor.isNull(2)) lastModified = cursor.getLong(2);
}
} catch (Exception e) {
Timber.e(e);
}
if (size <= 0) {
size = getUriSize(context, uri);
}
try {
final MediaExtractor extractor = new MediaExtractor();
MediaFormat format = null;
int i;

extractor.setDataSource(context, uri, null);
int numTracks = extractor.getTrackCount();
// find and select the first audio track present in the file.
for (i = 0; i < numTracks; i++) {
format = extractor.getTrackFormat(i);
try {
if (format.getString(MediaFormat.KEY_MIME).startsWith("audio/")) {
extractor.selectTrack(i);
break;
}
} catch (Exception e) {
Timber.e(e);
}
}

if (i == numTracks || format == null) {
throw new IOException("No audio track found in " + uri);
}
int channelCount;
try {
if (format.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
} else {
channelCount = 0;
}
} catch (Exception e) {
Timber.e(e);
channelCount = 0;
}
int sampleRate;
try {
if (format.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE);
} else {
sampleRate = 0;
}
} catch (Exception e) {
Timber.e(e);
sampleRate = 0;
}

long duration;
try {
if (format.containsKey(MediaFormat.KEY_DURATION)) {
duration = format.getLong(MediaFormat.KEY_DURATION);
} else {
duration = 0;
}
} catch (Exception e) {
Timber.e(e);
duration = 0;
}

int bitrate;
try {
if (format.containsKey(MediaFormat.KEY_BIT_RATE)) {
bitrate = format.getInteger(MediaFormat.KEY_BIT_RATE);
} else if (duration > 0 && size > 0) {
int estimated = (int) (size * 8000000L / duration);
bitrate = snapToStandardBitrate(estimated);
} else {
bitrate = 0;
}
} catch (Exception e) {
Timber.e(e);
bitrate = 0;
}

String mimeType;
try {
mimeType = format.getString(MediaFormat.KEY_MIME);
} catch (Exception e) {
Timber.e(e);
mimeType = "";
}

return new RecordInfo(
FileUtil.removeFileExtension(displayName),
readFileFormat(displayName, mimeType),
duration,
size,
uri.toString(),
lastModified,
sampleRate,
channelCount,
bitrate,
false
);
} catch (Exception e) {
Timber.e(e);
return new RecordInfo(
FileUtil.removeFileExtension(displayName), "", 0, size,
uri.toString(), lastModified, 0, 0, 0, false
);
}
}

public static RecordInfo readRecordInfo(@NonNull final File inputFile)
throws OutOfMemoryError, IllegalStateException {

Expand Down Expand Up @@ -501,7 +684,11 @@ private static int snapToStandardBitrate(int estimatedBitrate) {
}

private static String readFileFormat(File file, String mime) {
String name = file.getName().toLowerCase();
return readFileFormat(file.getName(), mime);
}

private static String readFileFormat(String fileName, String mime) {
String name = fileName == null ? "" : fileName.toLowerCase();
if (name.contains(AppConstants.FORMAT_M4A) || (mime != null && mime.contains("audio") && mime.contains("mp4a"))) {
return AppConstants.FORMAT_M4A;
} else if (name.contains(AppConstants.FORMAT_WAV) || (mime != null && mime.contains("audio") && mime.contains("raw"))) {
Expand Down
Loading