diff --git a/README.md b/README.md
index 568bfe7d4..905c01d19 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,30 @@
# FAQ
### When option to choose recording directory will be added?
-
There is no plans to add feature where user can change recording directory. Newer versions of Android added restrictions on ability to interact with device file system. There is no simple way how to implement the feature. So all records are stored in app's private dir. Anyway, all record files available for user to download from app's private dir to a public dir.
+There is no plans to add feature where user can change recording directory. Newer versions of Android added restrictions on ability to interact with device file system. There is no simple way how to implement the feature. So all records are stored in app's private dir. Anyway, all record files available for user to download from app's private dir to a public dir.
+
+## Android 10 Storage Changes (Local Fork)
+
+This fork enables public directory storage on Android 10 using the legacy storage API.
+
+**Why:** Android 10 introduced Scoped Storage, and the upstream app disabled the "Store in public directory" option on Android 10+. However, Android 10 still supports `requestLegacyExternalStorage`, allowing apps to opt out of Scoped Storage. This fork uses that mechanism to preserve recordings in a public location (`/sdcard/AudioRecorder/`) that survives app uninstall.
+
+**Changes made:**
+
+1. `AndroidManifest.xml`:
+ - Added `android:requestLegacyExternalStorage="true"` to opt out of Scoped Storage
+ - Extended storage permissions to API 29 (`maxSdkVersion="29"`)
+
+2. `SettingsActivity.java`:
+ - Removed Android Q block that hid the public directory toggle
+
+3. `PrefsImpl.java`:
+ - Removed Android Q block in `firstRunExecuted()` that disabled directory settings
+
+4. `MainActivity.java`:
+ - Removed Android Q block in `onStart()` that forced private storage on every launch
+
+**Limitations:** This only works on Android 10. Android 11+ enforces Scoped Storage and ignores `requestLegacyExternalStorage`. For Android 11+, the app would need to use MediaStore API or Storage Access Framework.
### License
diff --git a/app/build.gradle b/app/build.gradle
index 4ffde15f2..35b0ba968 100755
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -74,6 +74,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+
lintOptions {
abortOnError false
}
diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/app/main/MainActivityRecordingTest.java b/app/src/androidTest/java/com/dimowner/audiorecorder/app/main/MainActivityRecordingTest.java
new file mode 100644
index 000000000..1a3b6a57d
--- /dev/null
+++ b/app/src/androidTest/java/com/dimowner/audiorecorder/app/main/MainActivityRecordingTest.java
@@ -0,0 +1,91 @@
+package com.dimowner.audiorecorder.app.main;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static org.junit.Assert.assertEquals;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.GrantPermissionRule;
+import androidx.test.core.app.ActivityScenario;
+
+import com.dimowner.audiorecorder.ARApplication;
+import com.dimowner.audiorecorder.R;
+import com.dimowner.audiorecorder.app.RecordingService;
+import com.dimowner.audiorecorder.data.Prefs;
+
+import org.junit.Before;
+import org.junit.After;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@LargeTest
+public class MainActivityRecordingTest {
+
+ @Rule
+ public final GrantPermissionRule permissionRule =
+ GrantPermissionRule.grant(
+ Manifest.permission.RECORD_AUDIO,
+ Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ Manifest.permission.READ_EXTERNAL_STORAGE);
+
+ private ActivityScenario scenario;
+
+ @Before
+ public void setUp() {
+ Context context = ApplicationProvider.getApplicationContext();
+ Prefs prefs = ARApplication.getInjector().providePrefs(context);
+ prefs.firstRunExecuted();
+ prefs.setStoreDirPublic(false);
+ scenario = ActivityScenario.launch(MainActivity.class);
+ }
+
+ @After
+ public void tearDown() {
+ if (scenario != null) {
+ scenario.close();
+ }
+ Context context = ApplicationProvider.getApplicationContext();
+ context.stopService(new Intent(context, RecordingService.class));
+ }
+
+ @Test
+ public void recordButtonDoesNotAllocateNewFileWhenPausing() {
+ final long initialCounter = readRecordCounter();
+
+ onView(withId(R.id.btn_record)).perform(click());
+ waitForIdle();
+ assertEquals(initialCounter + 1, readRecordCounter());
+
+ onView(withId(R.id.btn_record)).perform(click()); // Pause
+ waitForIdle();
+ assertEquals("Second tap should not allocate a new record file",
+ initialCounter + 1, readRecordCounter());
+
+ onView(withId(R.id.btn_record_stop)).perform(click());
+ waitForIdle();
+
+ onView(withId(R.id.btn_record)).perform(click()); // Start a fresh session
+ waitForIdle();
+ assertEquals(initialCounter + 2, readRecordCounter());
+ }
+
+ private void waitForIdle() {
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ }
+
+ private long readRecordCounter() {
+ Context context = ApplicationProvider.getApplicationContext();
+ Prefs prefs = ARApplication.getInjector().providePrefs(context);
+ return prefs.getRecordCounter();
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7de796d5f..1addd1bac 100755
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,8 +2,8 @@
-
-
+
+
@@ -31,6 +31,7 @@
android:hardwareAccelerated="@bool/useHardwareAcceleration"
android:icon="@mipmap/audio_recorder_logo"
android:label="@string/app_name"
+ android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/audio_recorder_logo"
android:theme="@style/AppTheme">
-
+
diff --git a/app/src/main/java/com/dimowner/audiorecorder/Injector.java b/app/src/main/java/com/dimowner/audiorecorder/Injector.java
index 17e4c84e3..ce9199bea 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/Injector.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/Injector.java
@@ -201,7 +201,7 @@ public RecordsContract.UserActionsListener provideRecordsPresenter(Context conte
public SettingsContract.UserActionsListener provideSettingsPresenter(Context context) {
if (settingsPresenter == null) {
- settingsPresenter = new SettingsPresenter(provideLocalRepository(context), provideFileRepository(context),
+ settingsPresenter = new SettingsPresenter(context, provideLocalRepository(context), provideFileRepository(context),
provideRecordingTasksQueue(), provideLoadingTasksQueue(), providePrefs(context),
provideSettingsMapper(context), provideAppRecorder(context));
}
diff --git a/app/src/main/java/com/dimowner/audiorecorder/RecordingWidget.kt b/app/src/main/java/com/dimowner/audiorecorder/RecordingWidget.kt
index 0340264c7..a98e3c1c4 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/RecordingWidget.kt
+++ b/app/src/main/java/com/dimowner/audiorecorder/RecordingWidget.kt
@@ -1,5 +1,6 @@
package com.dimowner.audiorecorder
+import android.Manifest
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
@@ -8,8 +9,17 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+import android.content.pm.PackageManager
+import android.os.Build
import android.widget.RemoteViews
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import com.dimowner.audiorecorder.app.RecordingService
import com.dimowner.audiorecorder.app.TransparentRecordingActivity
+import com.dimowner.audiorecorder.data.RecordingTarget
+import com.dimowner.audiorecorder.exception.CantCreateFileException
+import com.dimowner.audiorecorder.exception.ErrorParser
+import timber.log.Timber
class RecordingWidget : AppWidgetProvider() {
override fun onUpdate(
@@ -52,6 +62,49 @@ private fun getRecordingPendingIntent(context: Context): PendingIntent {
class WidgetReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
+ val fileRepository = ARApplication.injector.provideFileRepository(context)
+
+ // Check permissions first
+ if (!hasRecordingPermissions(context)) {
+ launchTransparentActivity(context)
+ return
+ }
+
+ try {
+ val target = fileRepository.provideRecordingTarget(context)
+ startRecordingService(context, target)
+ } catch (e: CantCreateFileException) {
+ Timber.e(e, "Failed to create recording file from widget")
+ Toast.makeText(context, ErrorParser.parseException(e), Toast.LENGTH_LONG).show()
+ }
+ }
+
+ private fun hasRecordingPermissions(context: Context): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ return false
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ return false
+ }
+ }
+ return true
+ }
+
+ private fun startRecordingService(context: Context, target: RecordingTarget) {
+ val startIntent = Intent(context, RecordingService::class.java).apply {
+ action = RecordingService.ACTION_START_RECORDING_SERVICE
+ putExtra(RecordingService.EXTRAS_KEY_RECORD_PATH, target.path)
+ if (target.isSaf) {
+ putExtra(RecordingService.EXTRAS_KEY_SAF_URI, target.safUri.toString())
+ }
+ }
+ ContextCompat.startForegroundService(context, startIntent)
+ }
+
+ private fun launchTransparentActivity(context: Context) {
val activityIntent = Intent(context, TransparentRecordingActivity::class.java)
activityIntent.flags = FLAG_ACTIVITY_NEW_TASK
context.startActivity(activityIntent)
diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorder.java b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorder.java
index 579a58f19..9178bd8e0 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorder.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorder.java
@@ -16,8 +16,11 @@
package com.dimowner.audiorecorder.app;
+import android.content.Context;
+
import com.dimowner.audiorecorder.IntArrayList;
import com.dimowner.audiorecorder.audio.recorder.RecorderContract;
+import com.dimowner.audiorecorder.data.RecordingTarget;
import java.io.File;
@@ -27,6 +30,12 @@ public interface AppRecorder {
void removeRecordingCallback(AppRecorderCallback recorderCallback);
void setRecorder(RecorderContract.Recorder recorder);
void startRecording(String filePath, int channelCount, int sampleRate, int bitrate);
+
+ /**
+ * Start recording to a RecordingTarget (supports both File and SAF).
+ */
+ void startRecording(Context context, RecordingTarget target, int channelCount, int sampleRate, int bitrate);
+
void pauseRecording();
void resumeRecording();
void stopRecording();
@@ -35,5 +44,11 @@ public interface AppRecorder {
boolean isRecording();
boolean isPaused();
File getRecordFile();
+
+ /**
+ * Get the current recording target (may be SAF-based).
+ */
+ RecordingTarget getRecordingTarget();
+
void release();
}
diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java
index 3028898cd..1a3e2f094 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java
@@ -16,6 +16,9 @@
package com.dimowner.audiorecorder.app;
+import android.content.Context;
+import android.os.ParcelFileDescriptor;
+
import com.dimowner.audiorecorder.ARApplication;
import com.dimowner.audiorecorder.AppConstants;
import com.dimowner.audiorecorder.BackgroundQueue;
@@ -24,6 +27,7 @@
import com.dimowner.audiorecorder.audio.AudioDecoder;
import com.dimowner.audiorecorder.audio.recorder.RecorderContract;
import com.dimowner.audiorecorder.data.RecordDataSource;
+import com.dimowner.audiorecorder.data.RecordingTarget;
import com.dimowner.audiorecorder.data.database.LocalRepository;
import com.dimowner.audiorecorder.data.database.Record;
import com.dimowner.audiorecorder.exception.AppException;
@@ -31,6 +35,7 @@
import com.dimowner.audiorecorder.util.AndroidUtils;
import java.io.File;
+import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Timer;
@@ -54,6 +59,8 @@ public class AppRecorderImpl implements AppRecorder {
private long updateTime = 0;
private Timer timerProgress;
private String recordFilePath = null;
+ private RecordingTarget currentTarget = null;
+ private ParcelFileDescriptor currentPfd = null;
private volatile static AppRecorderImpl instance;
@@ -115,6 +122,7 @@ public void onRecordProgress(final long mills, final int amplitude) {
@Override
public void onStopRecord(final File output) {
stopRecordingTimer();
+ closePfd(); // Close SAF file descriptor if open
recordingsTasks.postRunnable(() -> {
RecordInfo info = AudioDecoder.readRecordInfo(output);
long duration = info.getDuration();
@@ -230,10 +238,33 @@ public void setRecorder(RecorderContract.Recorder recorder) {
public void startRecording(String filePath, int channelCount, int sampleRate, int bitrate) {
if (!audioRecorder.isRecording()) {
recordFilePath = filePath;
+ currentTarget = null;
audioRecorder.startRecording(filePath, channelCount, sampleRate, bitrate);
}
}
+ @Override
+ public void startRecording(Context context, RecordingTarget target, int channelCount, int sampleRate, int bitrate) {
+ if (!audioRecorder.isRecording()) {
+ currentTarget = target;
+ recordFilePath = target.getPath();
+
+ if (target.isSaf()) {
+ // SAF-based recording
+ try {
+ currentPfd = target.openForWriting(context);
+ audioRecorder.startRecording(currentPfd.getFileDescriptor(), target, channelCount, sampleRate, bitrate);
+ } catch (IOException e) {
+ Timber.e(e, "Failed to open SAF target for recording");
+ onRecordingError(new RecordingException());
+ }
+ } else {
+ // File-based recording
+ audioRecorder.startRecording(target.getPath(), channelCount, sampleRate, bitrate);
+ }
+ }
+ }
+
@Override
public void pauseRecording() {
if (audioRecorder.isRecording()) {
@@ -283,6 +314,11 @@ public File getRecordFile() {
return null;
}
+ @Override
+ public RecordingTarget getRecordingTarget() {
+ return currentTarget;
+ }
+
@Override
public void release() {
stopRecordingTimer();
@@ -290,6 +326,18 @@ public void release() {
apmpPool.clear();
audioRecorder.stopRecording();
appCallbacks.clear();
+ closePfd();
+ }
+
+ private void closePfd() {
+ if (currentPfd != null) {
+ try {
+ currentPfd.close();
+ } catch (IOException e) {
+ Timber.e(e, "Failed to close ParcelFileDescriptor");
+ }
+ currentPfd = null;
+ }
}
private void onRecordingStarted(File output) {
@@ -369,14 +417,18 @@ private void readProgress() {
}
private void stopRecordingTimer() {
- timerProgress.cancel();
- timerProgress.purge();
+ if (timerProgress != null) {
+ timerProgress.cancel();
+ timerProgress.purge();
+ }
updateTime = 0;
}
private void pauseRecordingTimer() {
- timerProgress.cancel();
- timerProgress.purge();
+ if (timerProgress != null) {
+ timerProgress.cancel();
+ timerProgress.purge();
+ }
updateTime = 0;
}
}
diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java b/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java
index da989bb13..c48cee401 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java
@@ -28,8 +28,10 @@
import android.content.pm.ServiceInfo;
import android.graphics.Color;
import android.media.RingtoneManager;
+import android.net.Uri;
import android.os.Build;
import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
@@ -49,6 +51,7 @@
import com.dimowner.audiorecorder.data.FileRepository;
import com.dimowner.audiorecorder.data.Prefs;
import com.dimowner.audiorecorder.data.RecordDataSource;
+import com.dimowner.audiorecorder.data.RecordingTarget;
import com.dimowner.audiorecorder.data.database.LocalRepository;
import com.dimowner.audiorecorder.data.database.Record;
import com.dimowner.audiorecorder.exception.AppException;
@@ -72,6 +75,7 @@ public class RecordingService extends Service {
private final static String CHANNEL_ID_ERRORS = "com.dimowner.audiorecorder.Errors";
public static final String EXTRAS_KEY_RECORD_PATH = "EXTRAS_KEY_RECORD_PATH";
+ public static final String EXTRAS_KEY_SAF_URI = "EXTRAS_KEY_SAF_URI";
public static final String ACTION_START_RECORDING_SERVICE = "ACTION_START_RECORDING_SERVICE";
public static final String ACTION_STOP_RECORDING_SERVICE = "ACTION_STOP_RECORDING_SERVICE";
@@ -96,6 +100,7 @@ public class RecordingService extends Service {
private ColorMap colorMap;
private boolean started = false;
private FileRepository fileRepository;
+ private RecordingTarget currentTarget = null;
public RecordingService() {
}
@@ -200,7 +205,9 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (!started) {
startForegroundService();
if (intent.hasExtra(EXTRAS_KEY_RECORD_PATH)) {
- startRecording(intent.getStringExtra(EXTRAS_KEY_RECORD_PATH));
+ String path = intent.getStringExtra(EXTRAS_KEY_RECORD_PATH);
+ String safUri = intent.getStringExtra(EXTRAS_KEY_SAF_URI);
+ startRecording(path, safUri);
} else {
showError(ErrorParser.parseException(new RecorderInitException()));
stopForegroundService();
@@ -414,8 +421,17 @@ private void updateNotification(long mills) {
}
}
- private void startRecording(String path) {
+ private void startRecording(String path, String safUriString) {
appRecorder.setRecorder(recorder);
+
+ // Create RecordingTarget based on whether SAF URI is provided
+ if (safUriString != null) {
+ Uri safUri = Uri.parse(safUriString);
+ currentTarget = new RecordingTarget(safUri, path);
+ } else {
+ currentTarget = new RecordingTarget(new java.io.File(path));
+ }
+
try {
if (fileRepository.hasAvailableSpace(getApplicationContext())) {
// if (appRecorder.isPaused()) {
@@ -425,13 +441,15 @@ private void startRecording(String path) {
if (audioPlayer.isPlaying() || audioPlayer.isPaused()) {
audioPlayer.stop();
}
+ final RecordingTarget target = currentTarget;
recordingsTasks.postRunnable(() -> {
try {
Record record = localRepository.insertEmptyFile(path);
prefs.setActiveRecord(record.getId());
recordDataSource.setRecordingRecord(record);
AndroidUtils.runOnUIThread(() -> appRecorder.startRecording(
- path,
+ getApplicationContext(),
+ target,
prefs.getSettingChannelCount(),
prefs.getSettingSampleRate(),
prefs.getSettingBitrate()
diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/TransparentRecordingActivity.kt b/app/src/main/java/com/dimowner/audiorecorder/app/TransparentRecordingActivity.kt
index ba5d4377d..04ce924c3 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/app/TransparentRecordingActivity.kt
+++ b/app/src/main/java/com/dimowner/audiorecorder/app/TransparentRecordingActivity.kt
@@ -54,10 +54,13 @@ class TransparentRecordingActivity : Activity() {
private fun startRecordingService() {
try {
+ val target = fileRepository.provideRecordingTarget(applicationContext)
val startIntent = Intent(applicationContext, RecordingService::class.java)
- val path = fileRepository.provideRecordFile().absolutePath
startIntent.action = RecordingService.ACTION_START_RECORDING_SERVICE
- startIntent.putExtra(RecordingService.EXTRAS_KEY_RECORD_PATH, path)
+ startIntent.putExtra(RecordingService.EXTRAS_KEY_RECORD_PATH, target.path)
+ if (target.isSaf) {
+ startIntent.putExtra(RecordingService.EXTRAS_KEY_SAF_URI, target.safUri.toString())
+ }
startService(startIntent)
} catch (e: CantCreateFileException) {
Toast.makeText(applicationContext, ErrorParser.parseException(e), Toast.LENGTH_LONG).show()
diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java
index a446ae8fe..3469f8f82 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java
@@ -269,8 +269,13 @@ private void updatePath(Context context) {
view.showSelectedPrivateDir();
}
} else {
- view.updatePath(fileRepository.getPublicDir().getAbsolutePath());
- view.showSelectedPublicDir();
+ File dir = fileRepository.getPublicDir();
+ if (dir != null) {
+ view.updatePath(dir.getAbsolutePath());
+ view.showSelectedPublicDir();
+ } else {
+ view.showError(R.string.error_unable_to_use_directory);
+ }
}
}
}
diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java
index 7aad701db..623cc4705 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java
@@ -60,6 +60,7 @@
import com.dimowner.audiorecorder.app.widget.WaveformViewNew;
import com.dimowner.audiorecorder.audio.AudioDecoder;
import com.dimowner.audiorecorder.data.FileRepository;
+import com.dimowner.audiorecorder.data.RecordingTarget;
import com.dimowner.audiorecorder.data.database.Record;
import com.dimowner.audiorecorder.exception.CantCreateFileException;
import com.dimowner.audiorecorder.exception.ErrorParser;
@@ -115,6 +116,14 @@ public class MainActivity extends Activity implements MainContract.View, View.On
private FileRepository fileRepository;
private ColorMap.OnThemeColorChangeListener onThemeColorChangeListener;
+ private enum RecordButtonState {
+ IDLE,
+ RECORDING,
+ PAUSED
+ }
+
+ private RecordButtonState recordButtonState = RecordButtonState.IDLE;
+
private final ServiceConnection connection = new ServiceConnection() {
@Override
@@ -271,11 +280,7 @@ public void onSeeking(int px, long mills) {
protected void onStart() {
super.onStart();
presenter.bindView(this);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- //This is needed for scoped storage support
- presenter.storeInPrivateDir(getApplicationContext());
-// presenter.checkPublicStorageRecords();
- }
+ // Removed Android Q block that forced private storage - we use requestLegacyExternalStorage instead
presenter.checkFirstRun();
presenter.setAudioRecorder(ARApplication.getInjector().provideAudioRecorder(getApplicationContext()));
presenter.updateRecordingDir(getApplicationContext());
@@ -394,6 +399,7 @@ public void showMessage(int resId) {
@Override
public void showRecordingStart() {
+ recordButtonState = RecordButtonState.RECORDING;
txtName.setClickable(false);
txtName.setFocusable(false);
txtName.setCompoundDrawables(null, null, null, null);
@@ -418,6 +424,7 @@ public void showRecordingStart() {
@Override
public void showRecordingStop() {
+ recordButtonState = RecordButtonState.IDLE;
txtName.setClickable(true);
txtName.setFocusable(true);
// txtName.setText("");
@@ -442,6 +449,7 @@ public void showRecordingStop() {
@Override
public void showRecordingPause() {
+ recordButtonState = RecordButtonState.PAUSED;
txtName.setClickable(false);
txtName.setFocusable(false);
txtName.setCompoundDrawables(null, null, null, null);
@@ -463,6 +471,7 @@ public void showRecordingPause() {
@Override
public void showRecordingResume() {
+ recordButtonState = RecordButtonState.RECORDING;
txtName.setClickable(false);
txtName.setFocusable(false);
txtName.setCompoundDrawables(null, null, null, null);
@@ -506,14 +515,32 @@ public void startWelcomeScreen() {
@Override
public void startRecordingService() {
+ if (recordButtonState != RecordButtonState.IDLE) {
+ return;
+ }
try {
- String path = fileRepository.provideRecordFile().getAbsolutePath();
+ // Use RecordingTarget for both file and SAF-based recording
+ RecordingTarget target = fileRepository.provideRecordingTarget(getApplicationContext());
+ String path = target.getPath();
+
Intent intent = new Intent(getApplicationContext(), RecordingService.class);
intent.setAction(RecordingService.ACTION_START_RECORDING_SERVICE);
intent.putExtra(RecordingService.EXTRAS_KEY_RECORD_PATH, path);
+
+ // Pass SAF URI if this is a SAF-based target
+ if (target.isSaf()) {
+ intent.putExtra(RecordingService.EXTRAS_KEY_SAF_URI, target.getSafUri().toString());
+ }
+
+ recordButtonState = RecordButtonState.RECORDING;
startService(intent);
} catch (CantCreateFileException e) {
+ recordButtonState = RecordButtonState.IDLE;
showError(ErrorParser.parseException(e));
+ } catch (RuntimeException e) {
+ recordButtonState = RecordButtonState.IDLE;
+ Timber.e(e);
+ showError(R.string.error_failed_to_start_recording);
}
}
@@ -669,7 +696,11 @@ public void downloadRecord(Record record) {
}
private boolean isPublicDir(String path) {
- return path.contains(FileUtil.getAppDir().getAbsolutePath());
+ if (path == null) {
+ return false;
+ }
+ File publicDir = fileRepository != null ? fileRepository.getPublicDir() : null;
+ return publicDir != null && path.contains(publicDir.getAbsolutePath());
}
@Override
diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainPresenter.java b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainPresenter.java
index 3f99df9a1..db8634257 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainPresenter.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainPresenter.java
@@ -391,6 +391,31 @@ public void startPlayback() {
}
}
+ private void startPlaybackWithSaf(Context context, Record record) {
+ if (audioPlayer.isPlaying()) {
+ audioPlayer.pause();
+ } else if (audioPlayer.isPaused()) {
+ audioPlayer.unpause();
+ } else {
+ // Try SAF playback
+ String safTreeUriString = prefs.getSafTreeUri();
+ if (safTreeUriString != null) {
+ Uri treeUri = Uri.parse(safTreeUriString);
+ Uri documentUri = FileUtil.reconstructSafDocumentUri(record.getPath(), treeUri);
+ if (documentUri != null) {
+ Timber.d("Playing via SAF URI: %s", documentUri);
+ audioPlayer.play(context, documentUri);
+ return;
+ }
+ }
+ // Fallback: show error if SAF reconstruction failed
+ Timber.w("Failed to reconstruct SAF URI for: %s", record.getPath());
+ if (view != null) {
+ view.showRecordFileNotAvailable(record.getPath());
+ }
+ }
+ }
+
@Override
public void onPlaybackClick(Context context, boolean isStorageAvailable) {
loadingTasks.postRunnable(() -> {
@@ -400,9 +425,8 @@ public void onPlaybackClick(Context context, boolean isStorageAvailable) {
//This method Starts or Pause playback.
if (FileUtil.isFileInExternalStorage(context, record.getPath())) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- if (view != null) {
- view.showRecordFileNotAvailable(record.getPath());
- }
+ // Try SAF playback for external storage on Android 10+
+ startPlaybackWithSaf(context, record);
} else if (isStorageAvailable) {
startPlayback();
}
diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java
index 973ea84d9..92a50de69 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java
@@ -53,12 +53,14 @@
import com.dimowner.audiorecorder.app.widget.SimpleWaveformView;
import com.dimowner.audiorecorder.app.widget.TouchLayout;
import com.dimowner.audiorecorder.app.widget.WaveformViewNew;
+import com.dimowner.audiorecorder.data.FileRepository;
import com.dimowner.audiorecorder.data.database.Record;
import com.dimowner.audiorecorder.util.AndroidUtils;
import com.dimowner.audiorecorder.util.AnimationUtil;
import com.dimowner.audiorecorder.util.FileUtil;
import com.dimowner.audiorecorder.util.TimeUtils;
+import java.io.File;
import java.util.ArrayList;
import java.util.List;
@@ -99,6 +101,7 @@ public class RecordsActivity extends Activity implements RecordsContract.View, V
private ImageButton btnDownloadMulti;
private RecordsContract.UserActionsListener presenter;
+ private FileRepository fileRepository;
private ColorMap colorMap;
final private List downloadRecords = new ArrayList<>();
@@ -314,6 +317,7 @@ public void onSelectDeselect(int selectedCount) {
recyclerView.setAdapter(adapter);
presenter = ARApplication.getInjector().provideRecordsPresenter(getApplicationContext());
+ fileRepository = ARApplication.getInjector().provideFileRepository(getApplicationContext());
waveformView.setOnSeekListener(new WaveformViewNew.OnSeekListener() {
@Override
@@ -413,8 +417,9 @@ private boolean startPlayback() {
String path = presenter.getActiveRecordPath();
if (FileUtil.isFileInExternalStorage(getApplicationContext(), path)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- AndroidUtils.showRecordFileNotAvailable(this, path);
- return false;
+ // Try SAF playback for external storage on Android 10+
+ presenter.startPlaybackWithSaf(getApplicationContext());
+ return true;
} else if (checkStoragePermissionPlayback()) {
presenter.startPlayback();
return true;
@@ -661,7 +666,11 @@ public boolean isListOnBottom() {
}
private boolean isPublicDir(String path) {
- return path.contains(FileUtil.getAppDir().getAbsolutePath());
+ if (path == null) {
+ return false;
+ }
+ File publicDir = fileRepository != null ? fileRepository.getPublicDir() : null;
+ return publicDir != null && path.contains(publicDir.getAbsolutePath());
}
@Override
diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsContract.java b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsContract.java
index fb83d3ac4..7f8934981 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsContract.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsContract.java
@@ -16,6 +16,8 @@
package com.dimowner.audiorecorder.app.records;
+import android.content.Context;
+
import com.dimowner.audiorecorder.Contract;
import com.dimowner.audiorecorder.app.info.RecordInfo;
import com.dimowner.audiorecorder.data.database.Record;
@@ -86,6 +88,8 @@ interface UserActionsListener extends Contract.UserActionsListener presenter.keepScreenOn(isChecked));
swAskToRename.setOnCheckedChangeListener((btn, isChecked) -> presenter.askToRenameAfterRecordingStop(isChecked));
@@ -239,6 +234,7 @@ protected void onCreate(Bundle savedInstanceState) {
}
initThemeColorSelector();
+ initStorageLocationSelector();
initNameFormatSelector();
}
@@ -274,6 +270,144 @@ private void initThemeColorSelector() {
});
}
+ private void initStorageLocationSelector() {
+ storageLocationSpinner = findViewById(R.id.spinnerStorageLocation);
+ List items = new ArrayList<>();
+
+ // Check if SD card is available
+ boolean sdCardAvailable = FileUtil.getSecondaryExternalPublicDir(getApplicationContext(), AppConstants.APPLICATION_NAME) != null;
+
+ // Add storage options
+ items.add(new AppSpinnerAdapter.ThemeItem(
+ getString(R.string.storage_internal),
+ getApplicationContext().getResources().getColor(colorMap.getPrimaryColorRes())));
+
+ if (sdCardAvailable) {
+ items.add(new AppSpinnerAdapter.ThemeItem(
+ getString(R.string.storage_sdcard),
+ getApplicationContext().getResources().getColor(colorMap.getPrimaryColorRes())));
+ } else {
+ items.add(new AppSpinnerAdapter.ThemeItem(
+ getString(R.string.storage_sdcard_unavailable),
+ getApplicationContext().getResources().getColor(R.color.text_secondary_light)));
+ }
+
+ items.add(new AppSpinnerAdapter.ThemeItem(
+ getString(R.string.storage_custom),
+ getApplicationContext().getResources().getColor(colorMap.getPrimaryColorRes())));
+
+ AppSpinnerAdapter adapter = new AppSpinnerAdapter(SettingsActivity.this,
+ R.layout.list_item_spinner, R.id.txtItem, items, R.drawable.ic_folder_open);
+ storageLocationSpinner.setAdapter(adapter);
+
+ final boolean sdAvailable = sdCardAvailable;
+ storageLocationSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ // Prevent triggering on initial load
+ if (position == currentStorageLocation) {
+ return;
+ }
+
+ // Handle SD card unavailable case
+ if (position == 1 && !sdAvailable) {
+ // Reset to previous selection
+ storageLocationSpinner.setSelection(currentStorageLocation);
+ showError(R.string.storage_sdcard_unavailable);
+ return;
+ }
+
+ // For SD card on Android 10+, we need to use SAF
+ if (position == 1) {
+ // Launch SAF folder picker
+ launchSafFolderPicker();
+ return; // Don't update storage location yet - wait for SAF result
+ }
+
+ currentStorageLocation = position;
+ presenter.setStorageLocation(getApplicationContext(), position);
+
+ // Show/hide custom directory button
+ updateCustomDirVisibility(position);
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) { }
+ });
+ }
+
+ private void launchSafFolderPicker() {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
+
+ try {
+ startActivityForResult(intent, REQUEST_SAF_FOLDER);
+ } catch (ActivityNotFoundException e) {
+ Timber.e(e, "No activity found for SAF folder picker");
+ showError(R.string.error_saf_not_available);
+ // Reset spinner to previous value
+ storageLocationSpinner.setSelection(currentStorageLocation);
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == REQUEST_SAF_FOLDER) {
+ if (resultCode == RESULT_OK && data != null) {
+ Uri treeUri = data.getData();
+ if (treeUri != null) {
+ final int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
+ try {
+ getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
+ presenter.setSafTreeUri(getApplicationContext(), treeUri.toString());
+ currentStorageLocation = 1;
+ presenter.setStorageLocation(getApplicationContext(), 1);
+ updateCustomDirVisibility(1);
+ Timber.d("SAF folder selected: %s", treeUri);
+ } catch (SecurityException e) {
+ Timber.e(e, "Failed to take persistable URI permission");
+ showError(R.string.error_saf_no_write_permission);
+ storageLocationSpinner.setSelection(currentStorageLocation);
+ }
+ } else {
+ storageLocationSpinner.setSelection(currentStorageLocation);
+ }
+ } else {
+ storageLocationSpinner.setSelection(currentStorageLocation);
+ }
+ } else if (requestCode == REQUEST_CUSTOM_FOLDER) {
+ if (resultCode == RESULT_OK && data != null) {
+ Uri treeUri = data.getData();
+ if (treeUri != null) {
+ final int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
+ try {
+ getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
+ presenter.setSafTreeUri(getApplicationContext(), treeUri.toString());
+ currentStorageLocation = 1;
+ presenter.setStorageLocation(getApplicationContext(), 1);
+ updateCustomDirVisibility(1);
+ Timber.d("Custom folder selected via SAF: %s", treeUri);
+ } catch (SecurityException e) {
+ Timber.e(e, "Failed to take persistable URI permission for custom folder");
+ showError(R.string.error_saf_no_write_permission);
+ }
+ }
+ }
+ }
+ }
+
+ private void updateCustomDirVisibility(int storageLocation) {
+ // Show "Change directory" button only for custom storage
+ boolean isCustom = (storageLocation == 2); // STORAGE_CUSTOM
+ btnChangeDir.setVisibility(isCustom ? View.VISIBLE : View.GONE);
+ }
+
private void initNameFormatSelector() {
nameFormatSelector = findViewById(R.id.name_format);
List items = new ArrayList<>();
@@ -304,11 +438,6 @@ protected void onStart() {
super.onStart();
presenter.bindView(this);
presenter.loadSettings();
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- //This is needed for scoped storage support
- swPublicDir.setChecked(false);
- swPublicDir.setEnabled(false);
- }
}
@Override
@@ -338,13 +467,15 @@ public void onClick(View v) {
startActivity(TrashActivity.getStartIntent(getApplicationContext()));
} else if (id == R.id.txt_records_location) {
presenter.onRecordsLocationClick();
- } else if (id == R.id.btn_file_browser) {
- startActivity(FileBrowserActivity.getStartIntent(getApplicationContext()));
- } else if (id == R.id.btnRate) {
- rateApp();
- } else if (id == R.id.btnReset) {
- presenter.resetSettings();
- presenter.loadSettings();
+ } else if (id == R.id.btn_file_browser) {
+ startActivity(FileBrowserActivity.getStartIntent(getApplicationContext()));
+ } else if (id == R.id.btn_change_records_dir) {
+ showChangeRecordsDirDialog();
+ } else if (id == R.id.btnRate) {
+ rateApp();
+ } else if (id == R.id.btnReset) {
+ presenter.resetSettings();
+ presenter.loadSettings();
} else if (id == R.id.btnRequest) {
requestFeature();
}
@@ -399,20 +530,40 @@ public SpannableStringBuilder getAboutContent() {
return aboutBody;
}
+ private void showChangeRecordsDirDialog() {
+ launchCustomFolderPicker();
+ }
+
+ private void launchCustomFolderPicker() {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+
+ try {
+ startActivityForResult(intent, REQUEST_CUSTOM_FOLDER);
+ } catch (ActivityNotFoundException e) {
+ Timber.e(e, "No activity found for folder picker");
+ showError(R.string.error_saf_not_available);
+ }
+ }
+
+ @Override
+ public void showStorageLocation(int location) {
+ currentStorageLocation = location;
+ if (storageLocationSpinner != null && storageLocationSpinner.getSelectedItemPosition() != location) {
+ storageLocationSpinner.setSelection(location);
+ }
+ updateCustomDirVisibility(location);
+ }
+
@Override
public void showStoreInPublicDir(boolean b) {
- swPublicDir.setOnCheckedChangeListener(null);
- swPublicDir.setChecked(b);
- swPublicDir.setOnCheckedChangeListener(publicDirListener);
+ // Legacy method - now handled by showStorageLocation
}
@Override
public void showDirectorySetting(boolean b) {
- panelPublicDir.setVisibility(b ? View.VISIBLE : View.GONE);
-// txtFileBrowser.setVisibility(b ? View.VISIBLE : View.GONE);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- txtStorageInfo.setVisibility(b ? View.VISIBLE : View.GONE);
- }
+ // Legacy method - storage settings now always visible
}
@Override
@@ -544,14 +695,28 @@ public void showInformation(String info) {
@Override
public void showRecordsLocation(String location) {
+ currentRecordsDir = location;
txtLocation.setVisibility(View.VISIBLE);
txtLocation.setText(getString(R.string.records_location, location));
+ // btnChangeDir visibility is managed by updateCustomDirVisibility
}
@Override
public void hideRecordsLocation() {
+ currentRecordsDir = null;
txtLocation.setText("");
txtLocation.setVisibility(View.GONE);
+ btnChangeDir.setVisibility(View.GONE);
+ }
+
+ @Override
+ public void showSdCardStorage(boolean available, boolean checked, String location) {
+ // Legacy method - SD card option is now in the storage location spinner
+ }
+
+ @Override
+ public void hideSdCardStorage() {
+ // Legacy method - SD card option is now in the storage location spinner
}
@Override
diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java b/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java
index 2ecd6ca6c..a61a06317 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java
@@ -26,6 +26,8 @@ public class SettingsContract {
interface View extends Contract.View {
+ void showStorageLocation(int location);
+
void showStoreInPublicDir(boolean b);
void showDirectorySetting(boolean b);
@@ -69,6 +71,8 @@ interface View extends Contract.View {
void showRecordsLocation(String location);
void hideRecordsLocation();
void openRecordsLocation(File file);
+ void showSdCardStorage(boolean available, boolean checked, String location);
+ void hideSdCardStorage();
void enableAudioSettings();
void disableAudioSettings();
@@ -78,8 +82,19 @@ public interface UserActionsListener extends Contract.UserActionsListener= Build.VERSION_CODES.N && isPaused.get()) {
diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java
index 4f1e57638..c799ae959 100755
--- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java
@@ -16,9 +16,11 @@
package com.dimowner.audiorecorder.audio.recorder;
+import com.dimowner.audiorecorder.data.RecordingTarget;
import com.dimowner.audiorecorder.exception.AppException;
import java.io.File;
+import java.io.FileDescriptor;
public interface RecorderContract {
@@ -34,6 +36,22 @@ interface RecorderCallback {
interface Recorder {
void setRecorderCallback(RecorderCallback callback);
void startRecording(String outputFile, int channelCount, int sampleRate, int bitrate);
+
+ /**
+ * Start recording to a FileDescriptor (for SAF support).
+ * @param fd The FileDescriptor to write to
+ * @param target The RecordingTarget containing path info for callbacks
+ * @param channelCount Number of audio channels
+ * @param sampleRate Sample rate in Hz
+ * @param bitrate Bitrate in bits per second
+ */
+ default void startRecording(FileDescriptor fd, RecordingTarget target, int channelCount, int sampleRate, int bitrate) {
+ // Default implementation falls back to file path if available
+ if (target != null && target.getFile() != null) {
+ startRecording(target.getFile().getAbsolutePath(), channelCount, sampleRate, bitrate);
+ }
+ }
+
void resumeRecording();
void pauseRecording();
void stopRecording();
diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java
index 574a5cb84..dde2b3d5f 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java
@@ -21,11 +21,14 @@
import android.media.MediaRecorder;
import android.os.Handler;
import com.dimowner.audiorecorder.AppConstants;
+import com.dimowner.audiorecorder.data.RecordingTarget;
import com.dimowner.audiorecorder.exception.InvalidOutputFile;
import com.dimowner.audiorecorder.exception.RecorderInitException;
import com.dimowner.audiorecorder.exception.RecordingException;
+import com.dimowner.audiorecorder.exception.WavSafNotSupportedException;
import com.dimowner.audiorecorder.util.AndroidUtils;
import java.io.File;
+import java.io.FileDescriptor;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
@@ -82,6 +85,19 @@ public void setRecorderCallback(RecorderContract.RecorderCallback callback) {
recorderCallback = callback;
}
+ /**
+ * WAV recording to SAF (SD card) is not supported because WAV requires RandomAccessFile
+ * to update the header after recording, which SAF doesn't provide.
+ * This method reports an error to the callback when SAF recording is attempted.
+ */
+ @Override
+ public void startRecording(FileDescriptor fd, RecordingTarget target, int channelCount, int sampleRate, int bitrate) {
+ Timber.e("WAV recording to SAF is not supported. WAV format requires RandomAccessFile for header updates.");
+ if (recorderCallback != null) {
+ recorderCallback.onError(new WavSafNotSupportedException());
+ }
+ }
+
@Override
@RequiresPermission(value = "android.permission.RECORD_AUDIO")
public void startRecording(String outputFile, int channelCount, int sampleRate, int bitrate) {
diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/FileRepository.java b/app/src/main/java/com/dimowner/audiorecorder/data/FileRepository.java
index 89d7a7965..e0aaa826e 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/data/FileRepository.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/data/FileRepository.java
@@ -17,6 +17,7 @@
package com.dimowner.audiorecorder.data;
import android.content.Context;
+import android.net.Uri;
import com.dimowner.audiorecorder.exception.CantCreateFileException;
@@ -26,6 +27,25 @@ public interface FileRepository {
File provideRecordFile() throws CantCreateFileException;
+ /**
+ * Create a new recording target. This may be a regular file or a SAF-based target
+ * depending on the current storage configuration.
+ * @param context Application context (needed for SAF operations)
+ * @return A RecordingTarget that can be used for recording
+ * @throws CantCreateFileException if the target cannot be created
+ */
+ RecordingTarget provideRecordingTarget(Context context) throws CantCreateFileException;
+
+ /**
+ * Check if we're currently configured to use SAF for recording.
+ */
+ boolean isUsingSaf();
+
+ /**
+ * Get the SAF tree URI if configured.
+ */
+ Uri getSafTreeUri();
+
File provideRecordFile(String name) throws CantCreateFileException;
// File getRecordFileByName(String name, String extension);
diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java b/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java
index ee0b259ba..c8c80f7db 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java
@@ -16,7 +16,13 @@
package com.dimowner.audiorecorder.data;
+import android.content.ContentResolver;
import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Environment;
+import android.provider.DocumentsContract;
+import android.text.TextUtils;
import java.io.File;
import java.io.FileNotFoundException;
@@ -36,8 +42,8 @@ public class FileRepositoryImpl implements FileRepository {
private volatile static FileRepositoryImpl instance;
private FileRepositoryImpl(Context context, Prefs prefs) {
- updateRecordingDir(context, prefs);
this.prefs = prefs;
+ updateRecordingDir(context, prefs);
}
public static FileRepositoryImpl getInstance(Context context, Prefs prefs) {
@@ -55,42 +61,147 @@ public static FileRepositoryImpl getInstance(Context context, Prefs prefs) {
public File provideRecordFile() throws CantCreateFileException {
prefs.incrementRecordCounter();
File recordFile;
- String recordName;
+ String recordName = generateRecordName();
+ String extension = getRecordingExtension();
+ recordFile = FileUtil.createFile(recordDirectory, FileUtil.addExtension(recordName, extension));
+
+ if (recordFile != null) {
+ return recordFile;
+ }
+ throw new CantCreateFileException();
+ }
+
+ @Override
+ public RecordingTarget provideRecordingTarget(Context context) throws CantCreateFileException {
+ prefs.incrementRecordCounter();
+ String recordName = generateRecordName();
+ String extension = getRecordingExtension();
+ String fileName = FileUtil.addExtension(recordName, extension);
+
+ // Check if we should use SAF (SD card storage with SAF URI configured)
+ if (isUsingSaf()) {
+ return createSafTarget(context, fileName, extension);
+ }
+
+ // Fall back to regular file creation
+ File recordFile = FileUtil.createFile(recordDirectory, fileName);
+ if (recordFile != null) {
+ return new RecordingTarget(recordFile);
+ }
+ throw new CantCreateFileException();
+ }
+
+ private RecordingTarget createSafTarget(Context context, String fileName, String extension) throws CantCreateFileException {
+ Uri treeUri = getSafTreeUri();
+ if (treeUri == null) {
+ throw new CantCreateFileException();
+ }
+
+ try {
+ ContentResolver resolver = context.getContentResolver();
+
+ // Build the document ID for the tree
+ String treeDocumentId = DocumentsContract.getTreeDocumentId(treeUri);
+
+ // Get MIME type for the file
+ String mimeType = getMimeType(extension);
+
+ // Create the document URI for the parent
+ Uri parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, treeDocumentId);
+
+ // Create the file using DocumentsContract
+ Uri newFileUri = DocumentsContract.createDocument(resolver, parentUri, mimeType, fileName);
+ if (newFileUri == null) {
+ Timber.e("Failed to create SAF file: %s", fileName);
+ throw new CantCreateFileException();
+ }
+
+ // Build a display path for the database
+ String displayPath = buildSafDisplayPath(treeUri, fileName);
+
+ Timber.d("Created SAF file: %s (display: %s)", newFileUri, displayPath);
+ return new RecordingTarget(newFileUri, displayPath);
+ } catch (Exception e) {
+ Timber.e(e, "Failed to create SAF recording target");
+ throw new CantCreateFileException();
+ }
+ }
+
+ private String getMimeType(String extension) {
+ switch (extension.toLowerCase()) {
+ case AppConstants.FORMAT_M4A:
+ return "audio/mp4";
+ case AppConstants.FORMAT_WAV:
+ return "audio/wav";
+ case AppConstants.FORMAT_3GP:
+ return "audio/3gpp";
+ default:
+ return "audio/*";
+ }
+ }
+
+ private String buildSafDisplayPath(Uri treeUri, String fileName) {
+ // Try to extract a readable path from the tree URI
+ // Tree URIs look like: content://com.android.externalstorage.documents/tree/6264-6362%3AAudioRecorder
+ String path = treeUri.getPath();
+ if (path != null && path.contains(":")) {
+ // Extract the volume and path parts
+ String[] parts = path.split(":");
+ if (parts.length >= 2) {
+ String treePart = parts[0];
+ String folderPath = parts.length > 1 ? parts[1] : "";
+ // Extract volume ID (like 6264-6362)
+ if (treePart.contains("/")) {
+ String volumeId = treePart.substring(treePart.lastIndexOf("/") + 1);
+ return "/storage/" + volumeId + "/" + folderPath + "/" + fileName;
+ }
+ }
+ }
+ // Fallback: just use the URI string representation
+ return treeUri.toString() + "/" + fileName;
+ }
+
+ private String generateRecordName() {
switch (prefs.getSettingNamingFormat()) {
default:
case AppConstants.NAME_FORMAT_RECORD:
- recordName = FileUtil.generateRecordNameCounted(prefs.getRecordCounter());
- break;
+ return FileUtil.generateRecordNameCounted(prefs.getRecordCounter());
case AppConstants.NAME_FORMAT_DATE:
- recordName = FileUtil.generateRecordNameDateVariant();
- break;
+ return FileUtil.generateRecordNameDateVariant();
case AppConstants.NAME_FORMAT_DATE_US:
- recordName = FileUtil.generateRecordNameDateUS();
- break;
+ return FileUtil.generateRecordNameDateUS();
case AppConstants.NAME_FORMAT_DATE_ISO8601:
- recordName = FileUtil.generateRecordNameDateISO8601();
- break;
+ return FileUtil.generateRecordNameDateISO8601();
case AppConstants.NAME_FORMAT_TIMESTAMP:
- recordName = FileUtil.generateRecordNameMills();
- break;
+ return FileUtil.generateRecordNameMills();
}
+ }
+
+ private String getRecordingExtension() {
switch (prefs.getSettingRecordingFormat()) {
default:
case AppConstants.FORMAT_M4A:
- recordFile = FileUtil.createFile(recordDirectory, FileUtil.addExtension(recordName, AppConstants.FORMAT_M4A));
- break;
+ return AppConstants.FORMAT_M4A;
case AppConstants.FORMAT_WAV:
- recordFile = FileUtil.createFile(recordDirectory, FileUtil.addExtension(recordName, AppConstants.FORMAT_WAV));
- break;
+ return AppConstants.FORMAT_WAV;
case AppConstants.FORMAT_3GP:
- recordFile = FileUtil.createFile(recordDirectory, FileUtil.addExtension(recordName, AppConstants.FORMAT_3GP));
- break;
+ return AppConstants.FORMAT_3GP;
}
+ }
- if (recordFile != null) {
- return recordFile;
+ @Override
+ public boolean isUsingSaf() {
+ return prefs.getStorageLocation() == Prefs.STORAGE_SDCARD
+ && prefs.getSafTreeUri() != null;
+ }
+
+ @Override
+ public Uri getSafTreeUri() {
+ String uriString = prefs.getSafTreeUri();
+ if (uriString != null) {
+ return Uri.parse(uriString);
}
- throw new CantCreateFileException();
+ return null;
}
@Override
@@ -104,6 +215,10 @@ public File provideRecordFile(String name) throws CantCreateFileException {
@Override
public File[] getPrivateDirFiles(Context context) {
+ if (prefs.isStoreInSdCard() && recordDirectory != null) {
+ File[] files = recordDirectory.listFiles();
+ return files != null ? files : new File[] {};
+ }
try {
return FileUtil.getPrivateRecordsDir(context).listFiles();
} catch (FileNotFoundException e) {
@@ -114,7 +229,7 @@ public File[] getPrivateDirFiles(Context context) {
@Override
public File[] getPublicDirFiles() {
- File dir = FileUtil.getAppDir();
+ File dir = resolvePublicDir();
if (dir != null) {
return dir.listFiles();
} else {
@@ -124,11 +239,14 @@ public File[] getPublicDirFiles() {
@Override
public File getPublicDir() {
- return FileUtil.getAppDir();
+ return resolvePublicDir();
}
@Override
public File getPrivateDir(Context context) {
+ if (prefs.isStoreInSdCard() && recordDirectory != null) {
+ return recordDirectory;
+ }
try {
return FileUtil.getPrivateRecordsDir(context);
} catch (FileNotFoundException e) {
@@ -190,35 +308,107 @@ public boolean renameFile(String path, String newName, String extension) {
}
public void updateRecordingDir(Context context, Prefs prefs) {
- if (prefs.isStoreDirPublic()) {
- recordDirectory = FileUtil.getAppDir();
- if (recordDirectory == null) {
- //Try to init private dir
- try {
- recordDirectory = FileUtil.getPrivateRecordsDir(context);
- } catch (FileNotFoundException e) {
- Timber.e(e);
- //If nothing helped then hardcode recording dir
- recordDirectory = new File("/data/data/" + ARApplication.appPackage() + "/files");
+ int storageLocation = prefs.getStorageLocation();
+
+ switch (storageLocation) {
+ case Prefs.STORAGE_SDCARD:
+ File sdDir = resolveSdCardPublicDir(context);
+ if (sdDir != null) {
+ recordDirectory = sdDir;
+ return;
}
+ Timber.w("SD card storage selected but unavailable. Falling back to internal storage.");
+ // Fall through to internal storage
+ case Prefs.STORAGE_INTERNAL:
+ default:
+ recordDirectory = resolvePublicDir();
+ if (recordDirectory == null) {
+ Timber.e("Failed to resolve public directory, using fallback");
+ recordDirectory = FileUtil.getAppDir();
+ }
+ break;
+ case Prefs.STORAGE_CUSTOM:
+ recordDirectory = resolvePublicDir();
+ if (recordDirectory == null) {
+ Timber.e("Failed to resolve custom directory, falling back to internal");
+ recordDirectory = FileUtil.getAppDir();
+ }
+ break;
+ }
+
+ // Final fallback - should never reach here
+ if (recordDirectory == null) {
+ recordDirectory = new File("/data/data/" + ARApplication.appPackage() + "/files");
+ }
+ }
+
+ private File resolvePublicDir() {
+ String customPath = prefs.getPublicDirectoryPath();
+ if (!TextUtils.isEmpty(customPath)) {
+ String normalized = customPath.trim();
+ if (TextUtils.isEmpty(normalized)) {
+ return resolveDefaultPublicDir();
}
- } else {
+ File customDir = buildPublicDir(normalized);
+ File ensuredCustom = FileUtil.createDir(customDir);
+ if (ensuredCustom != null) {
+ return ensuredCustom;
+ }
+ if (customDir.exists() && customDir.isDirectory()) {
+ return customDir;
+ }
+ Timber.e("Failed to access custom public directory: %s", customDir.getAbsolutePath());
+ }
+ return resolveDefaultPublicDir();
+ }
+
+ private File resolveSdCardDir(Context context) {
+ File musicDir = FileUtil.getSecondaryExternalMusicDir(context);
+ if (musicDir != null) {
try {
- recordDirectory = FileUtil.getPrivateRecordsDir(context);
+ return FileUtil.getPrivateRecordsDir(context, musicDir);
} catch (FileNotFoundException e) {
Timber.e(e);
- //Try to init public dir
- //App dir now is not available.
- //If nothing helped then hardcode recording dir
- recordDirectory = new File("/data/data/" + ARApplication.appPackage() + "/files");
}
}
+ return null;
+ }
+
+ /**
+ * Resolve a PUBLIC directory on the SD card (survives uninstall).
+ */
+ private File resolveSdCardPublicDir(Context context) {
+ return FileUtil.getSecondaryExternalPublicDir(context, AppConstants.APPLICATION_NAME);
+ }
+
+ private File resolveDefaultPublicDir() {
+ File defaultDir = FileUtil.getAppDir();
+ if (defaultDir != null) {
+ File ensuredDefault = FileUtil.createDir(defaultDir);
+ if (ensuredDefault != null) {
+ return ensuredDefault;
+ }
+ if (defaultDir.exists() && defaultDir.isDirectory()) {
+ return defaultDir;
+ }
+ }
+ return null;
+ }
+
+ private File buildPublicDir(String normalized) {
+ File dir = new File(normalized);
+ if (!dir.isAbsolute()) {
+ dir = new File(Environment.getExternalStorageDirectory(), normalized);
+ }
+ return dir;
}
@Override
public boolean hasAvailableSpace(Context context) throws IllegalArgumentException {
long space;
- if (prefs.isStoreDirPublic()) {
+ if (prefs.isStoreInSdCard() && recordDirectory != null) {
+ space = FileUtil.getFree(recordDirectory);
+ } else if (prefs.isStoreDirPublic()) {
// TODO: deprecated fix this
space = FileUtil.getAvailableExternalMemorySize();
} else {
diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java b/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java
index e3336bb01..f240832e5 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java
@@ -18,12 +18,56 @@
public interface Prefs {
+ // Storage location constants - all use public (persistent) paths
+ int STORAGE_INTERNAL = 0; // /storage/emulated/0/AudioRecorder
+ int STORAGE_SDCARD = 1; // /storage//AudioRecorder (via SAF)
+ int STORAGE_CUSTOM = 2; // User-specified path
+
boolean isFirstRun();
+
+ /**
+ * Get the SAF tree URI for SD card access.
+ * @return SAF tree URI string, or null if not set
+ */
+ String getSafTreeUri();
+
+ /**
+ * Set the SAF tree URI for SD card access.
+ * @param uri SAF tree URI string, or null to clear
+ */
+ void setSafTreeUri(String uri);
void firstRunExecuted();
+ /**
+ * Get the storage location type.
+ * @return One of STORAGE_INTERNAL, STORAGE_SDCARD, or STORAGE_CUSTOM
+ */
+ int getStorageLocation();
+
+ /**
+ * Set the storage location type.
+ * @param location One of STORAGE_INTERNAL, STORAGE_SDCARD, or STORAGE_CUSTOM
+ */
+ void setStorageLocation(int location);
+
+ // Deprecated: use getStorageLocation() instead
boolean isStoreDirPublic();
void setStoreDirPublic(boolean b);
+ // Deprecated: use getStorageLocation() instead
+ boolean isStoreInSdCard();
+ void setStoreInSdCard(boolean b);
+
+ /**
+ * @return Absolute path to the custom public recordings directory or empty string when default should be used.
+ */
+ String getPublicDirectoryPath();
+
+ /**
+ * Persist absolute path to the custom public recordings directory. Passing {@code null} or empty string clears the custom value.
+ */
+ void setPublicDirectoryPath(String path);
+
//This is needed for scoped storage support
boolean isShowDirectorySetting();
diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java b/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java
index 652c6f71d..e3a4d5854 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java
@@ -18,7 +18,7 @@
import android.content.Context;
import android.content.SharedPreferences;
-import android.os.Build;
+import android.text.TextUtils;
import com.dimowner.audiorecorder.AppConstants;
@@ -33,6 +33,10 @@ public class PrefsImpl implements Prefs {
private static final String PREF_KEY_IS_MIGRATED = "is_migrated";
private static final String PREF_KEY_IS_MIGRATED_DB3 = "is_migrated_db3";
private static final String PREF_KEY_IS_STORE_DIR_PUBLIC = "is_store_dir_public";
+ private static final String PREF_KEY_IS_STORE_IN_SD_CARD = "is_store_in_sd_card";
+ private static final String PREF_KEY_STORAGE_LOCATION = "storage_location";
+ private static final String PREF_KEY_SAF_TREE_URI = "saf_tree_uri";
+ private static final String PREF_KEY_PUBLIC_DIRECTORY_PATH = "public_directory_path";
private static final String PREF_KEY_IS_SHOW_DIRECTORY_SETTING = "is_show_directory_setting";
private static final String PREF_KEY_IS_ASK_TO_RENAME_AFTER_STOP_RECORDING = "is_ask_rename_after_stop_recording";
private static final String PREF_KEY_ACTIVE_RECORD = "active_record";
@@ -88,11 +92,37 @@ public void firstRunExecuted() {
editor.putBoolean(PREF_KEY_IS_STORE_DIR_PUBLIC, false);
editor.putBoolean(PREF_KEY_IS_MIGRATED, true);
editor.putBoolean(PREF_KEY_IS_PUBLIC_STORAGE_MIGRATED, true);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- editor.putBoolean(PREF_KEY_IS_SHOW_DIRECTORY_SETTING, false);
+ // Default to internal storage (public, persistent)
+ editor.putInt(PREF_KEY_STORAGE_LOCATION, STORAGE_INTERNAL);
+ editor.apply();
+ }
+
+ @Override
+ public int getStorageLocation() {
+ return sharedPreferences.getInt(PREF_KEY_STORAGE_LOCATION, STORAGE_INTERNAL);
+ }
+
+ @Override
+ public void setStorageLocation(int location) {
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putInt(PREF_KEY_STORAGE_LOCATION, location);
+ editor.apply();
+ }
+
+ @Override
+ public String getSafTreeUri() {
+ return sharedPreferences.getString(PREF_KEY_SAF_TREE_URI, null);
+ }
+
+ @Override
+ public void setSafTreeUri(String uri) {
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ if (uri == null) {
+ editor.remove(PREF_KEY_SAF_TREE_URI);
+ } else {
+ editor.putString(PREF_KEY_SAF_TREE_URI, uri);
}
editor.apply();
-// setStoreDirPublic(true);
}
@Override
@@ -107,6 +137,35 @@ public void setStoreDirPublic(boolean b) {
editor.apply();
}
+ @Override
+ public boolean isStoreInSdCard() {
+ return sharedPreferences.getBoolean(PREF_KEY_IS_STORE_IN_SD_CARD, false);
+ }
+
+ @Override
+ public void setStoreInSdCard(boolean b) {
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putBoolean(PREF_KEY_IS_STORE_IN_SD_CARD, b);
+ editor.apply();
+ }
+
+ @Override
+ public String getPublicDirectoryPath() {
+ return sharedPreferences.getString(PREF_KEY_PUBLIC_DIRECTORY_PATH, "");
+ }
+
+ @Override
+ public void setPublicDirectoryPath(String path) {
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ String normalized = path == null ? "" : path.trim();
+ if (TextUtils.isEmpty(normalized)) {
+ editor.remove(PREF_KEY_PUBLIC_DIRECTORY_PATH);
+ } else {
+ editor.putString(PREF_KEY_PUBLIC_DIRECTORY_PATH, normalized);
+ }
+ editor.apply();
+ }
+
@Override
public boolean isShowDirectorySetting() {
return sharedPreferences.getBoolean(PREF_KEY_IS_SHOW_DIRECTORY_SETTING, true);
diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/RecordingTarget.java b/app/src/main/java/com/dimowner/audiorecorder/data/RecordingTarget.java
new file mode 100644
index 000000000..81d0917b3
--- /dev/null
+++ b/app/src/main/java/com/dimowner/audiorecorder/data/RecordingTarget.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2024 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.data;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.IOException;
+
+import timber.log.Timber;
+
+/**
+ * Represents a recording target that can be either a regular File or a SAF-based URI.
+ * This abstraction allows the recorder to work with both traditional file paths
+ * and Storage Access Framework URIs transparently.
+ */
+public class RecordingTarget {
+
+ private final File file;
+ private final Uri safUri;
+ private final String displayPath;
+ private volatile ParcelFileDescriptor pfd;
+
+ /**
+ * Create a file-based recording target.
+ */
+ public RecordingTarget(File file) {
+ this.file = file;
+ this.safUri = null;
+ this.displayPath = file != null ? file.getAbsolutePath() : null;
+ }
+
+ /**
+ * Create a SAF-based recording target.
+ * @param safUri The SAF document URI
+ * @param displayPath A human-readable path for display/database purposes
+ */
+ public RecordingTarget(Uri safUri, String displayPath) {
+ this.file = null;
+ this.safUri = safUri;
+ this.displayPath = displayPath;
+ }
+
+ /**
+ * Check if this is a SAF-based target.
+ */
+ public boolean isSaf() {
+ return safUri != null;
+ }
+
+ /**
+ * Get the file for file-based targets.
+ * @return The file, or null if this is a SAF target
+ */
+ public File getFile() {
+ return file;
+ }
+
+ /**
+ * Get the SAF URI for SAF-based targets.
+ * @return The SAF URI, or null if this is a file target
+ */
+ public Uri getSafUri() {
+ return safUri;
+ }
+
+ /**
+ * Get the display path (for database and UI).
+ * For file targets, this is the absolute path.
+ * For SAF targets, this is a reconstructed path like /storage/XXXX-XXXX/AudioRecorder/file.m4a
+ */
+ public String getDisplayPath() {
+ return displayPath;
+ }
+
+ /**
+ * Get a file path string. For file targets, returns absolute path.
+ * For SAF targets, returns the display path.
+ */
+ public String getPath() {
+ return displayPath;
+ }
+
+ /**
+ * Open a ParcelFileDescriptor for writing. Must be closed after use via close().
+ * For file targets, opens the file directly.
+ * For SAF targets, uses ContentResolver.
+ */
+ public synchronized ParcelFileDescriptor openForWriting(Context context) throws IOException {
+ if (file != null) {
+ pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE);
+ return pfd;
+ } else if (safUri != null) {
+ try {
+ pfd = context.getContentResolver().openFileDescriptor(safUri, "rw");
+ if (pfd == null) {
+ throw new IOException("Failed to open SAF URI for writing: " + safUri);
+ }
+ return pfd;
+ } catch (Exception e) {
+ throw new IOException("Failed to open SAF URI: " + safUri, e);
+ }
+ }
+ throw new IOException("No valid target");
+ }
+
+ /**
+ * Get the FileDescriptor for this target. Opens it if not already open.
+ */
+ public FileDescriptor getFileDescriptor(Context context) throws IOException {
+ ParcelFileDescriptor localPfd = openForWriting(context);
+ return localPfd.getFileDescriptor();
+ }
+
+ /**
+ * Close any open file descriptors.
+ */
+ public synchronized void close() {
+ ParcelFileDescriptor localPfd = pfd;
+ pfd = null;
+ if (localPfd != null) {
+ try {
+ localPfd.close();
+ } catch (IOException e) {
+ Timber.e(e, "Failed to close ParcelFileDescriptor");
+ }
+ }
+ }
+
+ /**
+ * Check if the target exists and is writable.
+ */
+ public boolean exists() {
+ if (file != null) {
+ return file.exists();
+ }
+ // For SAF, we assume it exists if we have a URI
+ return safUri != null;
+ }
+}
diff --git a/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java b/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java
index 3748a1544..f7a296d47 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java
@@ -28,6 +28,7 @@ public abstract class AppException extends Exception {
public static final int NO_SPACE_AVAILABLE = 8;
public static final int RECORDING_ERROR = 9;
public static final int FAILED_TO_RESTORE = 10;
+ public static final int WAV_FORMAT_NOT_SUPPORTED_SAF = 11;
public abstract int getType();
}
diff --git a/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java b/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java
index 3d7008562..655990f66 100644
--- a/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java
@@ -43,6 +43,8 @@ public static int parseException(AppException e) {
return R.string.error_failed_to_restore;
} else if (e.getType() == AppException.READ_PERMISSION_DENIED) {
return R.string.error_permission_denied;
+ } else if (e.getType() == AppException.WAV_FORMAT_NOT_SUPPORTED_SAF) {
+ return R.string.error_wav_format_not_supported_saf;
}
return R.string.error_unknown;
}
diff --git a/app/src/main/java/com/dimowner/audiorecorder/exception/WavSafNotSupportedException.java b/app/src/main/java/com/dimowner/audiorecorder/exception/WavSafNotSupportedException.java
new file mode 100644
index 000000000..56d2b557d
--- /dev/null
+++ b/app/src/main/java/com/dimowner/audiorecorder/exception/WavSafNotSupportedException.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024 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.exception;
+
+/**
+ * Exception thrown when attempting to record WAV format to SD card via SAF.
+ * WAV recording requires RandomAccessFile to update the header after recording,
+ * which is not supported by the Storage Access Framework.
+ */
+public class WavSafNotSupportedException extends AppException {
+ @Override
+ public int getType() {
+ return AppException.WAV_FORMAT_NOT_SUPPORTED_SAF;
+ }
+}
diff --git a/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java b/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java
index e30d369e9..c220f9e0b 100755
--- a/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java
+++ b/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java
@@ -28,6 +28,8 @@
import android.util.Log;
import android.webkit.MimeTypeMap;
+import androidx.core.content.ContextCompat;
+
import com.dimowner.audiorecorder.AppConstants;
import java.io.BufferedInputStream;
@@ -40,6 +42,8 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
import timber.log.Timber;
@@ -72,6 +76,63 @@ public static File getPrivateRecordsDir(Context context) throws FileNotFoundExce
return dir;
}
+ public static File getPrivateRecordsDir(Context context, File musicDir) throws FileNotFoundException {
+ if (musicDir != null) {
+ File dir = new File(musicDir, AppConstants.RECORDS_DIR);
+ File created = createDir(dir);
+ if (created != null) {
+ return created;
+ }
+ }
+ throw new FileNotFoundException();
+ }
+
+ public static File getSecondaryExternalMusicDir(Context context) {
+ File[] dirs = ContextCompat.getExternalFilesDirs(context, Environment.DIRECTORY_MUSIC);
+ if (dirs != null) {
+ for (int i = 1; i < dirs.length; i++) {
+ File dir = dirs[i];
+ if (dir != null) {
+ return dir;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get a public directory on the SD card (not app-specific, survives uninstall).
+ * Extracts the SD card mount point from the app-specific path and creates a public folder.
+ * @param context Application context
+ * @param dirName Directory name to create on SD card root
+ * @return Public directory on SD card, or null if SD card not available
+ */
+ public static File getSecondaryExternalPublicDir(Context context, String dirName) {
+ File[] dirs = ContextCompat.getExternalFilesDirs(context, Environment.DIRECTORY_MUSIC);
+ if (dirs != null) {
+ for (int i = 1; i < dirs.length; i++) {
+ File appSpecificDir = dirs[i];
+ if (appSpecificDir != null) {
+ // Extract SD card root by finding /Android/data/ in the path
+ String path = appSpecificDir.getAbsolutePath();
+ int androidIndex = path.indexOf("/Android/data/");
+ if (androidIndex > 0) {
+ String sdCardRoot = path.substring(0, androidIndex);
+ File publicDir = new File(sdCardRoot, dirName);
+ File created = createDir(publicDir);
+ if (created != null) {
+ return created;
+ }
+ if (publicDir.exists() && publicDir.isDirectory()) {
+ return publicDir;
+ }
+ }
+ }
+ }
+ }
+ return null;
+ }
+
public static String generateRecordNameCounted(long counter) {
return AppConstants.BASE_RECORD_NAME + counter;
}
@@ -501,13 +562,30 @@ public static boolean isExternalStorageReadable() {
}
public static boolean isFileInExternalStorage(Context context, String path) {
- String privateDir = "";
+ if (path == null) {
+ return true;
+ }
+ List privateDirs = new ArrayList<>();
try {
- privateDir = FileUtil.getPrivateRecordsDir(context).getAbsolutePath();
+ privateDirs.add(FileUtil.getPrivateRecordsDir(context).getAbsolutePath());
} catch (FileNotFoundException e) {
Timber.e(e);
}
- return path == null || !path.contains(privateDir);
+ File[] dirs = ContextCompat.getExternalFilesDirs(context, Environment.DIRECTORY_MUSIC);
+ if (dirs != null) {
+ for (File dir : dirs) {
+ if (dir != null) {
+ File records = new File(dir, AppConstants.RECORDS_DIR);
+ privateDirs.add(records.getAbsolutePath());
+ }
+ }
+ }
+ for (String dirPath : privateDirs) {
+ if (dirPath != null && !dirPath.isEmpty() && path.startsWith(dirPath)) {
+ return false;
+ }
+ }
+ return true;
}
public static File getPublicMusicStorageDir(String albumName) {
@@ -715,4 +793,65 @@ public interface FileOnCopyListener {
void onCopyFinish(String message);
void onError(String message);
}
+
+ /**
+ * Reconstruct a SAF document URI from a display path and tree URI.
+ * Display paths look like: /storage/6264-6362/AudioRecorder/Record-1.m4a
+ * Tree URIs look like: content://com.android.externalstorage.documents/tree/6264-6362%3AAudioRecorder
+ *
+ * @param displayPath The display path stored in the database
+ * @param treeUri The SAF tree URI from preferences
+ * @return The document URI if reconstruction succeeds, null otherwise
+ */
+ public static Uri reconstructSafDocumentUri(String displayPath, Uri treeUri) {
+ if (displayPath == null || treeUri == null) {
+ return null;
+ }
+
+ try {
+ // Extract volume ID and folder path from tree URI
+ // Tree URI path looks like: /tree/6264-6362:AudioRecorder
+ String treeUriPath = treeUri.getPath();
+ if (treeUriPath == null || !treeUriPath.contains(":")) {
+ Timber.w("Invalid tree URI path: %s", treeUriPath);
+ return null;
+ }
+
+ // Parse tree document ID (e.g., "6264-6362:AudioRecorder")
+ String treeDocumentId = DocumentsContract.getTreeDocumentId(treeUri);
+
+ // Extract volume ID from display path
+ // Display path looks like: /storage/6264-6362/AudioRecorder/Record-1.m4a
+ if (!displayPath.startsWith("/storage/")) {
+ Timber.w("Display path doesn't start with /storage/: %s", displayPath);
+ return null;
+ }
+
+ // Remove "/storage/" prefix
+ String pathWithoutStorage = displayPath.substring(9); // "/storage/".length()
+
+ // Find the volume ID (first path segment after /storage/)
+ int firstSlash = pathWithoutStorage.indexOf('/');
+ if (firstSlash < 0) {
+ Timber.w("Cannot find volume ID in display path: %s", displayPath);
+ return null;
+ }
+
+ String volumeId = pathWithoutStorage.substring(0, firstSlash);
+ String remainingPath = pathWithoutStorage.substring(firstSlash + 1);
+
+ // Build document ID: volumeId:path (e.g., "6264-6362:AudioRecorder/Record-1.m4a")
+ String documentId = volumeId + ":" + remainingPath;
+
+ // Build the document URI using the tree
+ Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId);
+
+ Timber.d("Reconstructed SAF URI: %s from display path: %s", documentUri, displayPath);
+ return documentUri;
+
+ } catch (Exception e) {
+ Timber.e(e, "Failed to reconstruct SAF document URI from: %s", displayPath);
+ return null;
+ }
+ }
}
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index ec80da84f..34c65db74 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -126,34 +126,13 @@
android:paddingStart="0dp"
android:paddingEnd="1dp"/>
-
+
-
-
-
-
-
+ android:dropDownWidth="match_parent" />
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index cfb3d9a23..89d7047f7 100644
Binary files a/app/src/main/res/values-bg/strings.xml and b/app/src/main/res/values-bg/strings.xml differ
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index 83d014a83..7c0d95e28 100755
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -78,8 +78,8 @@
- S\'ha mogut %d gravació mogut correctament
- S\'han mogut %d gravacions correctament
- S\'han copiat %d gravacions correctament i n\'han fallat %d
- S\'han mogut %d gravacions correctament i n\'han fallat %d
+ S\'han copiat %1$d gravacions correctament i n\'han fallat %2$d
+ S\'han mogut %1$d gravacions correctament i n\'han fallat %2$d
S\'ha copiat %s al directori de Baixades
S\'ha mogut %s al directori privat de l\'aplicació
@@ -127,6 +127,11 @@
Explorador de fitxers
Directori privat
Directori públic
+ Ubicació d\'emmagatzematge
+ Emmagatzematge intern
+ Targeta SD
+ Ruta personalitzada
+ Targeta SD (no disponible)
S\'ha trobat a l\'aplicació
No s\'ha trobat a l\'aplicació
A la paperera
@@ -224,6 +229,9 @@
Error en la gravació.
No s\'ha pogut restaurar la gravació.
No s\'ha pogut esborrar la gravació.
+ No es pot obrir el selector de carpetes. És possible que el dispositiu no admeti aquesta funció.
+ No es pot escriure a la carpeta seleccionada. Si us plau, trieu una altra ubicació.
+ El format WAV no és compatible quan es desa a la targeta SD. Si us plau, utilitzeu el format M4A o 3GP, o canvieu a l\'emmagatzematge intern.
Cal moure les gravacions de l\'emmagatzematge públic
Més tard
Mou
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index e8d9b3f67..928dfc88f 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -57,7 +57,7 @@
No preguntar nuevamente
Abrir con…
Descargando: %s
- %d grabaciones descargadas exitosamente y %d fallaron al descargar
+ %1$d grabaciones descargadas exitosamente y %2$d fallaron al descargar
%s descargado al directorio de Descargas
Falló al descargar: %s
Descarga cancelada
@@ -96,6 +96,11 @@
Gestor de archivos
Directorio privado
Directorio público
+ Ubicación de almacenamiento
+ Almacenamiento interno
+ Tarjeta SD
+ Ruta personalizada
+ Tarjeta SD (no disponible)
Encontrado en la app
No encontrado en la app
En la basura
@@ -168,6 +173,9 @@
¡Error de grabación!
¡No se pudo restaurar la grabación!
¡No se pudo borrar la grabación!
+ No se puede abrir el selector de carpetas. Es posible que su dispositivo no admita esta función.
+ No se puede escribir en la carpeta seleccionada. Por favor, elija otra ubicación.
+ El formato WAV no es compatible al guardar en la tarjeta SD. Por favor, use el formato M4A o 3GP, o cambie al almacenamiento interno.
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 547544788..b96e79437 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -78,8 +78,8 @@
- Les %d l\'enregistrement a bien été déplacé
- Les %d enregistrements ont bien été déplacé
- %d enregistrements ont bien été copié et %d ont échoué
- %d enregistrements ont bien été déplacé et %d ont échoué
+ %1$d enregistrements ont bien été copiés et %2$d ont échoué
+ %1$d enregistrements ont bien été déplacés et %2$d ont échoué
%s copiés dans le dossier \'Downloads\'
%s déplacés dans le dossier \'Downloads\'
@@ -127,6 +127,11 @@
Explorateur de fichier
Dossier privé
Dossier public
+ Emplacement de stockage
+ Stockage interne
+ Carte SD
+ Chemin personnalisé
+ Carte SD (indisponible)
Trouvé dans l\'application
Pas trouvé dans l\'application
Dans la corbeille
@@ -225,6 +230,9 @@
Erreur pendant l\'enregistrement !
Échec de la récupération de l\'enregistrement !
Échec de la suppression de l\'enregistrement !
+ Impossible d\'ouvrir le sélecteur de dossier. Votre appareil ne supporte peut-être pas cette fonctionnalité.
+ Impossible d\'écrire dans le dossier sélectionné. Veuillez choisir un autre emplacement.
+ Le format WAV n\'est pas pris en charge lors de l\'enregistrement sur carte SD. Veuillez utiliser le format M4A ou 3GP, ou passer au stockage interne.
Déplacement du stockage public des enregistrements nécessaire
Plus tard
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 1c510d24d..c26172fb7 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -89,8 +89,8 @@
- %d записи перемещены успешно
- %d записей перемещено успешно
- %d записей скопировано успешно, и %d не удалось скопировать
- %d записей перемещено успешно, и %d не удалось переместить
+ %1$d записей скопировано успешно, и %2$d не удалось скопировать
+ %1$d записей перемещено успешно, и %2$d не удалось переместить
%s скопировано в папку \'Downloads\'
%s перемещено в приватную папку приложения
@@ -142,6 +142,11 @@
Файловый менеджер
Приватный каталог
Публичный каталог
+ Место хранения
+ Внутреннее хранилище
+ SD-карта
+ Пользовательский путь
+ SD-карта (недоступна)
Есть в приложении
Не найдено в приложении
В корзине
@@ -242,6 +247,9 @@
Ошибка при записи
Не удалось восстановить запись!
Не удалось удалить запись!
+ Не удалось открыть выбор папки. Ваше устройство может не поддерживать эту функцию.
+ Невозможно записать в выбранную папку. Пожалуйста, выберите другое место.
+ Формат WAV не поддерживается при сохранении на SD-карту. Пожалуйста, используйте формат M4A или 3GP, или переключитесь на внутреннее хранилище.
Нужно переместить записи из публичной папки
Позже
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 167a60612..6085dc347 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -79,8 +79,8 @@
- %d kayıt taşındı
- %d kayıt taşındı
- %d kayıt kopyalandı ve %d kayıt kopyalanamadı
- %d kayıt taşındı ve %d kayıt taşınamadı
+ %1$d kayıt kopyalandı ve %2$d kayıt kopyalanamadı
+ %1$d kayıt taşındı ve %2$d kayıt taşınamadı
%s \'Downloads\' dizinine kaydedildi
%s uygulama dizinine taşındı
@@ -128,6 +128,11 @@
Dosya yöneticisi
Uygulama dizini
Genel dizin
+ Depolama konumu
+ Dahili depolama
+ SD kart
+ Özel yol
+ SD kart (kullanılamıyor)
Uygulama içinde bulundu
Uygulama içinde bulunamadı
Çöp kutusunda
@@ -227,6 +232,9 @@
Kayıt hatası!
Kayıt geri yüklenemedi!
Kayıt silinemedi!
+ Klasör seçici açılamıyor. Cihazınız bu özelliği desteklemiyor olabilir.
+ Seçilen klasöre yazılamıyor. Lütfen farklı bir konum seçin.
+ SD karta kaydederken WAV formatı desteklenmiyor. Lütfen M4A veya 3GP formatını kullanın veya dahili depolamaya geçin.
Genel dizindeki kayıtların taşınması gerekiyor
Sonra
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 474398ea3..69fbb13d0 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -89,8 +89,8 @@
- %d записи успішно переміщено
- %d записів успішно переміщено
- %d записів успішно скопійовано, та %d не вдалося скопіювати
- %d записів успішно переміщено, та %d не вдалося перемістити
+ %1$d записів успішно скопійовано, та %2$d не вдалося скопіювати
+ %1$d записів успішно переміщено, та %2$d не вдалося перемістити
%s скопійовано в папку \'Downloads\'
%s переміщено в приватну папку додатка
@@ -142,6 +142,11 @@
Файловий менеджер
Приватний каталог
Публічний каталог
+ Місце зберігання
+ Внутрішнє сховище
+ SD-карта
+ Користувацький шлях
+ SD-карта (недоступна)
Є в додатку
Не знайдено в додатку
В кошику
@@ -242,6 +247,9 @@
Помилка при запису
Не вдалося відновити запис!
Не вдалося видалити запис!
+ Не вдалося відкрити вибір папки. Ваш пристрій може не підтримувати цю функцію.
+ Неможливо записати в обрану папку. Будь ласка, виберіть інше місце.
+ Формат WAV не підтримується при збереженні на SD-карту. Будь ласка, використовуйте формат M4A або 3GP, або перейдіть на внутрішнє сховище.
Потрібно перемістити записи з публічної папки
Пізніше
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 80c714e82..bcf94747c 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -81,8 +81,8 @@
- 已刪除 %d 則錄音
- 已刪除 %d 則錄音
- 已複製 %d 則錄音,%d 個失敗
- 已複製 %d 則錄音,%d 個失敗
+ 已複製 %1$d 則錄音,%2$d 個失敗
+ 已複製 %1$d 則錄音,%2$d 個失敗
「%s」已複製到 Download 資料夾
「%s」已移動到應用程式專用資料夾
@@ -131,6 +131,11 @@
檔案瀏覽
專用資料夾
共用資料夾
+ 儲存位置
+ 內部儲存空間
+ SD卡
+ 自訂路徑
+ SD卡(無法使用)
已在錄音機內
不在錄音機內
位於垃圾桶
@@ -223,6 +228,9 @@
錄製發生錯誤
還原錄音失敗
刪除錄音失敗
+ 無法開啟資料夾選擇器。您的裝置可能不支援此功能。
+ 無法寫入所選資料夾。請選擇其他位置。
+ 儲存至SD卡時不支援WAV格式。請使用M4A或3GP格式,或切換至內部儲存空間。
需要移動共用儲存空間中的錄音
下次
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 054f4da39..d467f4a49 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -75,8 +75,8 @@
- %d record moved successfully
- %d records moved successfully
- %d records copied successfully and %d failed to copy
- %d records moved successfully and %d failed to move
+ %1$d records copied successfully and %2$d failed to copy
+ %1$d records moved successfully and %2$d failed to move
%s 已保存到“下载”目录
%s 已移动到应用的专用目录
@@ -124,6 +124,11 @@
文件浏览器
私有目录
公共目录
+ 存储位置
+ 内部存储
+ SD卡
+ 自定义路径
+ SD卡(不可用)
在app中
在app中找不到
在回收站
@@ -223,6 +228,9 @@
录制错误!
无法恢复录音!
无法删除录音!
+ 无法打开文件夹选择器。您的设备可能不支持此功能。
+ 无法写入所选文件夹。请选择其他位置。
+ 保存到SD卡时不支持WAV格式。请使用M4A或3GP格式,或切换到内部存储。
Move public storage records is needed
Later
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8d6f09acc..eb91dea03 100755
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -79,8 +79,8 @@
- %d record moved successfully
- %d records moved successfully
- %d records copied successfully and %d failed to copy
- %d records moved successfully and %d failed to move
+ %1$d records copied successfully and %2$d failed to copy
+ %1$d records moved successfully and %2$d failed to move
%s copied to the \'Downloads\' directory
%s moved to the app\'s private directory
@@ -120,14 +120,20 @@
Created:
File location:
Naming:
- Records will be stored in the app\'s private directory which is not visible for other apps
- Records will be stored in a common directory on your device which is visible for other apps
+ Records will be stored in the app\'s private directory which is not visible for other apps. Warning: Files will be deleted if the app is uninstalled.
+ Records will be stored in a common directory on your device which is visible for other apps and will survive app uninstall
+ Warning: Files will be deleted if the app is uninstalled
Lost records
Trash
The records moved to the trash will be automatically removed forever in 60 days.
File browser
Private directory
Public directory
+ Storage location
+ Internal storage
+ SD card
+ Custom path
+ SD card (unavailable)
Found in the app
Not found in the app
In the trash
@@ -206,7 +212,16 @@
Stereo
Mono
Records location:\n%s
+ Change recordings directory
+ Provide a folder path (absolute or relative to internal storage) where new recordings should be stored.
+ Enable public storage before changing the directory.
+ Recordings directory reset to default.
+ Can\'t use that folder. Check that it exists and that the app has storage permission.
+ Store recordings on SD card (app folder)
+ SD card storage isn\'t available right now.
This feature is no longer available in Android 10 or higher.
+ Unable to open folder picker. Your device may not support this feature.
+ Cannot write to selected folder. Please choose a different location.
%s Mb
Move %d records
@@ -228,6 +243,7 @@
Recording error!
Failed to restore the record!
Failed to delete the record!
+ WAV format is not supported when saving to SD card. Please use M4A or 3GP format, or switch to internal storage.
Move public storage records is needed
Later