Skip to content

Commit c4612e2

Browse files
jamakr4codex
andcommitted
feat(dashcam): add OEM USB storage picker
Let users choose a USB drive or folder through the Android system picker and persist the selected volume and relative path. Keep recording on the existing native file-based pipeline without introducing the fork's copy job. Co-authored-by: Codex <codex@openai.com>
1 parent 3b25a89 commit c4612e2

7 files changed

Lines changed: 265 additions & 12 deletions

File tree

‎app/src/main/java/com/drivehub/kamera/MainActivity.java‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import android.content.Intent;
1717
import android.content.IntentFilter;
1818
import android.content.SharedPreferences;
19+
import android.net.Uri;
1920
import android.os.Bundle;
2021
import android.view.MotionEvent;
2122
import android.view.SurfaceHolder;
@@ -24,6 +25,7 @@
2425
import android.widget.Button;
2526
import android.widget.ImageButton;
2627
import android.widget.TextView;
28+
import android.widget.Toast;
2729

2830
import androidx.activity.EdgeToEdge;
2931
import androidx.appcompat.app.AppCompatActivity;
@@ -35,6 +37,7 @@
3537
public class MainActivity extends AppCompatActivity implements SurfaceHolder.Callback {
3638

3739
private static final int SWIPE_THRESHOLD_PX = 140;
40+
private static final int REQ_DASHCAM_USB_FOLDER = 1842;
3841

3942
private SurfaceHolder surfaceHolder;
4043
private TextView tvStatus;
@@ -101,6 +104,47 @@ public static boolean shouldBlockOverlay() {
101104
return sMainVisible && !sSettingsDialogOpen;
102105
}
103106

107+
@SuppressWarnings("deprecation")
108+
public void openDashcamUsbFolderPicker() {
109+
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
110+
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
111+
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
112+
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
113+
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
114+
try {
115+
startActivityForResult(intent, REQ_DASHCAM_USB_FOLDER);
116+
} catch (Throwable t) {
117+
Toast.makeText(this, R.string.settings_dashcam_storage_select_failed, Toast.LENGTH_LONG).show();
118+
}
119+
}
120+
121+
@Override
122+
@SuppressWarnings("deprecation")
123+
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
124+
super.onActivityResult(requestCode, resultCode, data);
125+
if (requestCode != REQ_DASHCAM_USB_FOLDER
126+
|| resultCode != RESULT_OK
127+
|| data == null) {
128+
return;
129+
}
130+
Uri treeUri = data.getData();
131+
if (treeUri == null) {
132+
Toast.makeText(this, R.string.settings_dashcam_storage_select_failed, Toast.LENGTH_LONG).show();
133+
return;
134+
}
135+
int takeFlags = data.getFlags()
136+
& (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
137+
if (takeFlags != 0) {
138+
try {
139+
getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
140+
} catch (SecurityException ignored) {
141+
// The selected mount is used through its native file path. Persisting the URI
142+
// permission is helpful on stock Android but not required on the system image.
143+
}
144+
}
145+
settingsDialog.onDashcamUsbTreeSelected(treeUri);
146+
}
147+
104148
@SuppressLint("ClickableViewAccessibility")
105149
@Override
106150
protected void onCreate(Bundle savedInstanceState) {
@@ -198,6 +242,9 @@ protected void onCreate(Bundle savedInstanceState) {
198242
protected void onStart() {
199243
super.onStart();
200244
sMainVisible = true;
245+
if (settingsDialog.isOpen()) {
246+
sSettingsDialogOpen = true;
247+
}
201248
try {
202249
ContextCompat.registerReceiver(
203250
this,

‎app/src/main/java/com/drivehub/kamera/dashcam/DashcamSettingsController.java‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import android.graphics.drawable.ColorDrawable;
2020
import android.graphics.drawable.GradientDrawable;
2121
import android.content.pm.PackageManager;
22+
import android.net.Uri;
2223
import android.os.Handler;
2324
import android.os.Looper;
2425
import android.text.Editable;
@@ -116,17 +117,19 @@ public static final class StorageViews {
116117
public final SegmentedControl targetGroup;
117118
public final TextView statusText;
118119
public final TextView activePathText;
120+
public final Button selectButton;
119121
public final Button ejectButton;
120122
public final TextView internalWarningText;
121123
public final EditText usbClipCount;
122124
public final EditText usbEventDirs;
123125

124126
public StorageViews(SegmentedControl targetGroup, TextView statusText,
125-
TextView activePathText, Button ejectButton, TextView internalWarningText,
126-
EditText usbClipCount, EditText usbEventDirs) {
127+
TextView activePathText, Button selectButton, Button ejectButton,
128+
TextView internalWarningText, EditText usbClipCount, EditText usbEventDirs) {
127129
this.targetGroup = targetGroup;
128130
this.statusText = statusText;
129131
this.activePathText = activePathText;
132+
this.selectButton = selectButton;
130133
this.ejectButton = ejectButton;
131134
this.internalWarningText = internalWarningText;
132135
this.usbClipCount = usbClipCount;
@@ -159,6 +162,8 @@ public BannerGroupViews(Switch toggle, SegmentedControl sizeGroup,
159162
private boolean syncingEnabled;
160163
private boolean syncingCameraSelection;
161164
private BroadcastReceiver usbEjectReceiver;
165+
private SharedPreferences storagePrefs;
166+
private StorageViews boundStorageViews;
162167

163168
public DashcamSettingsController(MainActivity activity) {
164169
this.activity = activity;
@@ -329,6 +334,8 @@ private void bindStorageGroup(SharedPreferences prefs, StorageViews views) {
329334
if (views == null) {
330335
return;
331336
}
337+
storagePrefs = prefs;
338+
boundStorageViews = views;
332339
if (views.targetGroup != null && views.targetGroup.getChildCount() >= 3) {
333340
int initialTarget = DashcamStorageManager.getStorageTarget(prefs);
334341
views.targetGroup.check(views.targetGroup.getChildAt(initialTarget).getId());
@@ -379,9 +386,39 @@ public void afterTextChanged(Editable s) {
379386
if (views.ejectButton != null) {
380387
views.ejectButton.setOnClickListener(v -> showUsbEjectConfirmDialog());
381388
}
389+
if (views.selectButton != null) {
390+
views.selectButton.setOnClickListener(v -> activity.openDashcamUsbFolderPicker());
391+
}
382392
refreshStorageStatus(prefs, views);
383393
}
384394

395+
/** Applies the document-tree selection as a native USB mount location, without a copy job. */
396+
public void onUsbTreeSelected(Uri treeUri) {
397+
if (!DashcamStorageManager.setSelectedUsbTree(activity, treeUri)) {
398+
Toast.makeText(activity, R.string.settings_dashcam_storage_select_usb_required,
399+
Toast.LENGTH_LONG).show();
400+
return;
401+
}
402+
SharedPreferences prefs = storagePrefs != null ? storagePrefs : UiPrefs.getPrefs(activity);
403+
DashcamStorageManager.setStorageTarget(prefs, DashcamStorageManager.TARGET_USB_ONLY);
404+
405+
boolean selectionDispatched = false;
406+
StorageViews views = boundStorageViews;
407+
if (views != null && views.targetGroup != null && views.targetGroup.getChildCount() >= 3) {
408+
int usbId = views.targetGroup.getChildAt(DashcamStorageManager.TARGET_USB_ONLY).getId();
409+
selectionDispatched = views.targetGroup.getCheckedId() != usbId;
410+
views.targetGroup.check(usbId);
411+
}
412+
if (!selectionDispatched && views != null) {
413+
refreshStorageStatus(prefs, views);
414+
}
415+
if (prefs.getBoolean(KEY_ENABLED, false) && !RecordingService.isRunning()) {
416+
RecordingService.startIfDashcamEnabled(activity);
417+
}
418+
Toast.makeText(activity, R.string.settings_dashcam_storage_select_success,
419+
Toast.LENGTH_SHORT).show();
420+
}
421+
385422
// ---------- Storage status ----------
386423

387424
// Runs a USB write test on a worker thread and updates the status label + active path.
@@ -493,6 +530,8 @@ public void onReceive(Context context, Intent intent) {
493530
// Called when the settings dialog closes — unregisters the USB eject receiver if still pending.
494531
public void onDismiss() {
495532
unregisterUsbEjectReceiver();
533+
storagePrefs = null;
534+
boundStorageViews = null;
496535
}
497536

498537
private void unregisterUsbEjectReceiver() {

‎app/src/main/java/com/drivehub/kamera/dashcam/DashcamStorageManager.java‎

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
import android.content.Context;
66
import android.content.SharedPreferences;
7+
import android.net.Uri;
78
import android.os.Build;
89
import android.os.storage.StorageManager;
910
import android.os.storage.StorageVolume;
11+
import android.provider.DocumentsContract;
1012
import android.util.Log;
1113

1214
import java.io.File;
@@ -39,6 +41,8 @@ public final class DashcamStorageManager {
3941
public static final int TARGET_INTERNAL_ONLY = 2;
4042

4143
public static final String KEY_STORAGE_TARGET = "dashcamStorageTarget";
44+
private static final String KEY_USB_VOLUME_ID = "dashcamUsbVolumeId";
45+
private static final String KEY_USB_RELATIVE_PATH = "dashcamUsbRelativePath";
4246
private static final String KEY_USB_RETENTION_CLIP_COUNT = "dashcamUsbRetentionClipCount";
4347
private static final String KEY_USB_MAX_RETAINED_EVENT_DIRS = "dashcamUsbMaxRetainedEventDirs";
4448

@@ -94,6 +98,32 @@ public static void setStorageTarget(SharedPreferences prefs, int target) {
9498
prefs.edit().putInt(KEY_STORAGE_TARGET, clampTarget(target)).apply();
9599
}
96100

101+
/**
102+
* Remembers a USB folder selected through Android's document-tree UI. Recording continues to
103+
* use the native file path; the URI is only translated into the volume id and relative path.
104+
*/
105+
public static boolean setSelectedUsbTree(Context context, Uri treeUri) {
106+
if (context == null || treeUri == null) {
107+
return false;
108+
}
109+
final String documentId;
110+
try {
111+
documentId = DocumentsContract.getTreeDocumentId(treeUri);
112+
} catch (Throwable t) {
113+
Log.w(TAG, "Could not read selected USB document id", t);
114+
return false;
115+
}
116+
SelectedUsbLocation location = parseSelectedUsbLocation(documentId);
117+
if (location == null) {
118+
return false;
119+
}
120+
UiPrefs.getPrefs(context).edit()
121+
.putString(KEY_USB_VOLUME_ID, location.volumeId)
122+
.putString(KEY_USB_RELATIVE_PATH, location.relativePath)
123+
.apply();
124+
return true;
125+
}
126+
97127
public static int clampTarget(int target) {
98128
return Math.max(TARGET_AUTO, Math.min(TARGET_INTERNAL_ONLY, target));
99129
}
@@ -199,6 +229,74 @@ private static final class UsbCandidate {
199229
}
200230
}
201231

232+
private static final class SelectedUsbLocation {
233+
final String volumeId;
234+
final String relativePath;
235+
236+
SelectedUsbLocation(String volumeId, String relativePath) {
237+
this.volumeId = volumeId;
238+
this.relativePath = relativePath;
239+
}
240+
}
241+
242+
private static SelectedUsbLocation getSelectedUsbLocation(SharedPreferences prefs) {
243+
if (prefs == null) {
244+
return null;
245+
}
246+
String volumeId = prefs.getString(KEY_USB_VOLUME_ID, null);
247+
if (volumeId == null || volumeId.trim().isEmpty()) {
248+
return null;
249+
}
250+
String relativePath = prefs.getString(KEY_USB_RELATIVE_PATH, "");
251+
return parseSelectedUsbLocation(volumeId.trim() + ":"
252+
+ (relativePath == null ? "" : relativePath));
253+
}
254+
255+
private static SelectedUsbLocation parseSelectedUsbLocation(String documentId) {
256+
if (documentId == null) {
257+
return null;
258+
}
259+
int separator = documentId.indexOf(':');
260+
String volumeId = (separator >= 0 ? documentId.substring(0, separator) : documentId).trim();
261+
String relativePath = separator >= 0 ? documentId.substring(separator + 1).trim() : "";
262+
if (volumeId.isEmpty()
263+
|| "primary".equalsIgnoreCase(volumeId)
264+
|| "home".equalsIgnoreCase(volumeId)
265+
|| volumeId.contains("/")
266+
|| volumeId.contains("\\")
267+
|| ".".equals(volumeId)
268+
|| "..".equals(volumeId)) {
269+
return null;
270+
}
271+
relativePath = normalizeRelativePath(relativePath);
272+
if (relativePath == null) {
273+
return null;
274+
}
275+
return new SelectedUsbLocation(volumeId, relativePath);
276+
}
277+
278+
private static String normalizeRelativePath(String path) {
279+
if (path == null || path.isEmpty()) {
280+
return "";
281+
}
282+
String normalized = path.replace('\\', '/');
283+
while (normalized.startsWith("/")) {
284+
normalized = normalized.substring(1);
285+
}
286+
while (normalized.endsWith("/")) {
287+
normalized = normalized.substring(0, normalized.length() - 1);
288+
}
289+
if (normalized.isEmpty()) {
290+
return "";
291+
}
292+
for (String segment : normalized.split("/")) {
293+
if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
294+
return null;
295+
}
296+
}
297+
return normalized;
298+
}
299+
202300
/**
203301
* Where to try writing on one volume, best first.
204302
*
@@ -215,8 +313,20 @@ private static final class UsbCandidate {
215313
* clips at the root of the stick where a person plugging it into a computer will look. The
216314
* sandbox is the fallback that always works, at the cost of being deleted on uninstall.
217315
*/
218-
private static List<File> recordsDirCandidates(UsbCandidate candidate) {
316+
private static List<File> recordsDirCandidates(
317+
UsbCandidate candidate, SelectedUsbLocation selectedLocation) {
219318
List<File> dirs = new ArrayList<>();
319+
if (selectedLocation != null) {
320+
File rawBase = new File(MEDIA_RW_ROOT, selectedLocation.volumeId);
321+
File exposedBase = candidate.rootDir;
322+
if (!selectedLocation.relativePath.isEmpty()) {
323+
rawBase = new File(rawBase, selectedLocation.relativePath);
324+
exposedBase = new File(exposedBase, selectedLocation.relativePath);
325+
}
326+
dirs.add(new File(rawBase, USB_RECORDS_DIR_NAME));
327+
dirs.add(new File(exposedBase, USB_RECORDS_DIR_NAME));
328+
return dirs;
329+
}
220330
if (!candidate.volumeId.isEmpty()) {
221331
dirs.add(new File(new File(MEDIA_RW_ROOT, candidate.volumeId), USB_RECORDS_DIR_NAME));
222332
}
@@ -234,8 +344,19 @@ private static List<File> recordsDirCandidates(UsbCandidate candidate) {
234344
* there is exactly one such candidate.
235345
*/
236346
private static UsbProbe probeUsb(Context context) {
347+
SelectedUsbLocation selectedLocation = getSelectedUsbLocation(UiPrefs.getPrefs(context));
237348
List<UsbCandidate> candidates = findUsbCandidates(context);
238-
if (candidates.isEmpty()) {
349+
if (selectedLocation != null) {
350+
candidates = filterSelectedVolume(candidates, selectedLocation.volumeId);
351+
if (candidates.isEmpty()) {
352+
candidates = filterSelectedVolume(
353+
findLegacyStorageCandidates(), selectedLocation.volumeId);
354+
}
355+
if (candidates.isEmpty()) {
356+
Log.i(TAG, "Selected USB volume is not mounted: " + selectedLocation.volumeId);
357+
return new UsbProbe(UsbState.NO_MEDIUM, null);
358+
}
359+
} else if (candidates.isEmpty()) {
239360
// AAOS 9 head units typically don't surface OTG/USB volumes via StorageManager:
240361
// the OS mounts them under /mnt/media_rw/... without registering them with the
241362
// MediaProvider, so getExternalFilesDirs() returns nothing usable. Fall back to a
@@ -250,12 +371,14 @@ private static UsbProbe probeUsb(Context context) {
250371
if (candidates.isEmpty()) {
251372
return new UsbProbe(UsbState.NO_MEDIUM, null);
252373
}
253-
List<UsbCandidate> prioritized = prioritizeUsbCandidates(candidates);
374+
List<UsbCandidate> prioritized = selectedLocation == null
375+
? prioritizeUsbCandidates(candidates)
376+
: candidates;
254377
List<File> usable = new ArrayList<>();
255378
boolean anyWriteTestFailed = false;
256379
for (UsbCandidate candidate : prioritized) {
257380
File accepted = null;
258-
for (File recordsDir : recordsDirCandidates(candidate)) {
381+
for (File recordsDir : recordsDirCandidates(candidate, selectedLocation)) {
259382
boolean existed = recordsDir.isDirectory();
260383
if (!existed && !recordsDir.mkdirs() && !recordsDir.isDirectory()) {
261384
// Logged rather than skipped silently: without this a NOT_WRITABLE verdict
@@ -299,6 +422,18 @@ private static UsbProbe probeUsb(Context context) {
299422
anyWriteTestFailed ? UsbState.WRITE_TEST_FAILED : UsbState.NOT_WRITABLE, null);
300423
}
301424

425+
private static List<UsbCandidate> filterSelectedVolume(
426+
List<UsbCandidate> candidates, String selectedVolumeId) {
427+
List<UsbCandidate> selected = new ArrayList<>();
428+
for (UsbCandidate candidate : candidates) {
429+
if (candidate.volumeId.equalsIgnoreCase(selectedVolumeId)
430+
|| candidate.rootDir.getName().equalsIgnoreCase(selectedVolumeId)) {
431+
selected.add(candidate);
432+
}
433+
}
434+
return selected;
435+
}
436+
302437
/**
303438
* Returns removable, non-primary storage roots known to the framework for this app. We start
304439
* from app-visible external dirs so we only consider mounted media the process can actually

0 commit comments

Comments
 (0)