Skip to content

Commit 2d50a47

Browse files
committed
new: add SAF support for SD card recording on Android 10+
Implement Storage Access Framework (SAF) to allow saving recordings to SD card public directories on Android 10, where traditional file APIs are blocked by Scoped Storage. Key changes: - Add ``RecordingTarget`` abstraction for File vs SAF URI targets - Add storage location selector in Settings (Internal/SD card/Custom) - Launch SAF folder picker when SD card is selected - Use ``DocumentsContract.createDocument()`` + ``FileDescriptor`` for recording - Add ``WavSafNotSupportedException`` - WAV format unsupported with SAF (requires ``RandomAccessFile`` for header updates) - Fix resource leak in ``RecordingTarget.openForWriting()`` - Fix null checks in ``AppRecorderImpl`` timer methods - Add translations for 8 new strings in all 9 locales Recordings saved via SAF survive app uninstall, achieving the goal of persistent SD card storage on Android 10.
1 parent 86c8696 commit 2d50a47

44 files changed

Lines changed: 1674 additions & 182 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,30 @@
4949

5050
# FAQ
5151
### <b>When option to choose recording directory will be added?</b>
52-
<p>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.</p>
52+
<p>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.</p>
53+
54+
## Android 10 Storage Changes (Local Fork)
55+
56+
This fork enables public directory storage on Android 10 using the legacy storage API.
57+
58+
**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.
59+
60+
**Changes made:**
61+
62+
1. `AndroidManifest.xml`:
63+
- Added `android:requestLegacyExternalStorage="true"` to opt out of Scoped Storage
64+
- Extended storage permissions to API 29 (`maxSdkVersion="29"`)
65+
66+
2. `SettingsActivity.java`:
67+
- Removed Android Q block that hid the public directory toggle
68+
69+
3. `PrefsImpl.java`:
70+
- Removed Android Q block in `firstRunExecuted()` that disabled directory settings
71+
72+
4. `MainActivity.java`:
73+
- Removed Android Q block in `onStart()` that forced private storage on every launch
74+
75+
**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.
5376

5477
### License
5578

app/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ android {
7474
targetCompatibility = JavaVersion.VERSION_17
7575
}
7676

