From f0bfda4d556a718325ee71abce233a4220f5f371 Mon Sep 17 00:00:00 2001 From: Dmytro Ponomarenko Date: Sun, 12 Jul 2026 22:24:40 +0300 Subject: [PATCH 1/6] Add support for user-selected public recording directory. --- .../v2/data/FileDataSourceImplTest.kt | 3 +- .../audiorecorder/v2/data/PrefsV2ImplTest.kt | 12 + .../audiorecorder/app/DecodeService.kt | 4 +- .../audiorecorder/audio/AudioDecoder.java | 209 +++++++++++++++++- .../audio/AudioWaveformVisualization.kt | 5 +- .../audio/player/AudioPlayerNew.kt | 16 +- .../audiorecorder/util/AndroidUtils.java | 35 +-- .../v2/app/home/HomeViewModel.kt | 55 ++++- .../v2/app/info/RecordInfoViewModel.kt | 17 +- .../v2/app/records/RecordsViewModel.kt | 86 ++++++- .../v2/app/settings/SettingsScreen.kt | 87 ++++++++ .../v2/app/settings/SettingsState.kt | 5 + .../v2/app/settings/SettingsViewModel.kt | 67 +++++- .../v2/audio/AudioRecordingService.kt | 174 +++++++++------ .../v2/audio/MediaRecorderBase.kt | 136 +++++++----- .../audiorecorder/v2/audio/RecorderV2.kt | 20 +- .../audiorecorder/v2/audio/WavRecorderV2.kt | 135 ++++++----- .../audiorecorder/v2/data/FileDataSource.kt | 23 ++ .../v2/data/FileDataSourceImpl.kt | 58 ++++- .../dimowner/audiorecorder/v2/data/PrefsV2.kt | 6 + .../audiorecorder/v2/data/PrefsV2Impl.kt | 9 + .../v2/data/RecordsDataSourceImpl.kt | 35 ++- .../v2/data/extensions/DataExtensions.kt | 9 +- .../v2/data/extensions/FileExtensions.kt | 12 + .../v2/data/extensions/SafExtensions.kt | 169 ++++++++++++++ .../v2/data/model/RecordTarget.kt | 54 +++++ .../dimowner/audiorecorder/v2/di/AppModule.kt | 6 +- app/src/main/res/values/strings.xml | 7 + .../v2/data/RecordsDataSourceImplTest.kt | 45 ++-- .../v2/data/extensions/DataExtensionsTest.kt | 17 +- 30 files changed, 1248 insertions(+), 268 deletions(-) create mode 100644 app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/SafExtensions.kt create mode 100644 app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordTarget.kt diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImplTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImplTest.kt index 2fef6c864..7c33789b9 100644 --- a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImplTest.kt +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImplTest.kt @@ -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 diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt index ac2d2aec8..9869889c9 100644 --- a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt @@ -130,6 +130,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) diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt b/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt index 638aaaba8..8ea60fcfd 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt @@ -181,7 +181,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 } @@ -249,7 +249,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 } diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/AudioDecoder.java b/app/src/main/java/com/dimowner/audiorecorder/audio/AudioDecoder.java index a85a737db..a5969158e 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/AudioDecoder.java +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/AudioDecoder.java @@ -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; @@ -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; @@ -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++) { @@ -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)) { @@ -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); @@ -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 @@ -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); } @@ -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 { @@ -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"))) { diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/AudioWaveformVisualization.kt b/app/src/main/java/com/dimowner/audiorecorder/audio/AudioWaveformVisualization.kt index 1d5a3ec8e..3f457a519 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/AudioWaveformVisualization.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/AudioWaveformVisualization.kt @@ -1,5 +1,6 @@ package com.dimowner.audiorecorder.audio +import android.content.Context import com.dimowner.audiorecorder.BackgroundQueue import java.lang.Exception @@ -11,9 +12,9 @@ class AudioWaveformVisualization( private val processingTasks: BackgroundQueue ) { - fun decodeRecordWaveform(path: String, listener: AudioDecodingListener? = null) { + fun decodeRecordWaveform(context: Context, path: String, listener: AudioDecodingListener? = null) { processingTasks.postRunnable { - AudioDecoder.decode(path, object : AudioDecodingListener { + AudioDecoder.decode(context, path, object : AudioDecodingListener { override fun isCanceled(): Boolean { return listener?.isCanceled() ?: false } diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/player/AudioPlayerNew.kt b/app/src/main/java/com/dimowner/audiorecorder/audio/player/AudioPlayerNew.kt index 3f035a002..d18e01fb0 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/player/AudioPlayerNew.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/player/AudioPlayerNew.kt @@ -15,6 +15,7 @@ */ package com.dimowner.audiorecorder.audio.player +import android.content.Context import android.media.AudioManager import android.media.MediaPlayer import android.media.MediaPlayer.OnPreparedListener @@ -26,8 +27,15 @@ import com.dimowner.audiorecorder.exception.PlayerDataSourceException import com.dimowner.audiorecorder.exception.PlayerInitException import timber.log.Timber import java.util.* +import androidx.core.net.toUri -class AudioPlayerNew: PlayerContractNew.Player, OnPreparedListener { +/** + * @param context Required to resolve content:// data sources (records stored in a + * user-selected public directory). When null only plain file paths are supported. + */ +class AudioPlayerNew( + private val context: Context? = null, +): PlayerContractNew.Player, OnPreparedListener { private val actionsListeners: MutableList = ArrayList() @@ -49,7 +57,11 @@ class AudioPlayerNew: PlayerContractNew.Player, OnPreparedListener { try { playerState = PlayerState.STOPPED mediaPlayer.reset() - mediaPlayer.setDataSource(dataSource) + if (dataSource.startsWith("content://") && context != null) { + mediaPlayer.setDataSource(context, dataSource.toUri()) + } else { + mediaPlayer.setDataSource(dataSource) + } mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC) } catch (e: Exception) { Timber.e(e) diff --git a/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java b/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java index e902acf0a..665597d94 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java +++ b/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java @@ -340,13 +340,25 @@ private static void insertMenuItemIcon(Context context, MenuItem menuItem) { menuItem.setIcon(null); } + /** + * Resolves a record path to a shareable Uri: a content:// document Uri (record stored in + * a user-selected public directory) is shared directly, a file path goes through + * the app's FileProvider. + */ + private static Uri getShareableUri(Context context, String sharePath) { + if (sharePath.startsWith("content://")) { + return Uri.parse(sharePath); + } + return FileProvider.getUriForFile( + context, + context.getApplicationContext().getPackageName() + ".app_file_provider", + new File(sharePath) + ); + } + public static void shareAudioFile(Context context, String sharePath, String name, String format) { if (sharePath != null) { - Uri fileUri = FileProvider.getUriForFile( - context, - context.getApplicationContext().getPackageName() + ".app_file_provider", - new File(sharePath) - ); + Uri fileUri = getShareableUri(context, sharePath); Intent share = new Intent(Intent.ACTION_SEND); share.setType("audio/" + format); share.putExtra(Intent.EXTRA_STREAM, fileUri); @@ -369,12 +381,7 @@ public static void shareAudioFiles(Context context, List list) { ArrayList files = new ArrayList<>(); for(String path : list) { - Uri uri = FileProvider.getUriForFile( - context, - context.getApplicationContext().getPackageName() + ".app_file_provider", - new File(path) - ); - files.add(uri); + files.add(getShareableUri(context, path)); } intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, files); String text = context.getResources().getQuantityString(R.plurals.share_records_count, list.size(), list.size()); @@ -385,11 +392,7 @@ public static void shareAudioFiles(Context context, List list) { public static void openAudioFile(Context context, String sharePath, String name) { if (sharePath != null) { - Uri fileUri = FileProvider.getUriForFile( - context, - context.getApplicationContext().getPackageName() + ".app_file_provider", - new File(sharePath) - ); + Uri fileUri = getShareableUri(context, sharePath); Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(fileUri, "audio/*"); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 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 5f7ac165d..aea183977 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 @@ -71,6 +71,8 @@ import com.dimowner.audiorecorder.v2.data.PrefsV2 import com.dimowner.audiorecorder.v2.data.RecordsDataSource import com.dimowner.audiorecorder.v2.data.extensions.isLostRecord import com.dimowner.audiorecorder.v2.data.extensions.copyFile +import com.dimowner.audiorecorder.v2.data.extensions.getDocumentLength +import com.dimowner.audiorecorder.v2.data.extensions.isContentUri import com.dimowner.audiorecorder.v2.data.model.AudioSource import com.dimowner.audiorecorder.v2.data.model.Record import com.dimowner.audiorecorder.v2.analytics.AnalyticsTracker @@ -579,10 +581,14 @@ class HomeViewModel @Inject constructor( val context: Context = getApplication().applicationContext val record = recordsDataSource.getRecord(prefs.recordedRecordId) record?.let { - val file = File(record.path) + val recordSize = if (record.path.isContentUri()) { + getDocumentLength(context, record.path) + } else { + File(record.path).length() + } withContext(mainDispatcher) { _state.value = _state.value.copy( - recordInfo = record.copy(size = file.length()).toInfoCombinedText(context) + recordInfo = record.copy(size = recordSize).toInfoCombinedText(context) ) } } @@ -592,7 +598,7 @@ class HomeViewModel @Inject constructor( val context: Context = getApplication().applicationContext val activeRecord = recordsDataSource.getActiveRecord() if (activeRecord != null) { - val lostRecord = if (activeRecord.isLostRecord()) { + val lostRecord = if (activeRecord.isLostRecord(context)) { activeRecord } else { null @@ -890,6 +896,30 @@ class HomeViewModel @Inject constructor( } private suspend fun performRenameActiveRecord(newName: String, activeRecord: Record) { + val context: Context = getApplication().applicationContext + if (activeRecord.path.isContentUri()) { + // A SAF document has no filesystem path to pre-check for collisions; + // the DocumentsProvider itself rejects a rename to an existing name. + if (activeRecord.name == newName) { + showLoadingProgress(false) + return + } + if (recordsDataSource.renameRecord(activeRecord, newName)) { + emitEvent( + HomeScreenEvent.ShowInfoSnack( + context.getString(R.string.msg_record_renamed, newName) + ) + ) + } else { + emitEvent( + HomeScreenEvent.ShowErrorSnack( + context.getString(R.string.error_file_exists) + ) + ) + showLoadingProgress(false) + } + return + } val currentFile = File(activeRecord.path) // Skip rename if the name hasn't changed if (currentFile.nameWithoutExtension == newName) { @@ -989,10 +1019,21 @@ class HomeViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { val activeRecord = recordsDataSource.getActiveRecord() if (activeRecord != null) { - DownloadService.startNotification( - getApplication().applicationContext, - activeRecord.path - ) + if (activeRecord.path.isContentUri()) { + //The download pipeline requires direct file access; a record in a + // user-selected public directory is already reachable by other apps. + val context: Context = getApplication().applicationContext + emitEvent( + HomeScreenEvent.ShowInfoSnack( + context.getString(R.string.msg_record_already_in_public_dir) + ) + ) + } else { + DownloadService.startNotification( + getApplication().applicationContext, + activeRecord.path + ) + } } } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoViewModel.kt index 580bd9fc8..aeea58c5f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoViewModel.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoViewModel.kt @@ -24,6 +24,7 @@ import com.dimowner.audiorecorder.v2.audio.readAuthorName import com.dimowner.audiorecorder.v2.audio.readDescription import com.dimowner.audiorecorder.v2.data.PrefsV2 import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.extensions.isContentUri import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher @@ -72,7 +73,13 @@ class RecordInfoViewModel @Inject constructor( if (_authorName.value != null) return // already loaded viewModelScope.launch { val name = withContext(ioDispatcher) { - File(filePath).readAuthorName() + if (filePath.isContentUri()) { + //Tags of records in a public directory are not readable: + // the tag library requires direct file access. + "" + } else { + File(filePath).readAuthorName() + } } _authorName.value = name } @@ -91,7 +98,13 @@ class RecordInfoViewModel @Inject constructor( if (dbRecord != null && dbRecord.description.isNotBlank()) { dbRecord.description } else { - val fileDesc = File(filePath).readDescription() + val fileDesc = if (filePath.isContentUri()) { + //Tags of records in a public directory are not readable: + // the tag library requires direct file access. + "" + } else { + File(filePath).readDescription() + } if (fileDesc.isNotBlank() && dbRecord != null) { try { recordsDataSource.updateRecord(dbRecord.copy(description = fileDesc)) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt index 1042a000f..9c4d2a4ab 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt @@ -42,6 +42,7 @@ import com.dimowner.audiorecorder.v2.data.PrefsV2 import com.dimowner.audiorecorder.v2.data.RecordsDataSource import com.dimowner.audiorecorder.v2.analytics.AnalyticsTracker import com.dimowner.audiorecorder.v2.data.extensions.checkForLostRecords +import com.dimowner.audiorecorder.v2.data.extensions.isContentUri import com.dimowner.audiorecorder.v2.data.model.Record import com.dimowner.audiorecorder.v2.data.model.SortOrder import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher @@ -150,7 +151,7 @@ internal class RecordsViewModel @Inject constructor( val deletedRecordsCount = recordsDataSource.getMovedToRecycleRecordsCount() val filterOptions = recordsDataSource.getFilterOptions() - val lostRecords = checkForLostRecords(allLoadedRecords) + val lostRecords = checkForLostRecords(context, allLoadedRecords) if (lostRecords.isNotEmpty()) { analyticsTracker.trackLostRecordsDetected(count = lostRecords.size) } @@ -426,6 +427,10 @@ internal class RecordsViewModel @Inject constructor( fun renameRecord(recordId: Long, newName: String) { viewModelScope.launch(ioDispatcher) { recordsDataSource.getRecord(recordId)?.let { record -> + if (record.path.isContentUri()) { + renameSafRecord(record, newName) + return@let + } val currentFile = File(record.path) // Skip rename if the name hasn't changed if (currentFile.nameWithoutExtension == newName) { @@ -477,6 +482,46 @@ internal class RecordsViewModel @Inject constructor( } } + /** + * Renames a record stored in a user-selected public directory. A SAF document has no + * filesystem path to pre-check for collisions; the DocumentsProvider itself rejects + * a rename to an existing name. + */ + private suspend fun renameSafRecord(record: Record, newName: String) { + if (record.name == newName) { + _state.value = _state.value.copy( + showRenameDialog = false, + operationSelectedRecord = null + ) + return + } + val context: Context = getApplication().applicationContext + if (recordsDataSource.renameRecord(record, newName)) { + emitEvent( + RecordsScreenEvent.ShowInfoSnack( + context.getString(R.string.msg_record_renamed, newName) + ) + ) + _state.value = _state.value.copy( + showRenameDialog = false, + operationSelectedRecord = null, + recordsMap = _state.value.recordsMap.mapRecordInMap(record.id) { oldRecord -> + oldRecord.copy(name = newName) + } + ) + } else { + emitEvent( + RecordsScreenEvent.ShowErrorSnack( + context.getString(R.string.error_file_exists) + ) + ) + _state.value = _state.value.copy( + showRenameDialog = false, + operationSelectedRecord = null + ) + } + } + fun onEditDescriptionRequest(record: RecordListItem) { multiSelectCancel() _state.value = _state.value.copy( @@ -555,10 +600,21 @@ internal class RecordsViewModel @Inject constructor( multiSelectCancel() viewModelScope.launch(ioDispatcher) { recordsDataSource.getRecord(recordId)?.let { - DownloadService.startNotification( - getApplication().applicationContext, - it.path - ) + if (it.path.isContentUri()) { + //The download pipeline requires direct file access; a record in a + // user-selected public directory is already reachable by other apps. + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowInfoSnack( + context.getString(R.string.msg_record_already_in_public_dir) + ) + ) + } else { + DownloadService.startNotification( + getApplication().applicationContext, + it.path + ) + } } _state.value = _state.value.copy( showSaveAsDialog = false, @@ -773,12 +829,23 @@ internal class RecordsViewModel @Inject constructor( private fun multiSelectSaveAs() { viewModelScope.launch(ioDispatcher) { val recordList = recordsDataSource.getRecords(state.value.selectedRecords.map { it.recordId }) - if (recordList.isNotEmpty()) { + //The download pipeline requires direct file access; records in a user-selected + // public directory are skipped — they are already reachable by other apps. + val downloadableList = recordList.filter { !it.path.isContentUri() } + if (downloadableList.size < recordList.size) { + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowInfoSnack( + context.getString(R.string.msg_record_already_in_public_dir) + ) + ) + } + if (downloadableList.isNotEmpty()) { withContext(mainDispatcher) { //Download record file with Service DownloadService.startNotification( getApplication().applicationContext, - recordList + downloadableList .map { it.path } .toCollection(ArrayList()) ) @@ -787,6 +854,11 @@ internal class RecordsViewModel @Inject constructor( showSaveAsMultipleDialog = false, ) } + } else if (recordList.isNotEmpty()) { + multiSelectCancel() + _state.value = _state.value.copy( + showSaveAsMultipleDialog = false, + ) } else { val context: Context = getApplication().applicationContext emitEvent( diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt index 1fe4a7ba9..10469a10f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt @@ -16,9 +16,12 @@ package com.dimowner.audiorecorder.v2.app.settings +import android.content.ActivityNotFoundException import android.os.Build import android.text.format.Formatter import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -40,6 +43,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -196,6 +200,11 @@ internal fun SettingsScreen( currentAuthorName = uiState.recordAuthorName, onAction = onAction, ) + RecordingLocationSettingRow( + publicRecordingDirName = uiState.publicRecordingDirName, + onAction = onAction, + enabled = uiState.isRecordingSettingEditable, + ) ResetRecordingSettingsPanel( sizePerMin = stringResource(id = R.string.size_per_min, uiState.sizePerMin), recordingSettingsText = uiState.recordingSettingsText, @@ -339,6 +348,84 @@ internal fun MaxDurationSettingRow( } } +/** + * Setting row for the recording storage location. Tapping the row opens the system directory + * picker (Storage Access Framework, no storage permission required); the picked public + * directory is used for all new recordings. The reset button switches back to the default + * app-private storage. + */ +@Composable +internal fun RecordingLocationSettingRow( + publicRecordingDirName: String?, + onAction: (SettingsScreenAction) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val directoryPickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { uri -> + if (uri != null) { + onAction(SettingsScreenAction.SetPublicRecordingDir(uri)) + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(if (enabled) 1f else DISABLED_ALPHA) + .clickable(enabled = enabled) { + try { + directoryPickerLauncher.launch(null) + } catch (e: ActivityNotFoundException) { + Timber.e(e, "No activity found to handle OpenDocumentTree") + } + } + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(16.dp) + .wrapContentSize(), + painter = painterResource(id = R.drawable.ic_folder_open), + contentDescription = stringResource(R.string.recording_location), + ) + Column( + modifier = Modifier + .weight(1f) + .wrapContentHeight() + ) { + Text( + text = stringResource(R.string.recording_location), + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = publicRecordingDirName + ?: stringResource(R.string.recording_location_app_storage), + fontSize = 14.sp, + lineHeight = 18.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } + if (publicRecordingDirName != null) { + IconButton( + enabled = enabled, + onClick = { onAction(SettingsScreenAction.ResetPublicRecordingDir) }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_round_close), + contentDescription = stringResource(R.string.recording_location_reset), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable internal fun AuthorNameSettingRow( currentAuthorName: String, diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt index 87babe874..36d01bd41 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt @@ -52,6 +52,11 @@ data class SettingsState( val recordAuthorName: String, /** True when the user previously used V1 and intentionally switched to V2. */ val isLegacyAppUser: Boolean = false, + /** + * Display name of the user-selected public directory where new recordings are stored, + * or null when the default app-private storage is used. + */ + val publicRecordingDirName: String? = null, ) : Parcelable @Parcelize 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 7cac3a25e..95e331a4c 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 @@ -17,6 +17,8 @@ package com.dimowner.audiorecorder.v2.app.settings import android.content.Context +import android.content.Intent +import android.net.Uri import android.os.Parcelable import androidx.compose.runtime.MutableState import androidx.compose.runtime.State @@ -38,6 +40,7 @@ import com.dimowner.audiorecorder.v2.analytics.AnalyticsTracker 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.extensions.getTreeDisplayName import com.dimowner.audiorecorder.v2.data.model.AudioSource import com.dimowner.audiorecorder.v2.data.model.BitRate import com.dimowner.audiorecorder.v2.data.model.ChannelCount @@ -50,10 +53,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Locale import javax.inject.Inject +import androidx.core.net.toUri @HiltViewModel internal class SettingsViewModel @Inject constructor( @@ -65,7 +70,7 @@ internal class SettingsViewModel @Inject constructor( private val analyticsTracker: AnalyticsTracker, @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, - @ApplicationContext context: Context, + @param:ApplicationContext private val context: Context, ) : ViewModel() { private val decimalFormat: DecimalFormat @@ -157,6 +162,9 @@ internal class SettingsViewModel @Inject constructor( val recordsCount = recordsDataSource.getRecordsCount() val recordsDuration = recordsDataSource.getRecordTotalDuration() val rawAvailableSpaceBytes = fileDataSource.getAvailableSpace() + val publicDirName = prefs.publicRecordingDirUri?.let { + getTreeDisplayName(context, it) ?: it + } val settings = _state.value.recordingSettings.firstOrNull { it.recordingFormat.isSelected } val availableTimeMills = spaceToRecordingTimeMills( rawAvailableSpaceBytes, @@ -173,7 +181,8 @@ internal class SettingsViewModel @Inject constructor( availableSpaceMills = availableTimeMills, availableSpaceBytes = rawAvailableSpaceBytes, // Load the selected audio source from preferences - selectedAudioSource = prefs.settingAudioSource + selectedAudioSource = prefs.settingAudioSource, + publicRecordingDirName = publicDirName, ) } recordsDataSource.removeOutdatedTrashRecords() @@ -386,6 +395,56 @@ internal class SettingsViewModel @Inject constructor( _state.value = _state.value.copy(recordAuthorName = trimmed) } + /** + * Persists the SAF tree picked via Intent.ACTION_OPEN_DOCUMENT_TREE as the public directory + * for new recordings. Takes a persistable Uri permission (no storage permission required) + * and releases the permission of the previously selected directory. + */ + fun setPublicRecordingDir(uri: Uri) { + viewModelScope.launch(ioDispatcher) { + try { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } catch (e: SecurityException) { + Timber.e(e, "Failed to take persistable permission for: $uri") + return@launch + } + releasePublicRecordingDirPermission(except = uri) + prefs.publicRecordingDirUri = uri.toString() + val name = getTreeDisplayName(context, uri.toString()) ?: uri.toString() + withContext(mainDispatcher) { + _state.value = _state.value.copy(publicRecordingDirName = name) + } + } + } + + /** Switches new recordings back to the default app-private storage. */ + fun resetPublicRecordingDir() { + viewModelScope.launch(ioDispatcher) { + releasePublicRecordingDirPermission(except = null) + prefs.publicRecordingDirUri = null + withContext(mainDispatcher) { + _state.value = _state.value.copy(publicRecordingDirName = null) + } + } + } + + private fun releasePublicRecordingDirPermission(except: Uri?) { + val previous = prefs.publicRecordingDirUri ?: return + val previousUri = previous.toUri() + if (previousUri == except) return + try { + context.contentResolver.releasePersistableUriPermission( + previousUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } catch (e: SecurityException) { + Timber.w(e, "Failed to release persistable permission for: $previousUri") + } + } + fun onAction(action: SettingsScreenAction) { when (action) { SettingsScreenAction.InitSettingsScreen -> initSettings() @@ -402,6 +461,8 @@ internal class SettingsViewModel @Inject constructor( is SettingsScreenAction.SetMaxRecordingDuration -> setMaxRecordingDuration(action.durationMinutes) is SettingsScreenAction.SetAudioSource -> setAudioSource(action.audioSource) is SettingsScreenAction.SetRecordAuthorName -> setRecordAuthorName(action.name) + is SettingsScreenAction.SetPublicRecordingDir -> setPublicRecordingDir(action.uri) + SettingsScreenAction.ResetPublicRecordingDir -> resetPublicRecordingDir() SettingsScreenAction.ExecuteFirstRun -> executeFirstRun() is SettingsScreenAction.SetAppV2 -> handleUseAppV2(action.value) SettingsScreenAction.UnlockLegacyAppSwitch -> unlockLegacyAppSwitch() @@ -469,6 +530,8 @@ internal sealed class SettingsScreenAction { data class SetMaxRecordingDuration(val durationMinutes: Int) : SettingsScreenAction() data class SetAudioSource(val audioSource: AudioSource) : SettingsScreenAction() data class SetRecordAuthorName(val name: String) : SettingsScreenAction() + data class SetPublicRecordingDir(val uri: Uri) : SettingsScreenAction() + data object ResetPublicRecordingDir : SettingsScreenAction() data object ExecuteFirstRun : SettingsScreenAction() data object UnlockLegacyAppSwitch : SettingsScreenAction() } 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 ffc5d8e69..5bb23d99b 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 @@ -23,6 +23,7 @@ import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.net.Uri import android.os.Binder import android.os.Build import android.os.Handler @@ -46,7 +47,9 @@ 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.extensions.isContentUri import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.RecordTarget import com.dimowner.audiorecorder.v2.data.model.RecordingFormat import com.dimowner.audiorecorder.v2.data.model.convertToRecordingFormat import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher @@ -165,6 +168,12 @@ class AudioRecordingService : Service() { */ private var lastAvailableSpaceCheckTime: Long = 0L + /** + * Path or content:// Uri of the record currently being written; used to check available + * space at the actual recording location (which may be a user-selected public directory). + */ + @Volatile private var currentRecordPathOrUri: String? = null + inner class ServiceBinder : Binder() { fun getService(): AudioRecordingService = this@AudioRecordingService } @@ -308,7 +317,7 @@ class AudioRecordingService : Service() { val now = System.currentTimeMillis() if (now - lastAvailableSpaceCheckTime >= AppConstants.MIN_REMAIN_RECORDING_TIME / 2) { lastAvailableSpaceCheckTime = now - val space = fileDataSource.getAvailableSpace() + val space = fileDataSource.getAvailableSpace(currentRecordPathOrUri) val availableTimeSeconds = convertSpaceBytesToTimeInSeconds( spaceBytes = space, recordingFormat = format, @@ -378,74 +387,99 @@ class AudioRecordingService : Service() { val bitrate = prefs.settingBitrate.value val channelCount = prefs.settingChannelCount.value - val availableTimeSeconds = convertSpaceBytesToTimeInSeconds( - spaceBytes = fileDataSource.getAvailableSpace(), - 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, - 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, - ) - - audioRecorder.startRecording( - outputFile = recordFile, - channelCount = channelCount, - sampleRate = sampleRate, - bitrate = bitrate, - maxRecordingDurationMills = prefs.maxRecordingDurationMills, - audioSource = prefs.settingAudioSource.value, - ) - 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) + if (audioRecorder.isRecording) { + return null + } + try { + // The record file is created before the space check because the available space + // at a user-selected public directory can only be queried through an existing + // document's file descriptor. + val target = fileDataSource.createRecordTarget(addExtension(recordName)) + if (prefs.publicRecordingDirUri != null && target is RecordTarget.LocalFile) { + //The user-selected public directory was not accessible; + // the record fell back to app-private storage. emitEvent(AudioRecordingServiceEvent.ShowErrorSnack( - "$failedToStartRecordingMsg\n$cantCreateFileMsg" + applicationContext.getString(R.string.error_public_dir_unavailable) )) - stopForegroundService() } + + val availableTimeSeconds = convertSpaceBytesToTimeInSeconds( + spaceBytes = fileDataSource.getAvailableSpace(target.pathOrUri), + recordingFormat = format, + sampleRate = sampleRate, + bitrate = bitrate, + channels = channelCount + ) + if (availableTimeSeconds <= AppConstants.MIN_REMAIN_RECORDING_TIME) { + //Not enough space to start recording. Remove the just-created empty file. + fileDataSource.deleteRecordFile(target.pathOrUri) + return null + } + + currentRecordPathOrUri = target.pathOrUri + // Use the actual file name (without extension) in case a suffix was added to avoid collision + val actualRecordName = target.nameWithoutExtension + val record = Record( + id = 0, + name = actualRecordName, + durationMills = 0, + created = target.created, + added = System.currentTimeMillis(), + removed = Long.MAX_VALUE, + path = target.pathOrUri, + 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, + ) + + audioRecorder.startRecording( + output = target.toRecordingOutput(), + channelCount = channelCount, + sampleRate = sampleRate, + bitrate = bitrate, + maxRecordingDurationMills = prefs.maxRecordingDurationMills, + audioSource = prefs.settingAudioSource.value, + ) + 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() } return null } + private fun RecordTarget.toRecordingOutput(): RecordingOutput { + return when (this) { + is RecordTarget.LocalFile -> RecordingOutput.OutputFile(file) + is RecordTarget.PublicDocument -> RecordingOutput.OutputDocument(uri) + } + } + private fun startForegroundWithNotification() { val notification = buildNotification() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -493,9 +527,18 @@ class AudioRecordingService : Service() { if (recordedRecordId >= 0) { val record = recordsDataSource.getRecord(recordedRecordId) if (record != null) { - val output = File(record.path) - val info = AudioDecoder.readRecordInfo(output) - output.writeTags(record.name, prefs.recordAuthorName) + val info = if (record.path.isContentUri()) { + AudioDecoder.readRecordInfo(applicationContext, Uri.parse(record.path)) + } else { + AudioDecoder.readRecordInfo(File(record.path)) + } + if (record.path.isContentUri()) { + //Metadata tags are not written for records in a public directory: + // the tag library requires direct file access. + Timber.d("Skip writing tags for SAF record: ${record.path}") + } else { + File(record.path).writeTags(record.name, prefs.recordAuthorName) + } val recordUpdated = record.copy( durationMills = info.duration / 1000, format = info.format, @@ -552,6 +595,7 @@ class AudioRecordingService : Service() { private fun stopForegroundService() { recordingAmplitudes.clear() totalRecordingSampleCount = 0 + currentRecordPathOrUri = null recordingFullDataBuffer.reset() _recordingState.value = RecordingServiceState() stopNotificationUpdates() 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 b77cdd020..cb82baafb 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 @@ -20,6 +20,7 @@ import android.media.MediaRecorder import android.os.Build import android.os.Handler import android.os.Looper +import android.os.ParcelFileDescriptor import android.os.SystemClock import com.dimowner.audiorecorder.AppConstants.RECORDING_VISUALIZATION_INTERVAL_NEW import com.dimowner.audiorecorder.IntArrayList @@ -31,7 +32,6 @@ 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 java.util.Timer import java.util.TimerTask @@ -53,7 +53,8 @@ abstract class MediaRecorderBase( private val amplitudesBuffer: IntArrayList = IntArrayList() @Volatile private var lastNonZeroAmplitude: Int = 0 private var mediaRecorder: MediaRecorder? = null - private var recordFile: File? = null + /** Open descriptor of the SAF output document; held for the whole recording session. */ + private var outputPfd: ParcelFileDescriptor? = null private var updateTime: Long = 0 private var durationMills: Long = 0 @@ -90,7 +91,7 @@ abstract class MediaRecorderBase( ) override fun startRecording( - outputFile: File, + output: RecordingOutput, channelCount: Int, sampleRate: Int, bitrate: Int, @@ -98,7 +99,7 @@ abstract class MediaRecorderBase( audioSource: Int, ): Boolean { Timber.d( - "Start ${recordingLogTag}Recording outputFile: ${outputFile.absolutePath}" + + "Start ${recordingLogTag}Recording output: ${output.describe()}" + " channelCount: $channelCount sampleRate: $sampleRate bitrate: $bitrate" + " maxRecordingDurationMills: $maxRecordingDurationMills audioSource: $audioSource" ) @@ -109,57 +110,90 @@ abstract class MediaRecorderBase( } amplitudesBuffer.clear() lastNonZeroAmplitude = 0 - return if (outputFile.exists() && outputFile.isFile) { - recordFile = outputFile - val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaRecorder(applicationContext) - } else { - @Suppress("DEPRECATION") - MediaRecorder() - } - this.mediaRecorder = recorder - - recorder.apply { - setAudioSource(audioSource) - configureRecorder(this, channelCount, sampleRate, bitrate) - setMaxDuration(maxRecordingDurationMills) - setOnInfoListener { _, what, _ -> handleRecorderInfo(what) } - setOutputFile(outputFile.absolutePath) - } + if (output is RecordingOutput.OutputFile + && !(output.file.exists() && output.file.isFile) + ) { + emitEvent(RecorderEvent.OnError(InvalidOutputFile())) + return false + } + val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(applicationContext) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + this.mediaRecorder = recorder - try { - recorder.prepare() - recorder.start() - scheduleRecordingTimeUpdate() - scheduleRecordingTimeUpdateBuffered() - emitEvent(RecorderEvent.OnStartRecording) - _isPaused = false - true - } catch (e: IOException) { - Timber.e(e, "prepare() failed") - mediaRecorder?.release() - mediaRecorder = null - emitEvent(RecorderEvent.OnError(RecorderInitException())) - false - } catch (e: IllegalStateException) { - Timber.e(e, "start() failed due to illegal state") - mediaRecorder?.release() - mediaRecorder = null - emitEvent(RecorderEvent.OnError(RecorderInitException())) - false - } catch (e: RuntimeException) { - // MediaRecorder.start() throws a plain RuntimeException (not a subclass) when - // the hardware source is unavailable or the codec rejects the configuration. - Timber.e(e, "start() failed") - mediaRecorder?.release() - mediaRecorder = null - emitEvent(RecorderEvent.OnError(RecorderInitException())) - false + recorder.apply { + setAudioSource(audioSource) + configureRecorder(this, channelCount, sampleRate, bitrate) + setMaxDuration(maxRecordingDurationMills) + setOnInfoListener { _, what, _ -> handleRecorderInfo(what) } + } + + try { + when (output) { + is RecordingOutput.OutputFile -> { + recorder.setOutputFile(output.file.absolutePath) + } + is RecordingOutput.OutputDocument -> { + // A SAF document has no filesystem path the recorder could open itself; + // hand it an open descriptor instead. Requires no storage permission. + val pfd = applicationContext.contentResolver + .openFileDescriptor(output.uri, "rw") + ?: throw IOException("Cannot open output document: ${output.uri}") + outputPfd = pfd + recorder.setOutputFile(pfd.fileDescriptor) + } } - } else { + } catch (e: Exception) { + Timber.e(e, "Failed to set recorder output: ${output.describe()}") + releaseRecorderAndOutput() emitEvent(RecorderEvent.OnError(InvalidOutputFile())) + return false + } + + return try { + recorder.prepare() + recorder.start() + scheduleRecordingTimeUpdate() + scheduleRecordingTimeUpdateBuffered() + emitEvent(RecorderEvent.OnStartRecording) + _isPaused = false + true + } catch (e: IOException) { + Timber.e(e, "prepare() failed") + releaseRecorderAndOutput() + emitEvent(RecorderEvent.OnError(RecorderInitException())) false + } catch (e: IllegalStateException) { + Timber.e(e, "start() failed due to illegal state") + releaseRecorderAndOutput() + emitEvent(RecorderEvent.OnError(RecorderInitException())) + false + } catch (e: RuntimeException) { + // MediaRecorder.start() throws a plain RuntimeException (not a subclass) when + // the hardware source is unavailable or the codec rejects the configuration. + Timber.e(e, "start() failed") + releaseRecorderAndOutput() + emitEvent(RecorderEvent.OnError(RecorderInitException())) + false + } + } + + private fun releaseRecorderAndOutput() { + mediaRecorder?.release() + mediaRecorder = null + closeOutputPfd() + } + + private fun closeOutputPfd() { + try { + outputPfd?.close() + } catch (e: IOException) { + Timber.e(e, "Failed to close output document descriptor") } + outputPfd = null } override fun resumeRecording(): Boolean { @@ -236,6 +270,7 @@ abstract class MediaRecorderBase( // Always release resources mediaRecorder?.release() mediaRecorder = null + closeOutputPfd() } if (!skipStopRecordingEventEmit) { @@ -244,7 +279,6 @@ abstract class MediaRecorderBase( // Reset all state durationMills = 0 - recordFile = null _isRecording = false _isPaused = false synchronized(amplitudesBuffer) { amplitudesBuffer.clear() } 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 84d8587d6..81d9ceab2 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 @@ -1,14 +1,32 @@ package com.dimowner.audiorecorder.v2.audio +import android.net.Uri import com.dimowner.audiorecorder.exception.AppException; import kotlinx.coroutines.flow.Flow import java.io.File; +/** + * Destination the recorder writes into: a plain file in the app-private storage, or a SAF + * document in the user-selected public directory written through a file descriptor + * (no storage permission required). + */ +sealed class RecordingOutput { + data class OutputFile(val file: File) : RecordingOutput() + data class OutputDocument(val uri: Uri) : RecordingOutput() + + fun describe(): String { + return when (this) { + is OutputFile -> file.absolutePath + is OutputDocument -> uri.toString() + } + } +} + interface RecorderV2 { fun subscribeRecorderEvents(): Flow fun startRecording( - outputFile: File, + output: RecordingOutput, channelCount: Int, sampleRate: Int, bitrate: Int, 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 259235304..e22e8bb9a 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 @@ -1,13 +1,16 @@ package com.dimowner.audiorecorder.v2.audio +import android.content.Context import android.media.AudioFormat import android.media.AudioRecord +import android.os.ParcelFileDescriptor import com.dimowner.audiorecorder.AppConstants.RECORDING_VISUALIZATION_INTERVAL_NEW import com.dimowner.audiorecorder.audio.sumOfAmplitudes import com.dimowner.audiorecorder.IntArrayList import com.dimowner.audiorecorder.exception.AlreadyRecordingException import com.dimowner.audiorecorder.exception.InvalidOutputFile import com.dimowner.audiorecorder.exception.RecorderInitException +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -17,10 +20,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import timber.log.Timber -import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.io.RandomAccessFile import java.util.Timer import java.util.TimerTask import javax.inject.Inject @@ -28,11 +29,14 @@ import javax.inject.Singleton @Singleton class WavRecorderV2 @Inject constructor( + @ApplicationContext private val applicationContext: Context, private val coroutineScope: CoroutineScope, ) : RecorderV2 { private var audioRecord: AudioRecord? = null private var recordingJob: Job? = null + /** Open descriptor of the SAF output document; held for the whole recording session. */ + private var outputPfd: ParcelFileDescriptor? = null @Volatile private var _isRecording: Boolean = false @Volatile private var _isPaused: Boolean = false @@ -58,7 +62,7 @@ class WavRecorderV2 @Inject constructor( } override fun startRecording( - outputFile: File, + output: RecordingOutput, channelCount: Int, sampleRate: Int, bitrate: Int, @@ -66,7 +70,7 @@ class WavRecorderV2 @Inject constructor( audioSource: Int, ): Boolean { Timber.d( - "WavRecorderV2.startRecording outputFile: ${outputFile.absolutePath} channelCount: $channelCount" + + "WavRecorderV2.startRecording output: ${output.describe()} channelCount: $channelCount" + " sampleRate: $sampleRate bitrate: $bitrate maxRecordingDurationMills: $maxRecordingDurationMills" + " audioSource: $audioSource" ) @@ -78,7 +82,9 @@ class WavRecorderV2 @Inject constructor( amplitudesBuffer.clear() lastNonZeroAmplitude = 0 lastEmittedDurationMills = -1L - if (!outputFile.exists() || !outputFile.isFile) { + if (output is RecordingOutput.OutputFile + && !(output.file.exists() && output.file.isFile) + ) { emitEvent(RecorderEvent.OnError(InvalidOutputFile())) return false } @@ -131,15 +137,16 @@ class WavRecorderV2 @Inject constructor( audioRecord = recorder - // Write a placeholder 44-byte WAV header; it will be overwritten with real values after recording. - try { - FileOutputStream(outputFile).use { fos -> - fos.write(ByteArray(44)) - } - } catch (e: IOException) { - Timber.e(e, "Failed to write placeholder WAV header") + // Open the output stream once for the whole session (plain file or SAF document + // descriptor) and write a placeholder 44-byte WAV header; it is overwritten in-place + // with real values after recording via the stream's seekable channel. + val outputStream = try { + openOutputStream(output).also { it.write(ByteArray(44)) } + } catch (e: Exception) { + Timber.e(e, "Failed to open WAV output: ${output.describe()}") recorder.release() audioRecord = null + closeOutputPfd() emitEvent(RecorderEvent.OnError(RecorderInitException())) return false } @@ -150,6 +157,7 @@ class WavRecorderV2 @Inject constructor( Timber.e(e, "startRecording() failed") recorder.release() audioRecord = null + closeOutput(outputStream) emitEvent(RecorderEvent.OnError(RecorderInitException())) return false } @@ -163,13 +171,11 @@ class WavRecorderV2 @Inject constructor( // Launch a coroutine to read audio data in the background recordingJob = coroutineScope.launch(Dispatchers.IO) { val buffer = ByteArray(bufferSize) - var fos: FileOutputStream? = null var totalBytesWritten = 0L val bytesPerSecond = sampleRate * channelCount * (bitsPerSample / 8) var maxDurationReached = false try { - fos = FileOutputStream(outputFile, true) // append after the placeholder header while (isActive && _isRecording) { if (_isPaused) { // Read and discard PCM data to prevent accumulating stale audio during pause @@ -182,7 +188,7 @@ class WavRecorderV2 @Inject constructor( } val readResult = recorder.read(buffer, 0, readChunkSize) if (readResult > 0) { - fos.write(buffer, 0, readResult) + outputStream.write(buffer, 0, readResult) totalBytesWritten += readResult // Calculate duration from bytes written @@ -215,44 +221,36 @@ class WavRecorderV2 @Inject constructor( } catch (e: IOException) { Timber.e(e, "Error writing PCM data") emitEvent(RecorderEvent.OnError(RecorderInitException())) - } finally { - try { - fos?.close() - } catch (e: IOException) { - Timber.e(e, "Error closing output file stream") - } } - // Write the real WAV header in-place now that we know the final audio length. - if (outputFile.exists()) { - try { - val totalAudioLen = totalBytesWritten - val totalDataLen = totalAudioLen + 36 - val byteRate = (sampleRateConfig * channelCountConfig * bitsPerSample / 8).toLong() - - RandomAccessFile(outputFile, "rw").use { raf -> - raf.seek(0) - val headerStream = FileOutputStream(raf.fd) - writeWavHeader( - out = headerStream, - totalAudioLen = totalAudioLen, - totalDataLen = totalDataLen, - sampleRate = sampleRateConfig, - channels = channelCountConfig, - byteRate = byteRate, - ) - headerStream.flush() - } - - if (maxDurationReached) { - emitEvent(RecorderEvent.OnMaxDurationReached) - } else { - emitEvent(RecorderEvent.OnStopRecording) - } - } catch (e: IOException) { - Timber.e(e, "Error writing WAV header") - emitEvent(RecorderEvent.OnError(RecorderInitException())) + // Write the real WAV header in-place now that we know the final audio length, + // seeking back to the start of the still-open output stream. + try { + val totalAudioLen = totalBytesWritten + val totalDataLen = totalAudioLen + 36 + val byteRate = (sampleRateConfig * channelCountConfig * bitsPerSample / 8).toLong() + + outputStream.channel.position(0) + writeWavHeader( + out = outputStream, + totalAudioLen = totalAudioLen, + totalDataLen = totalDataLen, + sampleRate = sampleRateConfig, + channels = channelCountConfig, + byteRate = byteRate, + ) + outputStream.flush() + + if (maxDurationReached) { + emitEvent(RecorderEvent.OnMaxDurationReached) + } else { + emitEvent(RecorderEvent.OnStopRecording) } + } catch (e: IOException) { + Timber.e(e, "Error writing WAV header") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + } finally { + closeOutput(outputStream) } // Clean up state only after header write so nothing above reads stale nulls. @@ -261,6 +259,43 @@ class WavRecorderV2 @Inject constructor( return true } + /** + * Opens the output as a [FileOutputStream] whose channel supports seeking, so the WAV + * header can be rewritten in-place at the end of the session. For a SAF document the + * backing [ParcelFileDescriptor] is kept in [outputPfd] until [closeOutput]. + */ + @Throws(IOException::class) + private fun openOutputStream(output: RecordingOutput): FileOutputStream { + return when (output) { + is RecordingOutput.OutputFile -> FileOutputStream(output.file) + is RecordingOutput.OutputDocument -> { + val pfd = applicationContext.contentResolver + .openFileDescriptor(output.uri, "rw") + ?: throw IOException("Cannot open output document: ${output.uri}") + outputPfd = pfd + FileOutputStream(pfd.fileDescriptor) + } + } + } + + private fun closeOutput(outputStream: FileOutputStream) { + try { + outputStream.close() + } catch (e: IOException) { + Timber.e(e, "Error closing output stream") + } + closeOutputPfd() + } + + private fun closeOutputPfd() { + try { + outputPfd?.close() + } catch (e: IOException) { + Timber.e(e, "Failed to close output document descriptor") + } + outputPfd = null + } + override fun resumeRecording(): Boolean { if (!_isRecording || !_isPaused) return false _isPaused = false diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt index e9873e732..8e64b74f9 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt @@ -18,6 +18,7 @@ package com.dimowner.audiorecorder.v2.data import android.content.Context import com.dimowner.audiorecorder.exception.CantCreateFileException +import com.dimowner.audiorecorder.v2.data.model.RecordTarget import java.io.File interface FileDataSource { @@ -27,6 +28,15 @@ interface FileDataSource { @Throws(CantCreateFileException::class) fun createRecordFile(fileName: String): File + /** + * Creates the destination for a new recording honoring the user-selected public directory + * setting: a SAF document in the picked directory when set (falling back to the private + * directory when the picked directory is no longer accessible), a private file otherwise. + */ + @Throws(CantCreateFileException::class) + fun createRecordTarget(fileName: String): RecordTarget + + /** Deletes a record file addressed by an absolute path or a content:// document Uri string. */ fun deleteRecordFile(path: String): Boolean @Deprecated("Not used anymore as redundant complexity logic") @@ -37,8 +47,21 @@ interface FileDataSource { fun renameFile(path: String, newName: String): File? + /** + * Renames a record file addressed by an absolute path or a content:// document Uri string, + * keeping the original extension. + * @return the new path/Uri string, or null on failure. + */ + fun renameRecordFile(pathOrUri: String, newName: String): String? + @Throws(IllegalArgumentException::class) fun getAvailableSpace(): Long + /** + * Available space in bytes at the storage hosting [pathOrUri] (file path or document Uri). + * Null falls back to the private records directory. + */ + fun getAvailableSpace(pathOrUri: String?): Long + fun requestSystemMoreMemory(context: Context, file: File, requiredSpace: Long) } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt index fc0c85ef8..966acd194 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt @@ -18,25 +18,35 @@ package com.dimowner.audiorecorder.v2.data import android.annotation.SuppressLint import android.content.Context +import android.net.Uri import com.dimowner.audiorecorder.AppConstants import com.dimowner.audiorecorder.exception.CantCreateFileException +import com.dimowner.audiorecorder.v2.data.extensions.createDocumentInTree import com.dimowner.audiorecorder.v2.data.extensions.createFile +import com.dimowner.audiorecorder.v2.data.extensions.deleteDocument import com.dimowner.audiorecorder.v2.data.extensions.deleteFileAndChildren +import com.dimowner.audiorecorder.v2.data.extensions.getAvailableSpaceForDocument import com.dimowner.audiorecorder.v2.data.extensions.getPrivateMusicStorageDir +import com.dimowner.audiorecorder.v2.data.extensions.hasPersistedTreePermission +import com.dimowner.audiorecorder.v2.data.extensions.isContentUri import com.dimowner.audiorecorder.v2.data.extensions.markFileAsDeleted +import com.dimowner.audiorecorder.v2.data.extensions.renameDocumentWithExtension import com.dimowner.audiorecorder.v2.data.extensions.renameFileWithExtension import com.dimowner.audiorecorder.v2.data.extensions.requestAllocateSpace import com.dimowner.audiorecorder.v2.data.extensions.unmarkFileAsDeleted +import com.dimowner.audiorecorder.v2.data.model.RecordTarget import dagger.hilt.android.qualifiers.ApplicationContext import timber.log.Timber import java.io.File import java.io.IOException import javax.inject.Inject import javax.inject.Singleton +import androidx.core.net.toUri @Singleton class FileDataSourceImpl @Inject internal constructor( - @ApplicationContext context: Context + @param:ApplicationContext private val context: Context, + private val prefs: PrefsV2, ): FileDataSource { private val recordDirectory: File? by lazy { @@ -62,8 +72,36 @@ class FileDataSourceImpl @Inject internal constructor( throw CantCreateFileException() } + override fun createRecordTarget(fileName: String): RecordTarget { + val publicDirUri = prefs.publicRecordingDirUri + if (publicDirUri != null) { + val treeUri = publicDirUri.toUri() + if (hasPersistedTreePermission(context, treeUri)) { + val document = createDocumentInTree(context, treeUri, fileName) + val documentName = document?.name + if (document != null && documentName != null) { + return RecordTarget.PublicDocument( + uri = document.uri, + name = documentName, + created = document.lastModified().takeIf { it > 0 } + ?: System.currentTimeMillis(), + ) + } + } + Timber.e( + "Public recording dir is not accessible," + + " falling back to private storage: $publicDirUri" + ) + } + return RecordTarget.LocalFile(createRecordFile(fileName)) + } + override fun deleteRecordFile(path: String): Boolean { - return deleteFileAndChildren(File(path)) + return if (path.isContentUri()) { + deleteDocument(context, path) + } else { + deleteFileAndChildren(File(path)) + } } override fun markAsRecordDeleted(path: String): String? { @@ -78,11 +116,27 @@ class FileDataSourceImpl @Inject internal constructor( return renameFileWithExtension(File(path), newName) } + override fun renameRecordFile(pathOrUri: String, newName: String): String? { + return if (pathOrUri.isContentUri()) { + renameDocumentWithExtension(context, pathOrUri, newName) + } else { + renameFileWithExtension(File(pathOrUri), newName)?.absolutePath + } + } + @SuppressLint("UsableSpace") override fun getAvailableSpace(): Long { return recordDirectory?.usableSpace ?: 0 } + override fun getAvailableSpace(pathOrUri: String?): Long { + return if (pathOrUri != null && pathOrUri.isContentUri()) { + getAvailableSpaceForDocument(context, pathOrUri) + } else { + getAvailableSpace() + } + } + override fun requestSystemMoreMemory(context: Context, file: File, requiredSpace: Long) { requestAllocateSpace(context, file, requiredSpace) } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt index ad5053556..a6722d8e0 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt @@ -74,6 +74,12 @@ interface PrefsV2 { var recordAuthorName: String + /** + * Persisted SAF tree Uri (as String) of the user-selected public directory where new + * recordings are stored. Null means the default app-private storage is used. + */ + var publicRecordingDirUri: String? + fun resetRecordingSettings() fun fullPreferenceReset() diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt index c606e8522..6f95a687a 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt @@ -293,6 +293,14 @@ class PrefsV2Impl @Inject internal constructor(@ApplicationContext context: Cont } } + override var publicRecordingDirUri: String? + get() = sharedPreferences.getString(PREF_KEY_PUBLIC_RECORDING_DIR_URI, null) + set(value) { + sharedPreferences.edit { + putString(PREF_KEY_PUBLIC_RECORDING_DIR_URI, value) + } + } + override fun resetRecordingSettings() { sharedPreferences.edit { putString( @@ -334,5 +342,6 @@ class PrefsV2Impl @Inject internal constructor(@ApplicationContext context: Cont private const val PREF_KEY_RECORD_AUTHOR_NAME = "pref_key_record_author_name" private const val PREF_KEY_SAVE_DESCRIPTION_TO_FILE = "pref_key_save_description_to_file" private const val PREF_KEY_IS_LOCAL_STORAGE_INFO_SHOWN = "pref_key_is_local_storage_info_shown" + private const val PREF_KEY_PUBLIC_RECORDING_DIR_URI = "pref_key_public_recording_dir_uri" } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt index 263249317..705112814 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt @@ -16,6 +16,7 @@ package com.dimowner.audiorecorder.v2.data +import android.content.Context import androidx.sqlite.db.SimpleSQLiteQuery import com.dimowner.audiorecorder.AppConstantsV2.RECORD_DESCRIPTION_MAX_LENGTH import com.dimowner.audiorecorder.audio.AudioDecoder @@ -23,6 +24,8 @@ import com.dimowner.audiorecorder.v2.app.records.models.RecordsFilter import com.dimowner.audiorecorder.v2.app.records.models.RecordsFilterOptions import com.dimowner.audiorecorder.v2.audio.BrokenRecordRestorer import com.dimowner.audiorecorder.v2.audio.writeCommentTag +import com.dimowner.audiorecorder.v2.data.extensions.getDocumentLength +import com.dimowner.audiorecorder.v2.data.extensions.isContentUri import com.dimowner.audiorecorder.v2.data.extensions.toRecordsSortColumnName import com.dimowner.audiorecorder.v2.data.extensions.toSqlSortOrder import com.dimowner.audiorecorder.v2.data.model.Record @@ -30,6 +33,7 @@ import com.dimowner.audiorecorder.v2.data.model.SortOrder import com.dimowner.audiorecorder.v2.data.model.convertToRecordingFormat import com.dimowner.audiorecorder.v2.data.room.RecordDao import com.dimowner.audiorecorder.v2.data.room.RecordEntity +import dagger.hilt.android.qualifiers.ApplicationContext import timber.log.Timber import java.io.File import javax.inject.Inject @@ -38,6 +42,7 @@ import javax.inject.Singleton @SuppressWarnings("TooGenericExceptionCaught") @Singleton class RecordsDataSourceImpl @Inject internal constructor( + @param:ApplicationContext private val context: Context, private val prefs: PrefsV2, private val recordDao: RecordDao, private val fileDataSource: FileDataSource, @@ -150,13 +155,13 @@ class RecordsDataSourceImpl @Inject internal constructor( override suspend fun renameRecord(record: Record, newName: String): Boolean { return try { - val renamed = try { - fileDataSource.renameFile(record.path, newName) + val renamedPathOrUri = try { + fileDataSource.renameRecordFile(record.path, newName) } catch (e: Exception) { Timber.e(e) null } - if (renamed == null) { + if (renamedPathOrUri == null) { // Step 1 failed — nothing to roll back. false } else { @@ -164,7 +169,7 @@ class RecordsDataSourceImpl @Inject internal constructor( val updated = recordDao.updateRecord( record.copy( name = newName, - path = renamed.absolutePath + path = renamedPathOrUri ).toRecordEntity() ) if (updated == 0) { @@ -175,7 +180,7 @@ class RecordsDataSourceImpl @Inject internal constructor( Timber.e(e) // Step 2 failed — roll back the file rename. try { - fileDataSource.renameFile(renamed.absolutePath, record.name) + fileDataSource.renameRecordFile(renamedPathOrUri, record.name) } catch (re: Exception) { Timber.e(re, "Failed to rollback file rename after DB update failure") } @@ -200,7 +205,11 @@ class RecordsDataSourceImpl @Inject internal constructor( val truncated = description.take(RECORD_DESCRIPTION_MAX_LENGTH) val updated = updateRecord(record.copy(description = truncated)) if (updated) { - if (writeToFile) { + if (record.path.isContentUri()) { + //Comment tags are not written for records in a public directory: + // the tag library requires direct file access. + Timber.d("Skip writing comment tag for SAF record: %s", record.path) + } else if (writeToFile) { File(record.path).writeCommentTag(truncated) } else { File(record.path).writeCommentTag("") @@ -327,8 +336,12 @@ class RecordsDataSourceImpl @Inject internal constructor( .filter { record -> // Only include records whose file exists on disk with non-zero size. // If the file doesn't exist or is empty, the record data is truly lost. - val file = File(record.path) - file.exists() && file.length() > 0 + if (record.path.isContentUri()) { + getDocumentLength(context, record.path) > 0 + } else { + val file = File(record.path) + file.exists() && file.length() > 0 + } } } catch (e: Exception) { Timber.e(e, "Failed to get broken records") @@ -339,6 +352,12 @@ class RecordsDataSourceImpl @Inject internal constructor( override suspend fun restoreBrokenRecord(recordId: Long): Boolean { return try { val record = recordDao.getRecordById(recordId)?.toRecord() ?: return false + if (record.path.isContentUri()) { + //Restoration rewrites the file structure in-place and requires direct + // file access, which is not available for SAF documents. + Timber.e("Cannot restore broken record stored in a public directory: ${record.path}") + return false + } val file = File(record.path) if (!file.exists() || file.length() == 0L) { Timber.e("Cannot restore broken record: file does not exist or is empty: ${record.path}") diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensions.kt index cc2d2e197..c78573e17 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensions.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensions.kt @@ -1,5 +1,6 @@ package com.dimowner.audiorecorder.v2.data.extensions +import android.content.Context import com.dimowner.audiorecorder.v2.data.model.Record import com.dimowner.audiorecorder.v2.data.model.SortOrder @@ -29,10 +30,10 @@ fun SortOrder.toRecordsSortColumnName(): String { } } -fun checkForLostRecords(records: List): List { - return records.filter { !isFileExists(it.path) } +fun checkForLostRecords(context: Context, records: List): List { + return records.filter { !recordFileExists(context, it.path) } } -fun Record.isLostRecord(): Boolean { - return !isFileExists(this.path) +fun Record.isLostRecord(context: Context): Boolean { + return !recordFileExists(context, this.path) } \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt index 16c9d48b2..3d804580c 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt @@ -236,3 +236,15 @@ fun copyFile(fileToCopy: FileDescriptor, newFile: File): Boolean { fun isFileExists(path: String): Boolean { return File(path).exists() } + +/** + * Checks record file existence for both app-private file paths and SAF document Uris + * (records stored in a user-selected public directory). + */ +fun recordFileExists(context: Context, path: String): Boolean { + return if (path.isContentUri()) { + documentFileExists(context, path) + } else { + File(path).exists() + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/SafExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/SafExtensions.kt new file mode 100644 index 000000000..7b3149ffb --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/SafExtensions.kt @@ -0,0 +1,169 @@ +/* + * 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.extensions + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import android.system.Os +import androidx.documentfile.provider.DocumentFile +import timber.log.Timber +import androidx.core.net.toUri + +private const val CONTENT_URI_PREFIX = "content://" + +/** + * SAF documents are created with a generic MIME type so DocumentsProvider implementations + * don't append their own extension to the display name (the real audio MIME type is derived + * from the file extension by the provider afterwards). + */ +private const val GENERIC_MIME_TYPE = "application/octet-stream" + +/** + * Returns true when this record path is a SAF document Uri (record stored in a user-selected + * public directory) rather than an absolute file path in the app-private storage. + */ +fun String.isContentUri(): Boolean = startsWith(CONTENT_URI_PREFIX) + +/** + * Returns true when the app still holds a persisted read+write permission for [treeUri], + * granted via Intent.ACTION_OPEN_DOCUMENT_TREE + takePersistableUriPermission. + */ +fun hasPersistedTreePermission(context: Context, treeUri: Uri): Boolean { + return context.contentResolver.persistedUriPermissions.any { + it.uri == treeUri && it.isReadPermission && it.isWritePermission + } +} + +/** + * Creates a new document with name [fileName] inside the SAF tree [treeUri]. + * The DocumentsProvider resolves display-name collisions itself (appends " (1)", " (2)"…). + * + * @return the created document, or null when the tree is not accessible/writable. + */ +fun createDocumentInTree(context: Context, treeUri: Uri, fileName: String): DocumentFile? { + return try { + val tree = DocumentFile.fromTreeUri(context, treeUri) + if (tree == null || !tree.isDirectory || !tree.canWrite()) { + Timber.e("SAF tree is not accessible or not writable: $treeUri") + null + } else { + tree.createFile(GENERIC_MIME_TYPE, fileName) + } + } catch (e: Exception) { + Timber.e(e, "Failed to create document $fileName in tree: $treeUri") + null + } +} + +fun documentFileExists(context: Context, uriString: String): Boolean { + return try { + DocumentFile.fromSingleUri(context, uriString.toUri())?.exists() == true + } catch (e: Exception) { + Timber.e(e, "Failed to check document existence: $uriString") + false + } +} + +fun getDocumentLength(context: Context, uriString: String): Long { + return try { + DocumentFile.fromSingleUri(context, uriString.toUri())?.length() ?: 0L + } catch (e: Exception) { + Timber.e(e, "Failed to read document length: $uriString") + 0L + } +} + +fun getDocumentName(context: Context, uriString: String): String? { + return try { + DocumentFile.fromSingleUri(context, uriString.toUri())?.name + } catch (e: Exception) { + Timber.e(e, "Failed to read document name: $uriString") + null + } +} + +fun getDocumentLastModified(context: Context, uriString: String): Long { + return try { + DocumentFile.fromSingleUri(context, uriString.toUri())?.lastModified() ?: 0L + } catch (e: Exception) { + Timber.e(e, "Failed to read document lastModified: $uriString") + 0L + } +} + +fun deleteDocument(context: Context, uriString: String): Boolean { + return try { + DocumentFile.fromSingleUri(context, uriString.toUri())?.delete() == true + } catch (e: Exception) { + Timber.e(e, "Failed to delete document: $uriString") + false + } +} + +/** + * Renames the document keeping its original extension, mirroring [renameFileWithExtension]. + * + * @return the Uri of the renamed document as String, or null on failure. + */ +fun renameDocumentWithExtension(context: Context, uriString: String, newName: String): String? { + return try { + val uri = uriString.toUri() + val currentName = getDocumentName(context, uriString) ?: return null + if (currentName.substringBeforeLast('.') == newName) { + return null + } + val extension = currentName.substringAfterLast('.', "") + val newFileName = if (extension.isEmpty()) newName else "$newName.$extension" + DocumentsContract.renameDocument(context.contentResolver, uri, newFileName)?.toString() + } catch (e: Exception) { + Timber.e(e, "Failed to rename document: $uriString to $newName") + null + } +} + +/** + * Returns the available space in bytes on the filesystem hosting the given document, + * queried via fstatvfs on an opened file descriptor. Returns 0 when the document + * cannot be opened (missing permission, deleted folder, …). + */ +fun getAvailableSpaceForDocument(context: Context, uriString: String): Long { + return try { + context.contentResolver.openFileDescriptor(uriString.toUri(), "r")?.use { pfd -> + val stat = Os.fstatvfs(pfd.fileDescriptor) + stat.f_bavail * stat.f_bsize + } ?: 0L + } catch (e: Exception) { + Timber.e(e, "Failed to read available space for document: $uriString") + 0L + } +} + +/** + * Human-readable name of a SAF tree directory for display in Settings, + * e.g. "Recordings" for content://…/tree/primary%3ARecordings. + */ +fun getTreeDisplayName(context: Context, treeUriString: String): String? { + return try { + val treeUri = treeUriString.toUri() + DocumentFile.fromTreeUri(context, treeUri)?.name + ?: DocumentsContract.getTreeDocumentId(treeUri).substringAfterLast(':') + .substringAfterLast('/').ifEmpty { null } + } catch (e: Exception) { + Timber.e(e, "Failed to get tree display name: $treeUriString") + null + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordTarget.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordTarget.kt new file mode 100644 index 000000000..412c0c9f8 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordTarget.kt @@ -0,0 +1,54 @@ +/* + * 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 android.net.Uri +import java.io.File + +/** + * Destination of a new recording: either a file in the app-private records directory (default) + * or a SAF document inside the user-selected public directory (no storage permission required). + * + * [pathOrUri] is what gets persisted in [Record.path] — an absolute file path or a + * content:// document Uri string. + */ +sealed class RecordTarget { + + abstract val pathOrUri: String + + /** File name including extension. May differ from the requested one on name collision. */ + abstract val name: String + + /** Creation timestamp in milliseconds. */ + abstract val created: Long + + val nameWithoutExtension: String + get() = name.substringBeforeLast('.') + + data class LocalFile(val file: File) : RecordTarget() { + override val pathOrUri: String get() = file.absolutePath + override val name: String get() = file.name + override val created: Long get() = file.lastModified() + } + + data class PublicDocument( + val uri: Uri, + override val name: String, + override val created: Long, + ) : RecordTarget() { + override val pathOrUri: String get() = uri.toString() + } +} 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 ab5d17cf4..77954e67d 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 @@ -1,5 +1,6 @@ package com.dimowner.audiorecorder.v2.di +import android.content.Context import com.dimowner.audiorecorder.audio.player.AudioPlayerNew import com.dimowner.audiorecorder.audio.player.PlayerContractNew import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher @@ -7,6 +8,7 @@ import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -32,8 +34,8 @@ class AppModule { @Singleton @Provides - fun providePlayerContractNew(): PlayerContractNew.Player { - return AudioPlayerNew() + fun providePlayerContractNew(@ApplicationContext context: Context): PlayerContractNew.Player { + return AudioPlayerNew(context) } /** diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ad3a4f6d..1979759b1 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,6 +73,13 @@ Author name Sets the artist/author tag embedded in the metadata of each new recording file. This name will appear in music players and file managers. + + + Storage location + App storage (private) + Use app storage + Selected folder is unavailable. Recording is saved to app storage. + The recording is already stored in a public folder Total recorded duration: %s Total records count: %d Available space: %s diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImplTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImplTest.kt index aea65363b..4f6978fee 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImplTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImplTest.kt @@ -85,11 +85,14 @@ class RecordsDataSourceImplTest { amps = intArrayOf(1, 2, 3, 4) ) + private val context: android.content.Context = mockk(relaxed = true) + @Before fun setUp() { MockKAnnotations.init(this) recordsDataSourceImpl = RecordsDataSourceImpl( + context, prefs, recordDao, fileDataSource, @@ -315,16 +318,14 @@ class RecordsDataSourceImplTest { val record = testRecordEntity.toRecord() val newName = "record_new_name" val renamedPath = "path/record_new_name" - val renamedFile = mockk() - every { fileDataSource.renameFile(record.path, newName) } returns renamedFile - every { renamedFile.absolutePath } returns renamedPath + every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedPath every { recordDao.updateRecord(any()) } returns 1 val result = recordsDataSourceImpl.renameRecord(record, newName) assertTrue(result) - verify(exactly = 1) { fileDataSource.renameFile(record.path, newName) } + verify(exactly = 1) { fileDataSource.renameRecordFile(record.path, newName) } verify(exactly = 1) { recordDao.updateRecord( record.copy(name = newName, path = renamedPath).toRecordEntity() @@ -337,7 +338,7 @@ class RecordsDataSourceImplTest { val record = testRecordEntity.toRecord() val newName = "record_new_name" - every { fileDataSource.renameFile(record.path, newName) } throws Exception("Failed to rename") + every { fileDataSource.renameRecordFile(record.path, newName) } throws Exception("Failed to rename") val result = recordsDataSourceImpl.renameRecord(record, newName) @@ -350,21 +351,17 @@ class RecordsDataSourceImplTest { val record = testRecordEntity.toRecord() val newName = "record_new_name" val renamedPath = "path/record_new_name" - val renamedFile = mockk() - val rolledBackFile = mockk() - every { fileDataSource.renameFile(record.path, newName) } returns renamedFile - every { renamedFile.absolutePath } returns renamedPath - every { rolledBackFile.absolutePath } returns record.path + every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedPath every { recordDao.updateRecord(any()) } returns 0 - every { fileDataSource.renameFile(renamedPath, record.name) } returns rolledBackFile + every { fileDataSource.renameRecordFile(renamedPath, record.name) } returns record.path val result = recordsDataSourceImpl.renameRecord(record, newName) assertFalse(result) - verify(exactly = 1) { fileDataSource.renameFile(record.path, newName) } + verify(exactly = 1) { fileDataSource.renameRecordFile(record.path, newName) } verify(exactly = 1) { recordDao.updateRecord(record.copy(name = newName, path = renamedPath).toRecordEntity()) } - verify(exactly = 1) { fileDataSource.renameFile(renamedPath, record.name) } + verify(exactly = 1) { fileDataSource.renameRecordFile(renamedPath, record.name) } } @Test @@ -372,21 +369,17 @@ class RecordsDataSourceImplTest { val record = testRecordEntity.toRecord() val newName = "record_new_name" val renamedPath = "path/record_new_name" - val renamedFile = mockk() - val rolledBackFile = mockk() - every { fileDataSource.renameFile(record.path, newName) } returns renamedFile - every { renamedFile.absolutePath } returns renamedPath - every { rolledBackFile.absolutePath } returns record.path + every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedPath every { recordDao.updateRecord(any()) } throws Exception("Failed to update record") - every { fileDataSource.renameFile(renamedPath, record.name) } returns rolledBackFile + every { fileDataSource.renameRecordFile(renamedPath, record.name) } returns record.path val result = recordsDataSourceImpl.renameRecord(record, newName) assertFalse(result) - verify(exactly = 1) { fileDataSource.renameFile(record.path, newName) } + verify(exactly = 1) { fileDataSource.renameRecordFile(record.path, newName) } verify(exactly = 1) { recordDao.updateRecord(record.copy(name = newName, path = renamedPath).toRecordEntity()) } - verify(exactly = 1) { fileDataSource.renameFile(renamedPath, record.name) } + verify(exactly = 1) { fileDataSource.renameRecordFile(renamedPath, record.name) } } @Test @@ -394,18 +387,16 @@ class RecordsDataSourceImplTest { val record = testRecordEntity.toRecord() val newName = "record_new_name" val renamedPath = "path/record_new_name" - val renamedFile = mockk() - every { fileDataSource.renameFile(record.path, newName) } returns renamedFile - every { renamedFile.absolutePath } returns renamedPath + every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedPath every { recordDao.updateRecord(any()) } throws Exception("Failed to update record") - every { fileDataSource.renameFile(renamedPath, record.name) } throws Exception("Failed to rollback") + every { fileDataSource.renameRecordFile(renamedPath, record.name) } throws Exception("Failed to rollback") val result = recordsDataSourceImpl.renameRecord(record, newName) assertFalse(result) - verify(exactly = 1) { fileDataSource.renameFile(record.path, newName) } - verify(exactly = 1) { fileDataSource.renameFile(renamedPath, record.name) } + verify(exactly = 1) { fileDataSource.renameRecordFile(record.path, newName) } + verify(exactly = 1) { fileDataSource.renameRecordFile(renamedPath, record.name) } } @Test diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensionsTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensionsTest.kt index b4ff3cbe1..e8c7638e0 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensionsTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensionsTest.kt @@ -1,8 +1,10 @@ package com.dimowner.audiorecorder.v2.data.extensions +import android.content.Context import com.dimowner.audiorecorder.v2.data.model.Record import com.dimowner.audiorecorder.v2.data.model.SortOrder import com.dimowner.audiorecorder.v2.data.room.RecordEntity +import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -15,6 +17,9 @@ class DataExtensionsTest { @get:Rule val tempFolder = TemporaryFolder() + //Context is only touched for content:// paths; plain file paths never use it. + private val context: Context = mockk() + private fun createTestRecord(path: String, id: Long = 1L): Record { return Record( id = id, @@ -67,19 +72,19 @@ class DataExtensionsTest { @Test fun test_isLostRecord_nonExistentPath() { val record = createTestRecord("/nonexistent/path/record.m4a") - assertTrue(record.isLostRecord()) + assertTrue(record.isLostRecord(context)) } @Test fun test_isLostRecord_existingFile() { val file = tempFolder.newFile("existing_record.m4a") val record = createTestRecord(file.absolutePath) - assertFalse(record.isLostRecord()) + assertFalse(record.isLostRecord(context)) } @Test fun test_checkForLostRecords_emptyList() { - val result = checkForLostRecords(emptyList()) + val result = checkForLostRecords(context, emptyList()) assertTrue(result.isEmpty()) } @@ -92,7 +97,7 @@ class DataExtensionsTest { createTestRecord(file2.absolutePath, id = 2L) ) - val result = checkForLostRecords(records) + val result = checkForLostRecords(context, records) assertTrue(result.isEmpty()) } @@ -103,7 +108,7 @@ class DataExtensionsTest { createTestRecord("/nonexistent/path/record2.m4a", id = 2L) ) - val result = checkForLostRecords(records) + val result = checkForLostRecords(context, records) assertEquals(2, result.size) } @@ -116,7 +121,7 @@ class DataExtensionsTest { createTestRecord("/nonexistent/path/lost2.m4a", id = 3L) ) - val result = checkForLostRecords(records) + val result = checkForLostRecords(context, records) assertEquals(2, result.size) assertEquals(2L, result[0].id) assertEquals(3L, result[1].id) From 21067d45c36cc54857587c19cce73210d452ed60 Mon Sep 17 00:00:00 2001 From: Dmytro Ponomarenko Date: Tue, 14 Jul 2026 23:42:50 +0300 Subject: [PATCH 2/6] Add support for user-selected public recording directory. --- .../dimowner/audiorecorder/v2/audio/WavRecorderV2.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 e22e8bb9a..a7d02deb6 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 @@ -141,7 +141,7 @@ class WavRecorderV2 @Inject constructor( // descriptor) and write a placeholder 44-byte WAV header; it is overwritten in-place // with real values after recording via the stream's seekable channel. val outputStream = try { - openOutputStream(output).also { it.write(ByteArray(44)) } + openOutputStream(output) } catch (e: Exception) { Timber.e(e, "Failed to open WAV output: ${output.describe()}") recorder.release() @@ -150,6 +150,16 @@ class WavRecorderV2 @Inject constructor( emitEvent(RecorderEvent.OnError(RecorderInitException())) return false } + try { + outputStream.write(ByteArray(44)) + } catch (e: IOException) { + Timber.e(e, "Failed to write placeholder WAV header") + recorder.release() + audioRecord = null + closeOutput(outputStream) + emitEvent(RecorderEvent.OnError(RecorderInitException())) + return false + } try { recorder.startRecording() From cc628058c33b1c6b80ea93368583b0200845eff4 Mon Sep 17 00:00:00 2001 From: Dmytro Ponomarenko Date: Wed, 15 Jul 2026 19:57:05 +0300 Subject: [PATCH 3/6] Copy Import audio files into public directory, when it selected in the settings. --- .../v2/app/home/HomeViewModel.kt | 64 +++++++++++++------ .../v2/data/extensions/FileExtensions.kt | 15 +++++ app/src/main/res/values/strings.xml | 1 + 3 files changed, 60 insertions(+), 20 deletions(-) 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 aea183977..daabc7c17 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 @@ -75,6 +75,7 @@ import com.dimowner.audiorecorder.v2.data.extensions.getDocumentLength import com.dimowner.audiorecorder.v2.data.extensions.isContentUri import com.dimowner.audiorecorder.v2.data.model.AudioSource import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.RecordTarget import com.dimowner.audiorecorder.v2.analytics.AnalyticsTracker import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher @@ -724,8 +725,8 @@ class HomeViewModel @Inject constructor( showLoadingProgress(true) _state.value = _state.value.copy(isShowImportProgress = true) viewModelScope.launch(ioDispatcher) { - // Tracks the destination file so a partial copy can be cleaned up on failure. - var newFile: File? = null + // Tracks the destination so a partial copy can be cleaned up on failure. + var target: RecordTarget? = null try { val parcelFileDescriptor: ParcelFileDescriptor? = context.contentResolver.openFileDescriptor(uri, "r") @@ -733,23 +734,46 @@ class HomeViewModel @Inject constructor( val sourceDocument = DocumentFile.fromSingleUri(context, uri) val name: String? = sourceDocument?.name if (name != null) { - // Fail fast if the file clearly won't fit, before creating anything on disk. - requireFreeSpaceForImport(sourceDocument.length()) - newFile = fileDataSource.createRecordFile(name) - if (fileDescriptor != null && copyFile(fileDescriptor, newFile)) { - val info = AudioDecoder.readRecordInfo(newFile) - val importedDescription = newFile.readDescription() + // The destination is created before the space check because the available + // space at a user-selected public directory can only be queried through an + // existing document's file descriptor (mirrors handleStartRecording()). + val newTarget = fileDataSource.createRecordTarget(name) + target = newTarget + if (prefs.publicRecordingDirUri != null && newTarget is RecordTarget.LocalFile) { + //The user-selected public directory was not accessible; + // the import fell back to app-private storage. + emitEvent(HomeScreenEvent.ShowErrorSnack( + context.getString(R.string.error_public_dir_unavailable_import) + )) + } + requireFreeSpaceForImport(sourceDocument.length(), newTarget.pathOrUri) + val copied = fileDescriptor != null && when (newTarget) { + is RecordTarget.LocalFile -> copyFile(fileDescriptor, newTarget.file) + is RecordTarget.PublicDocument -> copyFile(context, fileDescriptor, newTarget.uri) + } + if (copied) { + val info = when (newTarget) { + is RecordTarget.LocalFile -> AudioDecoder.readRecordInfo(newTarget.file) + is RecordTarget.PublicDocument -> AudioDecoder.readRecordInfo(context, newTarget.uri) + } + val importedDescription = if (newTarget is RecordTarget.LocalFile) { + newTarget.file.readDescription() + } else { + //Metadata tags are not read for records imported into a public + // directory: the tag library requires direct file access. + "" + } //Do 2 step import: 1) Import record with empty waveform. //2) Process and update waveform in background. val record = Record( id = 0, - name = newFile.nameWithoutExtension, + name = newTarget.nameWithoutExtension, durationMills = if (info.duration >= 0) info.duration / 1000 else 0, - created = newFile.lastModified(), + created = newTarget.created, added = System.currentTimeMillis(), removed = Long.MAX_VALUE, - path = newFile.absolutePath, + path = newTarget.pathOrUri, format = info.format, size = info.size, sampleRate = info.sampleRate, @@ -772,7 +796,7 @@ class HomeViewModel @Inject constructor( } else { // Copy produced no data; surface an error instead of leaving the // progress indicator spinning forever. - newFile.delete() + fileDataSource.deleteRecordFile(newTarget.pathOrUri) withContext(mainDispatcher) { _state.value = _state.value.copy(isShowImportProgress = false) } @@ -786,21 +810,21 @@ class HomeViewModel @Inject constructor( } } catch (e: SecurityException) { Timber.e(e) - newFile?.delete() + target?.let { fileDataSource.deleteRecordFile(it.pathOrUri) } withContext(mainDispatcher) { _state.value = _state.value.copy(isShowImportProgress = false) } handleError(context.getString(R.string.error_permission_denied)) } catch (e: NotEnoughSpaceException) { Timber.w(e, "importAudioFile: not enough storage space") - newFile?.delete() + target?.let { fileDataSource.deleteRecordFile(it.pathOrUri) } withContext(mainDispatcher) { _state.value = _state.value.copy(isShowImportProgress = false) } handleError(context.getString(R.string.msg_not_enough_storage_space)) } catch (e: IOException) { Timber.e(e) - newFile?.delete() + target?.let { fileDataSource.deleteRecordFile(it.pathOrUri) } withContext(mainDispatcher) { _state.value = _state.value.copy(isShowImportProgress = false) } @@ -834,14 +858,14 @@ class HomeViewModel @Inject constructor( } /** - * Throws [NotEnoughSpaceException] when the device clearly can't hold a [sourceSizeBytes] copy, - * so the import fails fast before creating a partial file. Skipped when either the source size - * or the free space is unknown (non-positive), leaving the copy step to surface a real ENOSPC. + * Throws [NotEnoughSpaceException] when the device clearly can't hold a [sourceSizeBytes] copy + * at [destinationPathOrUri]. Skipped when either the source size or the free space is unknown + * (non-positive), leaving the copy step to surface a real ENOSPC. */ - private fun requireFreeSpaceForImport(sourceSizeBytes: Long) { + private fun requireFreeSpaceForImport(sourceSizeBytes: Long, destinationPathOrUri: String) { if (sourceSizeBytes <= 0) return val available = try { - fileDataSource.getAvailableSpace() + fileDataSource.getAvailableSpace(destinationPathOrUri) } catch (e: Exception) { Timber.w(e, "importAudioFile: could not read available space") return diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt index 3d804580c..052c73a9a 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt @@ -18,6 +18,7 @@ package com.dimowner.audiorecorder.v2.data.extensions import android.annotation.SuppressLint import android.content.Context +import android.net.Uri import android.os.Build import android.os.Environment import android.os.ParcelFileDescriptor @@ -233,6 +234,20 @@ fun copyFile(fileToCopy: FileDescriptor, newFile: File): Boolean { } } +/** + * Copies [fileToCopy] into the SAF document [uri], mirroring [copyFile] for records stored + * in a user-selected public directory. + */ +@Throws(IOException::class) +fun copyFile(context: Context, fileToCopy: FileDescriptor, uri: Uri): Boolean { + return FileInputStream(fileToCopy).use { inputStream -> + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + val bytesCopied = inputStream.copyTo(outputStream) + bytesCopied > 0 + } ?: false + } +} + fun isFileExists(path: String): Boolean { return File(path).exists() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1979759b1..926ee680a 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -79,6 +79,7 @@ App storage (private) Use app storage Selected folder is unavailable. Recording is saved to app storage. + Selected folder is unavailable. File is imported to app storage. The recording is already stored in a public folder Total recorded duration: %s Total records count: %d From 2134b261769a228ae9dbe45d1d387453c04d4152 Mon Sep 17 00:00:00 2001 From: Dmytro Ponomarenko Date: Wed, 15 Jul 2026 22:42:39 +0300 Subject: [PATCH 4/6] Fix name collisions during create new record file, rename or import into a public storage. --- .../v2/data/extensions/FileExtensionsTest.kt | 21 ++++++ .../v2/app/home/HomeViewModel.kt | 13 ++-- .../v2/app/records/RecordsViewModel.kt | 13 ++-- .../audiorecorder/v2/data/FileDataSource.kt | 5 +- .../v2/data/FileDataSourceImpl.kt | 7 +- .../v2/data/RecordsDataSource.kt | 7 +- .../v2/data/RecordsDataSourceImpl.kt | 26 +++---- .../v2/data/extensions/FileExtensions.kt | 48 ++++++++++--- .../v2/data/extensions/SafExtensions.kt | 48 ++++++++++--- .../v2/data/model/RecordTarget.kt | 4 +- .../v2/data/model/RenamedRecordFile.kt | 29 ++++++++ .../v2/data/RecordsDataSourceImplTest.kt | 71 +++++++++++++------ 12 files changed, 222 insertions(+), 70 deletions(-) create mode 100644 app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenamedRecordFile.kt diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensionsTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensionsTest.kt index c3c92a8cf..84125e23e 100644 --- a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensionsTest.kt +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensionsTest.kt @@ -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 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 daabc7c17..29b73877b 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 @@ -922,16 +922,17 @@ class HomeViewModel @Inject constructor( private suspend fun performRenameActiveRecord(newName: String, activeRecord: Record) { val context: Context = getApplication().applicationContext if (activeRecord.path.isContentUri()) { - // A SAF document has no filesystem path to pre-check for collisions; - // the DocumentsProvider itself rejects a rename to an existing name. + // A SAF document has no filesystem path to pre-check for collisions; the + // DocumentsProvider resolves them itself, so the resulting name is reported back. if (activeRecord.name == newName) { showLoadingProgress(false) return } - if (recordsDataSource.renameRecord(activeRecord, newName)) { + val actualName = recordsDataSource.renameRecord(activeRecord, newName) + if (actualName != null) { emitEvent( HomeScreenEvent.ShowInfoSnack( - context.getString(R.string.msg_record_renamed, newName) + context.getString(R.string.msg_record_renamed, actualName) ) ) } else { @@ -963,11 +964,11 @@ class HomeViewModel @Inject constructor( showLoadingProgress(false) return } else { - recordsDataSource.renameRecord(activeRecord, newName) + val actualName = recordsDataSource.renameRecord(activeRecord, newName) ?: newName val context: Context = getApplication().applicationContext emitEvent( HomeScreenEvent.ShowInfoSnack( - context.getString(R.string.msg_record_renamed, newName) + context.getString(R.string.msg_record_renamed, actualName) ) ) } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt index 9c4d2a4ab..e67002257 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt @@ -454,7 +454,7 @@ internal class RecordsViewModel @Inject constructor( showRenameDialog = false, operationSelectedRecord = null ) - } else if (recordsDataSource.renameRecord(record, newName)) { + } else if (recordsDataSource.renameRecord(record, newName) != null) { val context: Context = getApplication().applicationContext emitEvent( RecordsScreenEvent.ShowInfoSnack( @@ -484,8 +484,8 @@ internal class RecordsViewModel @Inject constructor( /** * Renames a record stored in a user-selected public directory. A SAF document has no - * filesystem path to pre-check for collisions; the DocumentsProvider itself rejects - * a rename to an existing name. + * filesystem path to pre-check for collisions; the DocumentsProvider resolves them itself, + * so the record is shown under the name the file actually got. */ private suspend fun renameSafRecord(record: Record, newName: String) { if (record.name == newName) { @@ -496,17 +496,18 @@ internal class RecordsViewModel @Inject constructor( return } val context: Context = getApplication().applicationContext - if (recordsDataSource.renameRecord(record, newName)) { + val actualName = recordsDataSource.renameRecord(record, newName) + if (actualName != null) { emitEvent( RecordsScreenEvent.ShowInfoSnack( - context.getString(R.string.msg_record_renamed, newName) + context.getString(R.string.msg_record_renamed, actualName) ) ) _state.value = _state.value.copy( showRenameDialog = false, operationSelectedRecord = null, recordsMap = _state.value.recordsMap.mapRecordInMap(record.id) { oldRecord -> - oldRecord.copy(name = newName) + oldRecord.copy(name = actualName) } ) } else { diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt index 8e64b74f9..03148ad2b 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt @@ -19,6 +19,7 @@ package com.dimowner.audiorecorder.v2.data import android.content.Context import com.dimowner.audiorecorder.exception.CantCreateFileException import com.dimowner.audiorecorder.v2.data.model.RecordTarget +import com.dimowner.audiorecorder.v2.data.model.RenamedRecordFile import java.io.File interface FileDataSource { @@ -50,9 +51,9 @@ interface FileDataSource { /** * Renames a record file addressed by an absolute path or a content:// document Uri string, * keeping the original extension. - * @return the new path/Uri string, or null on failure. + * @return where the file now lives and the name it actually got, or null on failure. */ - fun renameRecordFile(pathOrUri: String, newName: String): String? + fun renameRecordFile(pathOrUri: String, newName: String): RenamedRecordFile? @Throws(IllegalArgumentException::class) fun getAvailableSpace(): Long diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt index 966acd194..75d6ed286 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt @@ -35,6 +35,7 @@ import com.dimowner.audiorecorder.v2.data.extensions.renameFileWithExtension import com.dimowner.audiorecorder.v2.data.extensions.requestAllocateSpace import com.dimowner.audiorecorder.v2.data.extensions.unmarkFileAsDeleted import com.dimowner.audiorecorder.v2.data.model.RecordTarget +import com.dimowner.audiorecorder.v2.data.model.RenamedRecordFile import dagger.hilt.android.qualifiers.ApplicationContext import timber.log.Timber import java.io.File @@ -116,11 +117,13 @@ class FileDataSourceImpl @Inject internal constructor( return renameFileWithExtension(File(path), newName) } - override fun renameRecordFile(pathOrUri: String, newName: String): String? { + override fun renameRecordFile(pathOrUri: String, newName: String): RenamedRecordFile? { return if (pathOrUri.isContentUri()) { renameDocumentWithExtension(context, pathOrUri, newName) } else { - renameFileWithExtension(File(pathOrUri), newName)?.absolutePath + renameFileWithExtension(File(pathOrUri), newName)?.let { + RenamedRecordFile(it.absolutePath, it.nameWithoutExtension) + } } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSource.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSource.kt index 042aa092c..5295bac46 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSource.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSource.kt @@ -53,7 +53,12 @@ interface RecordsDataSource { suspend fun updateRecords(records: List): Int - suspend fun renameRecord(record: Record, newName: String): Boolean + /** + * Renames the record file and the record itself. + * @return the name the record actually got, which differs from [newName] when the destination + * resolved a name collision ("Record (1)"), or null when the rename failed. + */ + suspend fun renameRecord(record: Record, newName: String): String? /** * Persists a record's description to the database and, when [writeToFile] is true, diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt index 705112814..7a5edf1a1 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt @@ -153,44 +153,46 @@ class RecordsDataSourceImpl @Inject internal constructor( return recordDao.updateRecords(records.map { it.toRecordEntity() }) } - override suspend fun renameRecord(record: Record, newName: String): Boolean { + override suspend fun renameRecord(record: Record, newName: String): String? { return try { - val renamedPathOrUri = try { + val renamedFile = try { fileDataSource.renameRecordFile(record.path, newName) } catch (e: Exception) { Timber.e(e) null } - if (renamedPathOrUri == null) { + if (renamedFile == null) { // Step 1 failed — nothing to roll back. - false + null } else { - val isUpdated = try { + // The name of the file on disk wins: it can differ from the requested one when + // the destination resolved a collision with an existing name. + val actualName = renamedFile.nameWithoutExtension + try { val updated = recordDao.updateRecord( record.copy( - name = newName, - path = renamedPathOrUri + name = actualName, + path = renamedFile.pathOrUri ).toRecordEntity() ) if (updated == 0) { throw Exception("No records updated") } - true + actualName } catch (e: Exception) { Timber.e(e) // Step 2 failed — roll back the file rename. try { - fileDataSource.renameRecordFile(renamedPathOrUri, record.name) + fileDataSource.renameRecordFile(renamedFile.pathOrUri, record.name) } catch (re: Exception) { Timber.e(re, "Failed to rollback file rename after DB update failure") } - false + null } - isUpdated } } catch (e: Exception) { Timber.e(e) - false + null } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt index 052c73a9a..b03d1e6d2 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt @@ -34,6 +34,44 @@ import java.io.IOException private const val RETRY_COUNT = 3 +/** Trailing dot segment that may be stripped as an extension: "m4a", "mp3", "3gp"… */ +private val FILE_EXTENSION_REGEX = Regex("[A-Za-z0-9]{1,5}") + +/** + * Makes [fileName] unique by adding a suffix (-1 or -2 or -3...) before its extension + * ("Record.m4a" -> "Record-1.m4a") until [isTaken] no longer matches. + * @param fileName Desired file name with extension. + * @param isTaken Tells whether a name is already used in the destination directory. + */ +fun uniqueFileName(fileName: String, isTaken: (String) -> Boolean): String { + if (!isTaken(fileName)) { + return fileName + } + val baseName = fileName.substringBeforeLast('.') + val extension = fileName.substringAfterLast('.', "") + var suffix = 1 + var uniqueName: String + do { + uniqueName = if (extension.isEmpty()) "$baseName-$suffix" else "$baseName-$suffix.$extension" + suffix++ + } while (isTaken(uniqueName)) + return uniqueName +} + +/** + * Record name for the file named [this]: the file name without its extension. The extension is + * stripped only when the trailing dot segment really looks like one, so a name a DocumentsProvider + * built while resolving a collision ("Record.m4a (1)") is kept whole and the record name still + * matches the file it points to. + */ +fun String.recordNameWithoutExtension(): String { + return if (substringAfterLast('.', "").matches(FILE_EXTENSION_REGEX)) { + substringBeforeLast('.') + } else { + this + } +} + /** * Create a file. * Also create parent directories if they are not exist. @@ -47,15 +85,7 @@ fun createFile(directory: File, fileName: String): File { directory.mkdirs() // Create the directory if it doesn't exist } - var newFileName = fileName - var suffix = 1 - - // Check if the file with the same name already exists - while (File(directory, newFileName).exists()) { - // Append a numeric suffix to the file name - newFileName = "${fileName.substringBeforeLast('.')}-$suffix.${fileName.substringAfterLast('.')}" - suffix++ - } + val newFileName = uniqueFileName(fileName) { File(directory, it).exists() } val file = File(directory, newFileName) try { diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/SafExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/SafExtensions.kt index 7b3149ffb..61435eb9e 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/SafExtensions.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/SafExtensions.kt @@ -19,18 +19,34 @@ import android.content.Context import android.net.Uri import android.provider.DocumentsContract import android.system.Os +import android.webkit.MimeTypeMap import androidx.documentfile.provider.DocumentFile +import com.dimowner.audiorecorder.v2.data.model.RenamedRecordFile import timber.log.Timber import androidx.core.net.toUri private const val CONTENT_URI_PREFIX = "content://" +/** Fallback for extensions the platform has no MIME type for. */ +private const val GENERIC_MIME_TYPE = "application/octet-stream" + /** - * SAF documents are created with a generic MIME type so DocumentsProvider implementations - * don't append their own extension to the display name (the real audio MIME type is derived - * from the file extension by the provider afterwards). + * MIME type to create a document named [fileName] with. + * + * It is derived from the extension with the same lookup a DocumentsProvider uses on its side, so + * the requested type always agrees with the display name. That agreement is what keeps the name + * intact: given a MIME type that contradicts the extension, the provider appends an extension of + * its own and treats the whole display name as the base name, suffixing a colliding one as + * "Record.m4a (1)" instead of "Record (1).m4a". + * + * An extension the platform doesn't know still agrees, as both sides fall back to the same + * generic type. */ -private const val GENERIC_MIME_TYPE = "application/octet-stream" +private fun mimeTypeForFileName(fileName: String): String { + val extension = fileName.substringAfterLast('.', "") + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) + ?: GENERIC_MIME_TYPE +} /** * Returns true when this record path is a SAF document Uri (record stored in a user-selected @@ -50,7 +66,10 @@ fun hasPersistedTreePermission(context: Context, treeUri: Uri): Boolean { /** * Creates a new document with name [fileName] inside the SAF tree [treeUri]. - * The DocumentsProvider resolves display-name collisions itself (appends " (1)", " (2)"…). + * + * The DocumentsProvider resolves a collision with an existing name itself, by creating + * "Record (1).m4a" rather than by failing, so the caller must name the record after the created + * document instead of after [fileName] (see RecordTarget.nameWithoutExtension). * * @return the created document, or null when the tree is not accessible/writable. */ @@ -61,7 +80,7 @@ fun createDocumentInTree(context: Context, treeUri: Uri, fileName: String): Docu Timber.e("SAF tree is not accessible or not writable: $treeUri") null } else { - tree.createFile(GENERIC_MIME_TYPE, fileName) + tree.createFile(mimeTypeForFileName(fileName), fileName) } } catch (e: Exception) { Timber.e(e, "Failed to create document $fileName in tree: $treeUri") @@ -117,9 +136,17 @@ fun deleteDocument(context: Context, uriString: String): Boolean { /** * Renames the document keeping its original extension, mirroring [renameFileWithExtension]. * - * @return the Uri of the renamed document as String, or null on failure. + * The DocumentsProvider resolves a collision with an existing name itself, by renaming to + * "Record (1).m4a" rather than by failing, so the resulting name is read back from the provider + * instead of being assumed to be [newName]. + * + * @return the renamed document, or null on failure. */ -fun renameDocumentWithExtension(context: Context, uriString: String, newName: String): String? { +fun renameDocumentWithExtension( + context: Context, + uriString: String, + newName: String, +): RenamedRecordFile? { return try { val uri = uriString.toUri() val currentName = getDocumentName(context, uriString) ?: return null @@ -128,7 +155,10 @@ fun renameDocumentWithExtension(context: Context, uriString: String, newName: St } val extension = currentName.substringAfterLast('.', "") val newFileName = if (extension.isEmpty()) newName else "$newName.$extension" - DocumentsContract.renameDocument(context.contentResolver, uri, newFileName)?.toString() + val renamedUri = DocumentsContract + .renameDocument(context.contentResolver, uri, newFileName)?.toString() ?: return null + val actualName = getDocumentName(context, renamedUri) ?: newFileName + RenamedRecordFile(renamedUri, actualName.recordNameWithoutExtension()) } catch (e: Exception) { Timber.e(e, "Failed to rename document: $uriString to $newName") null diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordTarget.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordTarget.kt index 412c0c9f8..594fd3840 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordTarget.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordTarget.kt @@ -16,6 +16,7 @@ package com.dimowner.audiorecorder.v2.data.model import android.net.Uri +import com.dimowner.audiorecorder.v2.data.extensions.recordNameWithoutExtension import java.io.File /** @@ -35,8 +36,9 @@ sealed class RecordTarget { /** Creation timestamp in milliseconds. */ abstract val created: Long + /** Name to store in [Record.name] so it matches the file this target points to. */ val nameWithoutExtension: String - get() = name.substringBeforeLast('.') + get() = name.recordNameWithoutExtension() data class LocalFile(val file: File) : RecordTarget() { override val pathOrUri: String get() = file.absolutePath diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenamedRecordFile.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenamedRecordFile.kt new file mode 100644 index 000000000..e11c47a85 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenamedRecordFile.kt @@ -0,0 +1,29 @@ +/* + * 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 + +/** + * Outcome of a successful record file rename. + * + * [nameWithoutExtension] is the name the file actually got, which differs from the requested one + * when the destination resolved a name collision (a DocumentsProvider renames to "Record (1).m4a" + * instead of failing). It is what [Record.name] must be set to so the record name keeps matching + * the file it points to. + */ +data class RenamedRecordFile( + val pathOrUri: String, + val nameWithoutExtension: String, +) diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImplTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImplTest.kt index 4f6978fee..60398e3d9 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImplTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImplTest.kt @@ -18,6 +18,7 @@ package com.dimowner.audiorecorder.v2.data import androidx.sqlite.db.SupportSQLiteQuery import com.dimowner.audiorecorder.v2.app.records.models.RecordsFilter import com.dimowner.audiorecorder.v2.audio.BrokenRecordRestorer +import com.dimowner.audiorecorder.v2.data.model.RenamedRecordFile import com.dimowner.audiorecorder.v2.data.model.SortOrder import com.dimowner.audiorecorder.v2.data.room.RecordDao import com.dimowner.audiorecorder.v2.data.room.RecordEntity @@ -317,18 +318,40 @@ class RecordsDataSourceImplTest { fun test_renameRecord_success() = runBlocking { val record = testRecordEntity.toRecord() val newName = "record_new_name" - val renamedPath = "path/record_new_name" + val renamedFile = RenamedRecordFile("path/record_new_name", newName) - every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedPath + every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedFile every { recordDao.updateRecord(any()) } returns 1 val result = recordsDataSourceImpl.renameRecord(record, newName) - assertTrue(result) + assertEquals(newName, result) verify(exactly = 1) { fileDataSource.renameRecordFile(record.path, newName) } verify(exactly = 1) { recordDao.updateRecord( - record.copy(name = newName, path = renamedPath).toRecordEntity() + record.copy(name = newName, path = renamedFile.pathOrUri).toRecordEntity() + ) + } + } + + @Test + fun test_renameRecord_name_collision_resolved_by_destination() = runBlocking { + val record = testRecordEntity.toRecord() + val newName = "record_new_name" + //The destination already held "record_new_name.m4a" and renamed the file to avoid it. + val actualName = "record_new_name (1)" + val renamedFile = RenamedRecordFile("path/record_new_name (1)", actualName) + + every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedFile + every { recordDao.updateRecord(any()) } returns 1 + + val result = recordsDataSourceImpl.renameRecord(record, newName) + + //The record is named after the file, not after the requested name. + assertEquals(actualName, result) + verify(exactly = 1) { + recordDao.updateRecord( + record.copy(name = actualName, path = renamedFile.pathOrUri).toRecordEntity() ) } } @@ -342,7 +365,7 @@ class RecordsDataSourceImplTest { val result = recordsDataSourceImpl.renameRecord(record, newName) - assertFalse(result) + assertNull(result) verify(exactly = 0) { recordDao.updateRecord(any()) } } @@ -350,53 +373,57 @@ class RecordsDataSourceImplTest { fun test_renameRecord_step_2_failed_update_returns_0_and_rollback_success() = runBlocking { val record = testRecordEntity.toRecord() val newName = "record_new_name" - val renamedPath = "path/record_new_name" + val renamedFile = RenamedRecordFile("path/record_new_name", newName) - every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedPath + every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedFile every { recordDao.updateRecord(any()) } returns 0 - every { fileDataSource.renameRecordFile(renamedPath, record.name) } returns record.path + every { + fileDataSource.renameRecordFile(renamedFile.pathOrUri, record.name) + } returns RenamedRecordFile(record.path, record.name) val result = recordsDataSourceImpl.renameRecord(record, newName) - assertFalse(result) + assertNull(result) verify(exactly = 1) { fileDataSource.renameRecordFile(record.path, newName) } - verify(exactly = 1) { recordDao.updateRecord(record.copy(name = newName, path = renamedPath).toRecordEntity()) } - verify(exactly = 1) { fileDataSource.renameRecordFile(renamedPath, record.name) } + verify(exactly = 1) { recordDao.updateRecord(record.copy(name = newName, path = renamedFile.pathOrUri).toRecordEntity()) } + verify(exactly = 1) { fileDataSource.renameRecordFile(renamedFile.pathOrUri, record.name) } } @Test fun test_renameRecord_step_2_failed_and_rollback_success() = runBlocking { val record = testRecordEntity.toRecord() val newName = "record_new_name" - val renamedPath = "path/record_new_name" + val renamedFile = RenamedRecordFile("path/record_new_name", newName) - every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedPath + every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedFile every { recordDao.updateRecord(any()) } throws Exception("Failed to update record") - every { fileDataSource.renameRecordFile(renamedPath, record.name) } returns record.path + every { + fileDataSource.renameRecordFile(renamedFile.pathOrUri, record.name) + } returns RenamedRecordFile(record.path, record.name) val result = recordsDataSourceImpl.renameRecord(record, newName) - assertFalse(result) + assertNull(result) verify(exactly = 1) { fileDataSource.renameRecordFile(record.path, newName) } - verify(exactly = 1) { recordDao.updateRecord(record.copy(name = newName, path = renamedPath).toRecordEntity()) } - verify(exactly = 1) { fileDataSource.renameRecordFile(renamedPath, record.name) } + verify(exactly = 1) { recordDao.updateRecord(record.copy(name = newName, path = renamedFile.pathOrUri).toRecordEntity()) } + verify(exactly = 1) { fileDataSource.renameRecordFile(renamedFile.pathOrUri, record.name) } } @Test fun test_renameRecord_step_2_failed_and_rollback_failed() = runBlocking { val record = testRecordEntity.toRecord() val newName = "record_new_name" - val renamedPath = "path/record_new_name" + val renamedFile = RenamedRecordFile("path/record_new_name", newName) - every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedPath + every { fileDataSource.renameRecordFile(record.path, newName) } returns renamedFile every { recordDao.updateRecord(any()) } throws Exception("Failed to update record") - every { fileDataSource.renameRecordFile(renamedPath, record.name) } throws Exception("Failed to rollback") + every { fileDataSource.renameRecordFile(renamedFile.pathOrUri, record.name) } throws Exception("Failed to rollback") val result = recordsDataSourceImpl.renameRecord(record, newName) - assertFalse(result) + assertNull(result) verify(exactly = 1) { fileDataSource.renameRecordFile(record.path, newName) } - verify(exactly = 1) { fileDataSource.renameRecordFile(renamedPath, record.name) } + verify(exactly = 1) { fileDataSource.renameRecordFile(renamedFile.pathOrUri, record.name) } } @Test From dfb969ca09a047671cdccf8360b52ab14968cbf7 Mon Sep 17 00:00:00 2001 From: Dmytro Ponomarenko Date: Wed, 15 Jul 2026 23:01:38 +0300 Subject: [PATCH 5/6] After switching to app's private storage or changing public recording directory the app retains access to previously selected public directory. --- .../v2/app/settings/SettingsViewModel.kt | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) 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 95e331a4c..e6ed08602 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 @@ -58,7 +58,6 @@ import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Locale import javax.inject.Inject -import androidx.core.net.toUri @HiltViewModel internal class SettingsViewModel @Inject constructor( @@ -397,8 +396,11 @@ internal class SettingsViewModel @Inject constructor( /** * Persists the SAF tree picked via Intent.ACTION_OPEN_DOCUMENT_TREE as the public directory - * for new recordings. Takes a persistable Uri permission (no storage permission required) - * and releases the permission of the previously selected directory. + * for new recordings. Takes a persistable Uri permission (no storage permission required). + * + * The permission of a previously selected directory is deliberately kept: records already + * stored there stay readable, and dropping it would make every one of them fail its existence + * check and be reported as lost. */ fun setPublicRecordingDir(uri: Uri) { viewModelScope.launch(ioDispatcher) { @@ -411,7 +413,6 @@ internal class SettingsViewModel @Inject constructor( Timber.e(e, "Failed to take persistable permission for: $uri") return@launch } - releasePublicRecordingDirPermission(except = uri) prefs.publicRecordingDirUri = uri.toString() val name = getTreeDisplayName(context, uri.toString()) ?: uri.toString() withContext(mainDispatcher) { @@ -420,10 +421,12 @@ internal class SettingsViewModel @Inject constructor( } } - /** Switches new recordings back to the default app-private storage. */ + /** + * Switches new recordings back to the default app-private storage. The persisted permission of + * the directory is kept, so records already stored there remain accessible. + */ fun resetPublicRecordingDir() { viewModelScope.launch(ioDispatcher) { - releasePublicRecordingDirPermission(except = null) prefs.publicRecordingDirUri = null withContext(mainDispatcher) { _state.value = _state.value.copy(publicRecordingDirName = null) @@ -431,20 +434,6 @@ internal class SettingsViewModel @Inject constructor( } } - private fun releasePublicRecordingDirPermission(except: Uri?) { - val previous = prefs.publicRecordingDirUri ?: return - val previousUri = previous.toUri() - if (previousUri == except) return - try { - context.contentResolver.releasePersistableUriPermission( - previousUri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - } catch (e: SecurityException) { - Timber.w(e, "Failed to release persistable permission for: $previousUri") - } - } - fun onAction(action: SettingsScreenAction) { when (action) { SettingsScreenAction.InitSettingsScreen -> initSettings() From 29dc41af223baead9abeb24b8e213179024f921d Mon Sep 17 00:00:00 2001 From: Dmytro Ponomarenko Date: Wed, 15 Jul 2026 23:14:01 +0300 Subject: [PATCH 6/6] Add recording location setting to welcome setup screen --- .../v2/app/settings/WelcomeSetupSettingsScreen.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt index 1431296b3..0825bd451 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt @@ -147,6 +147,11 @@ internal fun WelcomeSetupSettingsScreen( currentAuthorName = uiState.recordAuthorName, onAction = onAction, ) + RecordingLocationSettingRow( + publicRecordingDirName = uiState.publicRecordingDirName, + onAction = onAction, + enabled = uiState.isRecordingSettingEditable, + ) RecordSettingsPanel( recordingSettings = uiState.recordingSettings, enabled = uiState.isRecordingSettingEditable,