Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,30 @@

# FAQ
### <b>When option to choose recording directory will be added?</b>
<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>
<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>

## 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

Expand Down
4 changes: 4 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = '17'
}

lintOptions {
abortOnError false
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MainActivity> 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();
}
}
7 changes: 4 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
Expand Down Expand Up @@ -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">
<receiver
Expand Down Expand Up @@ -112,7 +113,7 @@
android:exported="false"
android:foregroundServiceType="dataSync" />

<receiver android:name=".WidgetReceiver" />
<receiver android:name=".WidgetReceiver" android:exported="true" />
<receiver android:name=".app.RecordingService$StopRecordingReceiver" />
<receiver android:name=".app.PlaybackService$StopPlaybackReceiver" />
<receiver android:name=".app.DownloadService$StopDownloadReceiver" />
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/dimowner/audiorecorder/Injector.java
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
53 changes: 53 additions & 0 deletions app/src/main/java/com/dimowner/audiorecorder/RecordingWidget.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.dimowner.audiorecorder

import android.Manifest
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/com/dimowner/audiorecorder/app/AppRecorder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
Expand All @@ -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();
}
Loading