77+
kotlinOptions {
78+
jvmTarget = '17'
79+
}
80+
7781
lintOptions {
7882
abortOnError false
7983
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.dimowner.audiorecorder.app.main;
2+
3+
import static androidx.test.espresso.Espresso.onView;
4+
import static androidx.test.espresso.action.ViewActions.click;
5+
import static androidx.test.espresso.matcher.ViewMatchers.withId;
6+
import static org.junit.Assert.assertEquals;
7+
8+
import android.Manifest;
9+
import android.content.Context;
10+
import android.content.Intent;
11+
12+
import androidx.test.core.app.ApplicationProvider;
13+
import androidx.test.ext.junit.runners.AndroidJUnit4;
14+
import androidx.test.filters.LargeTest;
15+
import androidx.test.platform.app.InstrumentationRegistry;
16+
import androidx.test.rule.GrantPermissionRule;
17+
import androidx.test.core.app.ActivityScenario;
18+
19+
import com.dimowner.audiorecorder.ARApplication;
20+
import com.dimowner.audiorecorder.R;
21+
import com.dimowner.audiorecorder.app.RecordingService;
22+
import com.dimowner.audiorecorder.data.Prefs;
23+
24+
import org.junit.Before;
25+
import org.junit.After;
26+
import org.junit.Rule;
27+
import org.junit.Test;
28+
import org.junit.runner.RunWith;
29+
30+
@RunWith(AndroidJUnit4.class)
31+
@LargeTest
32+
public class MainActivityRecordingTest {
33+
34+
@Rule
35+
public final GrantPermissionRule permissionRule =
36+
GrantPermissionRule.grant(
37+
Manifest.permission.RECORD_AUDIO,
38+
Manifest.permission.WRITE_EXTERNAL_STORAGE,
39+
Manifest.permission.READ_EXTERNAL_STORAGE);
40+
41+
private ActivityScenario<MainActivity> scenario;
42+
43+
@Before
44+
public void setUp() {
45+
Context context = ApplicationProvider.getApplicationContext();
46+
Prefs prefs = ARApplication.getInjector().providePrefs(context);
47+
prefs.firstRunExecuted();
48+
prefs.setStoreDirPublic(false);
49+
scenario = ActivityScenario.launch(MainActivity.class);
50+
}
51+
52+
@After
53+
public void tearDown() {
54+
if (scenario != null) {
55+
scenario.close();
56+
}
57+
Context context = ApplicationProvider.getApplicationContext();
58+
context.stopService(new Intent(context, RecordingService.class));
59+
}
60+
61+
@Test
62+
public void recordButtonDoesNotAllocateNewFileWhenPausing() {
63+
final long initialCounter = readRecordCounter();
64+
65+
onView(withId(R.id.btn_record)).perform(click());
66+
waitForIdle();
67+
assertEquals(initialCounter + 1, readRecordCounter());
68+
69+
onView(withId(R.id.btn_record)).perform(click()); // Pause
70+
waitForIdle();
71+
assertEquals("Second tap should not allocate a new record file",
72+
initialCounter + 1, readRecordCounter());
73+
74+
onView(withId(R.id.btn_record_stop)).perform(click());
75+
waitForIdle();
76+
77+
onView(withId(R.id.btn_record)).perform(click()); // Start a fresh session
78+
waitForIdle();
79+
assertEquals(initialCounter + 2, readRecordCounter());
80+
}
81+
82+
private void waitForIdle() {
83+
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
84+
}
85+
86+
private long readRecordCounter() {
87+
Context context = ApplicationProvider.getApplicationContext();
88+
Prefs prefs = ARApplication.getInjector().providePrefs(context);
89+
return prefs.getRecordCounter();
90+
}
91+
}

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
33

44
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
5-
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
6-
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
5+
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
6+
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
77
<uses-permission android:name="android.permission.RECORD_AUDIO" />
88
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
99
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -31,6 +31,7 @@
3131
android:hardwareAccelerated="@bool/useHardwareAcceleration"
3232
android:icon="@mipmap/audio_recorder_logo"
3333
android:label="@string/app_name"
34+
android:requestLegacyExternalStorage="true"
3435
android:roundIcon="@mipmap/audio_recorder_logo"
3536
android:theme="@style/AppTheme">
3637
<receiver
@@ -112,7 +113,7 @@
112113
android:exported="false"
113114
android:foregroundServiceType="dataSync" />
114115

115-
<receiver android:name=".WidgetReceiver" />
116+
<receiver android:name=".WidgetReceiver" android:exported="true" />
116117
<receiver android:name=".app.RecordingService$StopRecordingReceiver" />
117118
<receiver android:name=".app.PlaybackService$StopPlaybackReceiver" />
118119
<receiver android:name=".app.DownloadService$StopDownloadReceiver" />

app/src/main/java/com/dimowner/audiorecorder/Injector.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ public RecordsContract.UserActionsListener provideRecordsPresenter(Context conte
201201

202202
public SettingsContract.UserActionsListener provideSettingsPresenter(Context context) {
203203
if (settingsPresenter == null) {
204-
settingsPresenter = new SettingsPresenter(provideLocalRepository(context), provideFileRepository(context),
204+
settingsPresenter = new SettingsPresenter(context, provideLocalRepository(context), provideFileRepository(context),
205205
provideRecordingTasksQueue(), provideLoadingTasksQueue(), providePrefs(context),
206206
provideSettingsMapper(context), provideAppRecorder(context));
207207
}

app/src/main/java/com/dimowner/audiorecorder/RecordingWidget.kt

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.dimowner.audiorecorder
22

3+
import android.Manifest
34
import android.annotation.SuppressLint
45
import android.app.PendingIntent
56
import android.appwidget.AppWidgetManager
@@ -8,8 +9,17 @@ import android.content.BroadcastReceiver
89
import android.content.Context
910
import android.content.Intent
1011
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
12+
import android.content.pm.PackageManager
13+
import android.os.Build
1114
import android.widget.RemoteViews
15+
import android.widget.Toast
16+
import androidx.core.content.ContextCompat
17+
import com.dimowner.audiorecorder.app.RecordingService
1218
import com.dimowner.audiorecorder.app.TransparentRecordingActivity
19+
import com.dimowner.audiorecorder.data.RecordingTarget
20+
import com.dimowner.audiorecorder.exception.CantCreateFileException
21+
import com.dimowner.audiorecorder.exception.ErrorParser
22+
import timber.log.Timber
1323

1424
class RecordingWidget : AppWidgetProvider() {
1525
override fun onUpdate(
@@ -52,6 +62,49 @@ private fun getRecordingPendingIntent(context: Context): PendingIntent {
5262

5363
class WidgetReceiver : BroadcastReceiver() {
5464
override fun onReceive(context: Context, intent: Intent) {
65+
val fileRepository = ARApplication.injector.provideFileRepository(context)
66+
67+
// Check permissions first
68+
if (!hasRecordingPermissions(context)) {
69+
launchTransparentActivity(context)
70+
return
71+
}
72+
73+
try {
74+
val target = fileRepository.provideRecordingTarget(context)
75+
startRecordingService(context, target)
76+
} catch (e: CantCreateFileException) {
77+
Timber.e(e, "Failed to create recording file from widget")
78+
Toast.makeText(context, ErrorParser.parseException(e), Toast.LENGTH_LONG).show()
79+
}
80+
}
81+
82+
private fun hasRecordingPermissions(context: Context): Boolean {
83+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
84+
if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
85+
return false
86+
}
87+
}
88+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
89+
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
90+
return false
91+
}
92+
}
93+
return true
94+
}
95+
96+
private fun startRecordingService(context: Context, target: RecordingTarget) {
97+
val startIntent = Intent(context, RecordingService::class.java).apply {
98+
action = RecordingService.ACTION_START_RECORDING_SERVICE
99+
putExtra(RecordingService.EXTRAS_KEY_RECORD_PATH, target.path)
100+
if (target.isSaf) {
101+
putExtra(RecordingService.EXTRAS_KEY_SAF_URI, target.safUri.toString())
102+
}
103+
}
104+
ContextCompat.startForegroundService(context, startIntent)
105+
}
106+
107+
private fun launchTransparentActivity(context: Context) {
55108
val activityIntent = Intent(context, TransparentRecordingActivity::class.java)
56109
activityIntent.flags = FLAG_ACTIVITY_NEW_TASK
57110
context.startActivity(activityIntent)

app/src/main/java/com/dimowner/audiorecorder/app/AppRecorder.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616

1717
package com.dimowner.audiorecorder.app;
1818

19+
import android.content.Context;
20+
1921
import com.dimowner.audiorecorder.IntArrayList;
2022
import com.dimowner.audiorecorder.audio.recorder.RecorderContract;
23+
import com.dimowner.audiorecorder.data.RecordingTarget;
2124

2225
import java.io.File;
2326

@@ -27,6 +30,12 @@ public interface AppRecorder {
2730
void removeRecordingCallback(AppRecorderCallback recorderCallback);
2831
void setRecorder(RecorderContract.Recorder recorder);
2932
void startRecording(String filePath, int channelCount, int sampleRate, int bitrate);
33+
34+
/**
35+
* Start recording to a RecordingTarget (supports both File and SAF).
36+
*/
37+
void startRecording(Context context, RecordingTarget target, int channelCount, int sampleRate, int bitrate);
38+
3039
void pauseRecording();
3140
void resumeRecording();
3241
void stopRecording();
@@ -35,5 +44,11 @@ public interface AppRecorder {
3544
boolean isRecording();
3645
boolean isPaused();
3746
File getRecordFile();
47+
48+
/**
49+
* Get the current recording target (may be SAF-based).
50+
*/
51+
RecordingTarget getRecordingTarget();
52+
3853
void release();
3954
}

0 commit comments

Comments
 (0)