44
55import android .content .Context ;
66import android .content .SharedPreferences ;
7+ import android .net .Uri ;
78import android .os .Build ;
89import android .os .storage .StorageManager ;
910import android .os .storage .StorageVolume ;
11+ import android .provider .DocumentsContract ;
1012import android .util .Log ;
1113
1214import 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