diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..be5965c --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,119 @@ +# Migrating from SimpleStorage 2.x to 3.0 + +Version 3.0 introduces a single abstraction over Android's three file worlds and one vocabulary +for every long-running operation. The 2.x API keeps compiling throughout the 3.x cycle (parts of +it as `@Deprecated`), so you can migrate incrementally. + +Platform changes: + +| | 2.x | 3.0 | +|---|---|---| +| minSdk | 23 | **26** | +| compileSdk / targetSdk | 36 | **37** (Android 17) | +| AGP / Gradle (to build this repo) | 8.13 / 8.14 | 9.2 / 9.4 | + +## The one-minute overview + +```kotlin +// 2.x +ioScope.launch { + file.copyFileTo(context, targetFolder, + onConflict = object : SingleFileConflictCallback(uiScope) { + override fun onFileConflict(destFile: DocumentFile, action: FileConflictAction) { + action.confirmResolution(ConflictResolution.REPLACE) + } + } + ).collect { result -> when (result) { /* 8 branches */ } } +} + +// 3.0 — from any thread, no scope juggling +val result = file.copyTo(targetFolder) { + onConflict { ConflictResolution.REPLACE } + onProgress { progressBar.progress = it.percent.toInt() } +} +when (result) { + is TransferResult.Success -> toast("Copied ${result.result.name}") + is TransferResult.Failure -> log(result.errorCode, result.cause) +} +``` + +## API mapping + +### Obtaining files + +| 2.x | 3.0 | +|---|---| +| `DocumentFileCompat.fromUri(context, uri)` | `StorageFile.from(context, uri)` | +| `DocumentFileCompat.fromFile(context, file)` | `StorageFile.from(context, file)` | +| `DocumentFileCompat.fromFullPath(context, path)` | `StorageFile.fromPath(context, absolutePath)` | +| `DocumentFileCompat.fromSimplePath(context, storageId, basePath)` | `StorageFile.fromPath(context, StoragePath(storageId, basePath))` | +| `DocumentFileCompat.fromPublicFolder(context, type)` | `StorageFile.fromPublicDirectory(context, type)` | +| `MediaStoreCompat.fromMediaId(context, ...)` → `MediaFile` | `StorageFile.from(context, mediaUri)` | +| `FileFullPath(context, storageId, basePath)` | `StoragePath(storageId, basePath)` — no `Context` needed | + +`StorageFile` holds its `Context` internally: none of its members ask for one. `absolutePath` +returns `null` (not `""`) when a physical path cannot be resolved. Escape hatches: +`asDocumentFile()`, `asMediaFile()`, `asRawFile()`. + +### File operations + +| 2.x | 3.0 one-shot | 3.0 Flow | +|---|---|---| +| `DocumentFile.copyFileTo(context, target, …)` | `StorageFile.copyTo(target) { }` | `copyToAsFlow(target)` | +| `DocumentFile.moveFileTo(context, target, …)` | `StorageFile.moveTo(target) { }` | `moveToAsFlow(target)` | +| `DocumentFile.copyFolderTo/moveFolderTo(…)` | same `copyTo`/`moveTo` — folders are detected | same | +| `List.compressToZip(context, zip, …)` | `List.zipTo(zipFile) { }` | `zipToAsFlow(zipFile)` | +| `DocumentFile.decompressZip(context, folder, …)` | `StorageFile.unzipTo(folder) { }` | `unzipToAsFlow(folder)` | +| `DocumentFile.deleteRecursively(context)` | `StorageFile.deleteRecursively()` (suspend) | — | +| `DocumentFile.search(…)` | — | `StorageFile.search(…)` | + +Options that used to be positional parameters (`updateInterval`, `skipEmptyFiles`, +`fileDescription`, space checking) now live in the `TransferSpec` lambda. + +### Results + +| 2.x | 3.0 | +|---|---| +| `SingleFileResult` / `SingleFolderResult` / `MultipleFilesResult` / `ZipCompressionResult` / `ZipDecompressionResult` | `TransferEvent` (`PhaseChanged`, `Progress`, `Completed`) | +| `...Result.Completed(result: Any)` + casting | `TransferResult.Success` — typed | +| `...Result.Error(errorCode, message, cause)` | `TransferResult.Failure(errorCode, message, cause, partialStats)` | +| `writeSpeed: Int` (bytes per update interval) | `Progress.bytesPerSecond: Long` | + +### Conflict handling + +| 2.x | 3.0 | +|---|---| +| `object : SingleFileConflictCallback(uiScope) { override fun onFileConflict(...) { action.confirmResolution(...) } }` | `onConflict { conflict -> ConflictResolution.REPLACE }` | +| `SingleFolderConflictCallback.onParentConflict/onContentConflict` | same single resolver — receives `Conflict.TargetFolder(canMerge)` first, then a `Conflict.TargetFile` per conflicting child | + +The resolver is a `suspend` function: show a dialog with +`withContext(Dispatchers.Main) { … }` and simply return the answer. There is no `uiScope`, no +`GlobalScope` default, and no zombie-thread hazard. + +### Storage access & pickers (Views) + +| 2.x (`@Deprecated`) | 3.0 | +|---|---| +| `SimpleStorageHelper(activity)` + 4 callbacks + `onSaveInstanceState` + `onActivityResult` | `StorageAccessManager(activity)` — suspend functions, nothing to forward | +| `helper.requestStorageAccess()` + `onStorageAccessGranted` | `val access = manager.ensureAccess(StoragePath.primary("Documents"))` | +| `helper.openFolderPicker()` + `onFolderSelected` | `val result = manager.pickFolder()` | +| `helper.openFilePicker()` + `onFileSelected` | `val result = manager.pickFiles(allowMultiple = true)` | +| `helper.createFile(mimeType)` + `onFileCreated` | `val result = manager.createFile(mimeType)` | +| — | `manager.pickMedia()` — system Photo Picker, no permission needed | + +`StorageAccessManager` has no built-in dialogs: `ensureAccess` returns `WrongRootSelected` and you +decide how to explain and retry. If you want ready-made dialogs, keep using `SimpleStorageHelper` +until you migrate. + +### Compose + +Existing `rememberLauncherFor*` composables are unchanged. New in 3.0: +`rememberLauncherForMediaPicker(maxItems) { files -> … }` for the system Photo Picker. + +## Deprecation timeline + +| Phase | What happens | +|---|---| +| 3.0.0-alpha | `SimpleStorage`, `SimpleStorageHelper`, and the picker/access callback interfaces are `@Deprecated` | +| 3.0.0-rc | `DocumentFile`/`MediaFile` operation extensions become `@Deprecated`, delegating to the v3 engine | +| 4.0 | Deprecated 2.x API is removed | diff --git a/README.md b/README.md index 648e423..734886e 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,15 @@ allprojects { } ``` +### Version 3 (beta) + +Version `3.0.0-beta01` introduces a redesigned API: one [`StorageFile`](storage/src/main/java/com/anggrayudi/storage/StorageFile.kt) +abstraction over `DocumentFile`/`MediaFile`/`java.io.File`, one-shot suspend operations +(`copyTo`, `moveTo`, `zipTo`, `unzipTo`) with a unified `TransferResult`, suspend-lambda conflict +resolution, and [`StorageAccessManager`](storage/src/main/java/com/anggrayudi/storage/access/StorageAccessManager.kt) +replacing `SimpleStorageHelper`. It targets Android 17 (API 37) with minSdk 26. The 2.x API keeps +working during the 3.x cycle. Read the [migration guide](MIGRATION.md). + ### Java Compatibility Simple Storage is built in Kotlin. Follow this [documentation](JAVA_COMPATIBILITY.md) to use it in your Java project. diff --git a/V3_TEST_CASES.md b/V3_TEST_CASES.md new file mode 100644 index 0000000..140e5c7 --- /dev/null +++ b/V3_TEST_CASES.md @@ -0,0 +1,139 @@ +# SimpleStorage 3.0.0-alpha01 — On-Device Test Cases + +> Target: emulator API 36+, branch `release/3.0.0`. +> Priority tags: **[P0]** = known gap, blocks beta if broken. **[P1]** = core behavior. **[P2]** = best effort. +> Execution: implement Groups 1–6 as instrumented tests under `storage/src/androidTest/`, run with +> `./gradlew :storage:connectedDebugAndroidTest`. Group 7 is driven via adb on the sample app. +> Fill the **Status** column with PASS / FAIL / BLOCKED plus a short note. + +Notes for the implementer: +- Use `context.getExternalFilesDir(null)` (app-external storage) as the playground — no permission + or SAF grant is required there, and `getBasePath()` resolves correctly since it is under + `/storage/emulated/0`. +- Instrumented tests run on the instrumentation thread, so the main looper stays free for the + conflict-resolver adapters (this is exactly what JVM tests could not cover). +- `TransferSpec.checkAvailableSpace` can stay `true` on the emulator (real StatFs). +- Clean up created files/MediaStore rows in `@After`. + +## Group 1 — StorageFile factories & metadata + +| ID | Pri | Case | Steps | Expected | Status | +|----|-----|------|-------|----------|--------| +| TC-01 | P1 | Raw file metadata | Create `a.txt` ("hello") in app-external dir; `StorageFile.from(context, file)` | `name=a.txt`, `isFile`, `exists`, `length=5`, `mimeType` text/plain or null, `absolutePath` non-null, `path.storageId=primary` | **PASS** — `StorageFileFactoryTest.tc01_rawFileMetadata`, all fields matched on-device (emulator-5554, API 37). | +| TC-02 | P1 | fromPath round-trip | `StorageFile.fromPath(context, file.absolutePath)` for existing file; and for a nonexistent path | Existing → resolves, same `uri` as TC-01; nonexistent → `null` | **PASS** — `StorageFileFactoryTest.tc02_fromPathRoundTrip`. | +| TC-03 | P0 | MediaStore backend | Insert a file into `MediaStore.Downloads` (resolver.insert + write bytes); `StorageFile.from(context, mediaUri)` | Returns MediaStore-backed instance: `isFile`, correct `name`/`length`; `openInputStream()` returns the written bytes | **PASS** — `StorageFileFactoryTest.tc03_mediaStoreBackend`; bytes verified byte-for-byte via `assertArrayEquals`. | +| TC-04 | P1 | Children & child() | Folder with 2 files + 1 subfolder; `list()`, `child("sub/x.txt")` | `list()` size 3; nested child resolves; missing child → null | **PASS** — `StorageFileFactoryTest.tc04_childrenAndChild`. | + +## Group 2 — One-shot transfers (happy paths) + +| ID | Pri | Case | Steps | Expected | Status | +|----|-----|------|-------|----------|--------| +| TC-10 | P1 | copyTo file | Copy 1 file into empty target folder | `TransferResult.Success`, content identical (checksum), source intact, `result.name` correct | **PASS** — `TransferHappyPathTest.tc10_copyToFile`; MD5 verified equal, source untouched. | +| TC-11 | P1 | moveTo file | Move 1 file | Success, content in target, source gone | **PASS** — `TransferHappyPathTest.tc11_moveToFile`. | +| TC-12 | P1 | copyTo folder recursive | Tree: 3 levels, 4 files, 1 empty folder. Copy with default spec (`skipEmptyFiles=true`) | Success; all 4 files present with identical content; document whether the empty folder is skipped | **PASS** — `TransferHappyPathTest.tc12_copyToFolderRecursive`; all 4 checksums matched. Observed: the empty folder (`subA/emptyFolder`) was **not** created in the target — `skipEmptyFiles=true` skips empty folders too, not just zero-length files (verified via on-device `File.list()`, logged "empty folder present in copy target = false"). | +| TC-13 | P1 | zip → unzip round-trip | Zip the TC-12 tree to `archive.zip`; unzip to a fresh folder | Both Success; extracted checksums match originals; `TransferStats.filesTransferred=4` | **PASS** — `TransferHappyPathTest.tc13_zipUnzipRoundTrip`; `stats.filesTransferred == 4` confirmed, all checksums matched. | +| TC-14 | P1 | Invalid target | `copyTo` where target is a FILE, not folder | `Failure(INVALID_TARGET)` | **PASS** — `TransferHappyPathTest.tc14_invalidTarget`. | +| TC-15 | P1 | Progress events | Copy a ~20 MB random file with `updateInterval=100`, collect `onProgress` | At least one `Progress` with `0 < percent <= 100` and `bytesPerSecond > 0`; document if engine thresholds suppress it | **PASS** — `TransferHappyPathTest.tc15_progressEvents`. Observed: exactly 1 progress event fired (`percent=0.234375, bytesTransferred=49152, bytesPerSecond=491520`) before completion — the emulator's virtual disk is fast enough that a 20 MB copy leaves only a narrow window for the 100ms timer, but it fired with valid values every run. | + +## Group 3 — Conflict resolution (the critical gap: suspend→callback adapters) + +| ID | Pri | Case | Steps | Expected | Status | +|----|-----|------|-------|----------|--------| +| TC-20 | P0 | REPLACE | Target already has `a.txt` (old content); copy new `a.txt` with `onConflict { REPLACE }` | Success; exactly one `a.txt` in target with NEW content | **PASS** — `ConflictResolutionTest.tc20_replace`. No deadlock: resolver ran on the instrumentation thread with a free main looper as predicted. | +| TC-21 | P0 | CREATE_NEW | Same setup, resolver returns `CREATE_NEW` | Success; target has `a.txt` (old) AND `a (1).txt` (new) | **PASS** — `ConflictResolutionTest.tc21_createNew`; both files present with correct content. | +| TC-22 | P0 | SKIP | Same setup, resolver returns `SKIP` | Target content untouched; document the returned result (Success-skip vs Failure) and assert it is deterministic | **PASS** (documented) — `ConflictResolutionTest.tc22_skip`. Target untouched in both runs. Observed result shape: **`TransferResult.Failure(TransferErrorCode.UNKNOWN_IO_ERROR, "Transfer finished without a terminal event")`**, identical across two independent runs (deterministic). This is a false-negative-shaped result: SKIP is a normal, expected outcome but is reported as `UNKNOWN_IO_ERROR`, indistinguishable from a real I/O failure. See "library bugs" note below — same root cause family as TC-24, but a distinct, unfixed code path (`copyFileTo`'s single-file SKIP branch returns with no event at all, v2 `DocumentFileExt.kt` around line 2881). Left undocumented-but-not-fixed since correcting it is an API-shape decision (what should SKIP's `TransferResult` look like?), not a small unambiguous bug fix. | +| TC-23 | P0 | Suspending resolver, no deadlock | Resolver does `withContext(Dispatchers.Main) { delay(300) }` before answering; wrap the whole op in `withTimeout(30s)` | Completes well before timeout; no ANR; resolution honored | **PASS** — `ConflictResolutionTest.tc23_suspendingResolverNoDeadlock`; completed in ~305ms (vs 30s timeout), confirming no deadlock on the instrumentation thread (this is the scenario that deadlocks under Robolectric). | +| TC-24 | P0 | Folder merge | Copy folder onto existing same-name folder containing one overlapping + one distinct file; resolver: `MERGE` for `Conflict.TargetFolder`, `REPLACE` for `Conflict.TargetFile` | Success; distinct files from both sides present; overlapping file has source content; resolver received TargetFolder first, then TargetFile(s) | **PASS after library fix** — `ConflictResolutionTest.tc24_folderMerge`. **Initially FAILED**: found and fixed a real library bug, see below. All content-level assertions (distinct files from both sides, overlapping file replaced with source content, resolver invoked TargetFolder then TargetFile) were already correct on disk even before the fix — only the reported `TransferResult` was wrong. | +| TC-25 | P1 | Resolver receives correct conflict info | In TC-20, capture `conflict.target` | `Conflict.TargetFile`, `target.name == "a.txt"`, `target.exists == true` | **PASS** — `ConflictResolutionTest.tc25_resolverReceivesCorrectConflictInfo`. | + +## Group 4 — MediaStore transfers + +| ID | Pri | Case | Steps | Expected | Status | +|----|-----|------|-------|----------|--------| +| TC-30 | P0 | MediaStore → folder copy | Using TC-03's media file: `mediaStorageFile.copyTo(appExternalFolder)` | Success; file lands in target with identical bytes | **PASS** — `MediaStoreTransferTest.tc30_mediaStoreToFolderCopy`; bytes verified byte-for-byte. | +| TC-31 | P2 | deleteRecursively on media | `delete()` / `deleteRecursively()` on the media-backed StorageFile | Returns true; MediaStore row gone | **PASS** — `MediaStoreTransferTest.tc31_deleteRecursivelyOnMedia`; confirmed the MediaStore row is gone via a direct `ContentResolver.query`. | + +## Group 5 — Flow forms & cancellation + +| ID | Pri | Case | Steps | Expected | Status | +|----|-----|------|-------|----------|--------| +| TC-40 | P1 | Event stream shape | Collect `copyToAsFlow` for a small file into a list | Ends with exactly ONE `Completed`; `Completed.result` is `Success`; no events after terminal | **PASS** — `FlowFormsTest.tc40_eventStreamShape`. | +| TC-41 | P1 | Cancellation | Launch collection of a ~50 MB copy, cancel the job at first `Progress` | Collection stops promptly (< 2 s); no crash; document target-file leftover state | **PASS** — `FlowFormsTest.tc41_cancellation`; `job.cancelAndJoin()` returned in well under 2s, no crash. Observed leftover state: the target file (`big.bin`) was **fully present** (52428800 of 52428800 bytes) — on this emulator's fast virtual disk, the underlying copy loop finished before the cancellation signal could interrupt it, so cancellation stopped the *event stream* promptly but did not truncate the file. A genuinely slower target (real device, network share) could still show a partial file; this wasn't reproducible here. | + +## Group 6 — search (on-device regression of the 2.3.0 duplication fix) + +| ID | Pri | Case | Steps | Expected | Status | +|----|-----|------|-------|----------|--------| +| TC-50 | P1 | Recursive search, no duplicates | Tree of 5 entries (3 files, 2 folders); `search(recursive=true)` terminal emission | Exactly 5 unique results | **PASS** — `SearchTest.tc50_recursiveSearchNoDuplicates`. Confirms the 2.3.0 `walkFileTreeForSearch` duplication fix (`fileTree.addAll(fileTree)` removed) holds on a real device, not just in the JVM simulation `ANALYSIS.md` was based on. | + +## Group 7 — Sample app smoke via adb (uiautomator) + +| ID | Pri | Case | Steps | Expected | Status | +|----|-----|------|-------|----------|--------| +| TC-60 | P1 | Install & launch | `./gradlew :sample:installLocalDebug`; launch MainActivity; screenshot | No crash; content below action bar, clear of status/gesture bars | **PASS** — installed via `installLocalDebug`, launched with `adb shell am start`; `dumpsys window` showed the activity focused, screenshot confirmed the "Simple Storage" toolbar and content render correctly below the status bar with no crash. | +| TC-61 | P2 | Legacy folder picker | Tap SELECT FOLDER, drive SAF UI ("Use this folder" → allow) via uiautomator | `onFolderSelected` toast/log fires; no crash (deprecated API still functional) | **PASS** — drove the full SAF flow via `uiautomator dump` + `adb shell input tap` (tapped SELECT FOLDER → navigated into Download/SimpleStorageTest, since the volume root and the bare Download folder are both rejected by DocumentsUI with "Can't use this folder" → USE THIS FOLDER → ALLOW on the grant dialog). Toast `"/storage/emulated/0/Download/SimpleStorageTest"` appeared, confirming `onFolderSelected` fired; app did not crash. Deprecated `SimpleStorageHelper.openFolderPicker` API confirmed functional on API 37 / minSdk 26 with the new AGP 9.2.1/Gradle 9.4.1/Kotlin 2.3.10 toolchain. | + +## Library bugs found during this pass + +### Confirmed and fixed: folder-merge conflict resolution silently reports failure despite full success + +- **Symptom**: `TC-24` (`storage/src/androidTest/.../ConflictResolutionTest.kt`) initially failed: + `copyTo` on a folder with a resolved content conflict (parent `MERGE` + a per-file `REPLACE`) + returned `TransferResult.Failure(TransferErrorCode.UNKNOWN_IO_ERROR, "Transfer finished without a + terminal event")` — **even though on-device inspection showed the merge fully succeeded**: all + 3 expected files (`common.txt` with replaced content, `onlyInSource.txt`, `onlyInTarget.txt`) + were present and correct on disk. +- **Root cause**: `storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt`, private + `copyFolderTo` (the shared engine behind both `copyFolderTo` and `moveFolderTo`, since + `moveFolderTo` just delegates to it with `deleteSourceWhenComplete=true`). A local `finalize` + lambda (defined at line 2609) gates sending the terminal `SingleFolderResult.Completed` event on + `!success || conflictedFiles.isEmpty()`. It is called once before content conflicts are resolved + (line 2619, correctly skipping completion when conflicts are pending) and once more, + unconditionally, after all conflicts have been resolved and copied (line 2681, immediately + followed by `close()` at line 2682). The `conflictedFiles` `ArrayList` populated during the + initial file walk was never cleared after its filtered copy (`solutions`) was processed, so the + second `finalize()` call still saw a non-empty list and incorrectly concluded "conflicts still + pending" — it skipped sending `Completed` and the flow closed silently with no terminal event at + all. This is a genuine v2-engine bug, not something introduced by the v3 wrapper; it was invisible + until now because this content-conflict path was never exercised by an automated test (JVM/Robolectric + can't reach it — see the file header note about `Dispatchers.Main` deadlocking under Robolectric). +- **Fix applied** (small, unambiguous, separate commit): added `conflictedFiles.clear()` at line + 2645, right after the `solutions` list is derived from it, so the second `finalize()` call + correctly recognizes completion. Nothing else reads `conflictedFiles` after that point in the + function. Verified: `TC-24` passes after the fix, `./gradlew :storage:testDebugUnitTest` and the + full `connectedDebugAndroidTest` suite (21/21) still pass. +- **Blast radius**: any `copyTo`/`moveTo` (v3) or `copyFolderTo`/`moveFolderTo` (v2) call where a + folder-level conflict resolves to `MERGE` (or its v2 equivalent) **and** at least one file inside + actually conflicts. Folder copies with no conflicts, or where the conflict resolves to + `REPLACE`/`CREATE_NEW` (no merge, so no content-conflict scan), are unaffected — this is why + `TC-12`/`TC-13` (no conflicts) passed both before and after the fix. + +### Documented, not fixed: single-file SKIP produces the same "no terminal event" shape + +- **Symptom**: `TC-22` — resolving a single-file conflict with `ConflictResolution.SKIP` returns + `TransferResult.Failure(TransferErrorCode.UNKNOWN_IO_ERROR, "Transfer finished without a terminal + event")`, deterministically (reproduced identically across two independent runs). The target is + correctly left untouched, so there's no data-safety issue — but the reported result is + indistinguishable from a real I/O error, which is a rough API edge. +- **Root cause hypothesis**: `DocumentFileExt.kt`'s single-file `copyFileTo` (private overload, line + 2887: `if (fileConflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { + return }`) returns without sending any `SingleFileResult` event at all when the conflict resolves + to SKIP. The v3 wrapper's `TransferSpec.await()` (`StorageFileTransfer.kt`) then falls back to its + generic "no terminal event" `Failure(UNKNOWN_IO_ERROR)` since it never saw a `Completed`. +- **Why not fixed**: unlike the TC-24 bug, this isn't an unambiguous defect — it's a product + decision about what `TransferResult` SKIP *should* produce (e.g. a dedicated `SKIPPED` error code, + or `Success` with a stats flag). That's a v3 API-shape change, out of scope for "small, + unambiguous correction." +- **Related, unverified**: the same `finalize`-reuse pattern as the TC-24 bug also exists in the + private multi-file engine behind `List.copyFilesTo`/`moveFilesTo` + (`DocumentFileExt.kt` around lines 2094-2171, producing `MultipleFilesResult`). That code path is + not reachable from any v3 `StorageFile` API (`StorageFileTransfer.kt` never imports + `MultipleFilesResult`) and so is out of scope for this pass and was **not** reproduced or fixed — + flagged here only because it shares the identical code shape as the confirmed TC-24 bug. + +## Out of scope (documented, not tested here) + +- `StorageAccessManager.ensureAccess`/`pickFolder`/`pickFiles`/`createFile`/`pickMedia` — require + interactive SAF/Photo Picker UI; needs a dedicated UI-automation pass or manual QA. +- SD-card storage paths — emulator has no removable volume by default. +- `rememberLauncherForMediaPicker` — Compose UI test, separate pass. diff --git a/gradle.properties b/gradle.properties index ae8efde..d0fee5d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,14 +15,12 @@ org.gradle.jvmargs=-Xmx2G # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official org.jetbrains.dokka.experimental.gradle.pluginMode=V2EnabledWithHelpers # For publishing: GROUP=com.anggrayudi -VERSION_NAME=2.4.0-SNAPSHOT +VERSION_NAME=3.0.0-beta01 POM_NAME=storage POM_DESCRIPTION=Simplify Android Storage Access Framework for file management across API levels. POM_INCEPTION_YEAR=2020 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0c395d..7c9ab6e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.13.2" +agp = "9.2.1" kotlin = "2.3.10" activityCompose = "1.13.0" coroutines = "1.11.0" @@ -9,6 +9,9 @@ androidx-core = { group = "androidx.core", name = "core-ktx", version = "1.18.0" junit = { group = "junit", name = "junit", version = "4.13.2" } androidx-junit = { group = "androidx.test.ext", name = "junit", version = "1.3.0" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.7.0" } +androidx-test-runner = { group = "androidx.test", name = "runner", version = "1.7.0" } +androidx-test-core = { group = "androidx.test", name = "core", version = "1.7.0" } +androidx-test-rules = { group = "androidx.test", name = "rules", version = "1.7.0" } androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version = "2.10.0" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version = "1.7.1" } androidx-activity = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityCompose" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 9bbc975..1b33c55 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4f5eb9d..c61a118 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index faf9300..23d15a9 100755 --- a/gradlew +++ b/gradlew @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9b42019..5eed7ee 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 7363e30..92fea0c 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -2,13 +2,12 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) } android { namespace = "com.anggrayudi.storage.sample" - compileSdk = 36 + compileSdk = 37 signingConfigs { val debugKeystore = @@ -31,8 +30,8 @@ android { defaultConfig { applicationId = "com.anggrayudi.storage.sample" - minSdk = 23 - targetSdk = 36 + minSdk = 26 + targetSdk = 37 versionCode = 1 versionName = rootProject.extra["VERSION_NAME"] as String multiDexEnabled = true diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java b/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java index 9d969d9..2b15da8 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java @@ -63,7 +63,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { private void setupButtonActions() { findViewById(R.id.btnRequestStoragePermission).setOnClickListener(v -> permissionRequest.check()); - findViewById(R.id.btnRequestStoragePermission).setEnabled(Build.VERSION.SDK_INT >= 23 && Build.VERSION.SDK_INT <= 28); + findViewById(R.id.btnRequestStoragePermission).setEnabled(Build.VERSION.SDK_INT <= 28); findViewById(R.id.btnSelectFolder).setOnClickListener(v -> storageHelper.openFolderPicker(REQUEST_CODE_PICK_FOLDER)); findViewById(R.id.btnSelectFile).setOnClickListener(v -> storageHelper.openFilePicker(REQUEST_CODE_PICK_FILE)); findViewById(R.id.btnCreateFile).setOnClickListener(v -> storageHelper.createFile("text/plain", "File name", null, REQUEST_CODE_CREATE_FILE)); diff --git a/storage-compose/build.gradle.kts b/storage-compose/build.gradle.kts index fde93cd..e15aca5 100644 --- a/storage-compose/build.gradle.kts +++ b/storage-compose/build.gradle.kts @@ -3,7 +3,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") id("org.jetbrains.kotlin.plugin.parcelize") - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.dokka) alias(libs.plugins.maven.publish) @@ -11,16 +10,16 @@ plugins { android { namespace = "com.anggrayudi.storage.compose" - compileSdk = 36 + compileSdk = 37 resourcePrefix = "ss_" defaultConfig { - minSdk = 23 + minSdk = 26 consumerProguardFiles("consumer-rules.pro") } - testOptions { targetSdk = 36 } - lint { targetSdk = 36 } + testOptions { targetSdk = 37 } + lint { targetSdk = 37 } buildTypes { release { @@ -37,7 +36,8 @@ android { compilerOptions { jvmTarget = JvmTarget.JVM_11 // Support @JvmDefault - freeCompilerArgs = listOf("-Xjvm-default=all", "-opt-in=kotlin.RequiresOptIn") + freeCompilerArgs = + listOf("-Xjvm-default=all", "-opt-in=kotlin.RequiresOptIn", "-Xexplicit-api=warning") } } } diff --git a/storage-compose/src/main/java/com/anggrayudi/storage/compose/MediaPickerCompose.kt b/storage-compose/src/main/java/com/anggrayudi/storage/compose/MediaPickerCompose.kt new file mode 100644 index 0000000..740c2f6 --- /dev/null +++ b/storage-compose/src/main/java/com/anggrayudi/storage/compose/MediaPickerCompose.kt @@ -0,0 +1,65 @@ +package com.anggrayudi.storage.compose + +import android.net.Uri +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import com.anggrayudi.storage.StorageFile + +/** + * Launches the system Photo Picker ([ActivityResultContracts.PickVisualMedia]). + * + * @author Anggrayudi H + */ +class MediaPickerLauncher +internal constructor( + private val single: ManagedActivityResultLauncher?, + private val multiple: ManagedActivityResultLauncher>?, +) { + + fun launch( + type: ActivityResultContracts.PickVisualMedia.VisualMediaType = + ActivityResultContracts.PickVisualMedia.ImageAndVideo + ) { + val request = PickVisualMediaRequest(type) + single?.launch(request) ?: multiple?.launch(request) + } +} + +/** + * Remembers a launcher for the system Photo Picker — no storage permission and no SAF grant + * required. Picked media arrive as [StorageFile]s; an empty list means the user canceled. + * + * @param maxItems `1` opens the single-pick UI; `2..100` allows multi-select. Must not change + * across recompositions. + */ +@Composable +fun rememberLauncherForMediaPicker( + maxItems: Int = 1, + onMediaPicked: (List) -> Unit, +): MediaPickerLauncher { + val appContext = LocalContext.current.applicationContext + val currentOnMediaPicked = rememberUpdatedState(onMediaPicked) + return if (maxItems <= 1) { + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + currentOnMediaPicked.value( + listOfNotNull(uri?.let { StorageFile.from(appContext, it) }) + ) + } + remember(launcher) { MediaPickerLauncher(launcher, null) } + } else { + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(maxItems) + ) { uris -> + currentOnMediaPicked.value(uris.mapNotNull { StorageFile.from(appContext, it) }) + } + remember(launcher) { MediaPickerLauncher(null, launcher) } + } +} diff --git a/storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt b/storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt index 57ebc80..59b792e 100644 --- a/storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt +++ b/storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt @@ -204,7 +204,6 @@ internal data class StorageAccessDialogData( fun rememberLauncherForStorageAccess( expectedStorageType: StorageType = StorageType.UNKNOWN, expectedBasePath: String = "", - /** It only takes effect on API 26+ */ initialPath: FileFullPath? = null, onStorageAccessGranted: (root: DocumentFile) -> Unit, ): StorageAccessLauncher { @@ -350,7 +349,6 @@ internal constructor( @Composable fun rememberLauncherForFilePicker( allowMultiple: Boolean = false, - /** It only takes effect on API 26+ */ initialPath: FileFullPath? = null, filterMimeTypes: Set = emptySet(), onFilesPicked: (files: List) -> Unit, @@ -414,7 +412,6 @@ internal constructor( fun rememberLauncherForFileCreation( mimeType: String, fileName: String? = null, - /** It only takes effect on API 26+ */ initialPath: FileFullPath? = null, onFileCreated: (file: DocumentFile) -> Unit, ): FileCreationLauncher { @@ -474,7 +471,6 @@ internal constructor( @Composable fun rememberLauncherForFolderPicker( - /** It only takes effect on API 26+ */ initialPath: FileFullPath? = null, onFolderPicked: (folder: DocumentFile) -> Unit, ): FolderPickerLauncher { diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts index 1e8e8de..ef4c5e8 100644 --- a/storage/build.gradle.kts +++ b/storage/build.gradle.kts @@ -2,23 +2,23 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") - alias(libs.plugins.kotlin.android) alias(libs.plugins.dokka) alias(libs.plugins.maven.publish) } android { namespace = "com.anggrayudi.storage" - compileSdk = 36 + compileSdk = 37 resourcePrefix = "ss_" defaultConfig { - minSdk = 23 + minSdk = 26 consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - testOptions { targetSdk = 36 } - lint { targetSdk = 36 } + testOptions { targetSdk = 37 } + lint { targetSdk = 37 } buildTypes { release { @@ -38,7 +38,8 @@ android { compilerOptions { jvmTarget = JvmTarget.JVM_11 // Support @JvmDefault - freeCompilerArgs = listOf("-Xjvm-default=all", "-opt-in=kotlin.RequiresOptIn") + freeCompilerArgs = + listOf("-Xjvm-default=all", "-opt-in=kotlin.RequiresOptIn", "-Xexplicit-api=warning") } } } @@ -58,6 +59,14 @@ dependencies { testImplementation(libs.mockk) testImplementation(libs.kotlin.test) testImplementation(libs.robolectric) + + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.coroutines.test) + androidTestImplementation(libs.kotlin.test) } afterEvaluate { diff --git a/storage/src/androidTest/java/com/anggrayudi/storage/ConflictResolutionTest.kt b/storage/src/androidTest/java/com/anggrayudi/storage/ConflictResolutionTest.kt new file mode 100644 index 0000000..7da145f --- /dev/null +++ b/storage/src/androidTest/java/com/anggrayudi/storage/ConflictResolutionTest.kt @@ -0,0 +1,203 @@ +package com.anggrayudi.storage + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.anggrayudi.storage.transfer.Conflict +import com.anggrayudi.storage.transfer.ConflictResolution +import com.anggrayudi.storage.transfer.TransferResult +import com.anggrayudi.storage.transfer.getOrNull +import com.anggrayudi.storage.transfer.isSuccess +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Group 3 - Conflict resolution (V3_TEST_CASES.md TC-20..TC-25). This is the critical gap this + * device pass exists to close: the suspend `ConflictResolver` is bridged internally to v2's + * callback classes which post continuations to `Dispatchers.Main`. Under Robolectric the main + * looper never pumps during `runBlocking`, so this path deadlocks there - on the instrumentation + * thread the looper is free, so this is the first time it has ever actually run. + */ +@RunWith(AndroidJUnit4::class) +class ConflictResolutionTest { + + private val context = targetContext() + private lateinit var playground: File + + @Before + fun setUp() { + playground = newPlaygroundDir("tc20_25") + } + + @After + fun tearDown() { + playground.deleteRecursivelyOrThrow() + } + + private fun storageFile(file: File) = StorageFile.from(context, file) + + /** source folder with a new a.txt + target folder with an existing (old) a.txt. */ + private fun setUpSingleFileConflict(): Triple { + val source = File(playground, "source").apply { mkdirs() } + val target = File(playground, "target").apply { mkdirs() } + val newFile = File(source, "a.txt").apply { writeText("NEW content") } + val oldFile = File(target, "a.txt").apply { writeText("OLD content") } + return Triple(source, target, newFile) + } + + // TC-20: REPLACE + @Test + fun tc20_replace() = runBlocking { + val (_, target, newFile) = setUpSingleFileConflict() + + val result = storageFile(newFile).copyTo(storageFile(target)) { onConflict { ConflictResolution.REPLACE } } + + assertTrue("expected success but was $result", result.isSuccess) + val filesNamedA = target.listFiles { f -> f.name.startsWith("a") }.orEmpty() + assertEquals("expected exactly one a.txt in target", 1, filesNamedA.size) + assertEquals("a.txt", filesNamedA[0].name) + assertEquals("NEW content", filesNamedA[0].readText()) + } + + // TC-21: CREATE_NEW + @Test + fun tc21_createNew() = runBlocking { + val (_, target, newFile) = setUpSingleFileConflict() + + val result = storageFile(newFile).copyTo(storageFile(target)) { onConflict { ConflictResolution.CREATE_NEW } } + + assertTrue("expected success but was $result", result.isSuccess) + val original = File(target, "a.txt") + val duplicate = File(target, "a (1).txt") + assertTrue("original a.txt should still exist", original.exists()) + assertEquals("OLD content", original.readText()) + assertTrue("expected a (1).txt to be created, target has: ${target.list()?.toList()}", duplicate.exists()) + assertEquals("NEW content", duplicate.readText()) + } + + // TC-22: SKIP - documents the returned result shape, and that it is deterministic. + @Test + fun tc22_skip() = runBlocking { + val (_, target, newFile) = setUpSingleFileConflict() + + val result1 = storageFile(newFile).copyTo(storageFile(target)) { onConflict { ConflictResolution.SKIP } } + // Target must be untouched by the skipped transfer regardless of how the result is shaped. + assertEquals("OLD content", File(target, "a.txt").readText()) + assertTrue( + "SKIP must not silently fabricate a second file", + target.listFiles { f -> f.name.startsWith("a") }.orEmpty().size == 1, + ) + + println("TC-22: result of copyTo with SKIP resolution = $result1") + + // Run again on a fresh, identical setup to confirm the shape is deterministic, not flaky. + val (_, target2, newFile2) = setUpSingleFileConflict() + val result2 = storageFile(newFile2).copyTo(storageFile(target2)) { onConflict { ConflictResolution.SKIP } } + println("TC-22: result of second run with SKIP resolution = $result2") + + assertEquals( + "SKIP result shape must be deterministic across runs", + result1::class, + result2::class, + ) + if (result1 is TransferResult.Failure && result2 is TransferResult.Failure) { + assertEquals(result1.errorCode, result2.errorCode) + } + } + + // TC-23: Suspending resolver, no deadlock + @Test + fun tc23_suspendingResolverNoDeadlock() = runBlocking { + val (_, target, newFile) = setUpSingleFileConflict() + val started = System.currentTimeMillis() + + val result = + withTimeout(30_000) { + storageFile(newFile).copyTo(storageFile(target)) { + onConflict { + withContext(Dispatchers.Main) { delay(300) } + ConflictResolution.REPLACE + } + } + } + + val elapsed = System.currentTimeMillis() - started + println("TC-23: suspending resolver completed in ${elapsed}ms") + assertTrue("expected success but was $result", result.isSuccess) + assertTrue("resolver delay of 300ms should have been honored", elapsed >= 300) + assertTrue("should complete well before the 30s timeout, took ${elapsed}ms", elapsed < 10_000) + assertEquals("NEW content", File(target, "a.txt").readText()) + } + + // TC-24: Folder merge + @Test + fun tc24_folderMerge() = runBlocking { + val sourceParent = File(playground, "sourceParent").apply { mkdirs() } + val sourceShared = File(sourceParent, "shared").apply { mkdirs() } + File(sourceShared, "common.txt").writeText("NEW common") + File(sourceShared, "onlyInSource.txt").writeText("only in source") + + val targetParent = File(playground, "targetParent").apply { mkdirs() } + val targetShared = File(targetParent, "shared").apply { mkdirs() } + File(targetShared, "common.txt").writeText("OLD common") + File(targetShared, "onlyInTarget.txt").writeText("only in target") + + val conflictOrder = mutableListOf() + val result = + storageFile(sourceShared).copyTo(storageFile(targetParent)) { + onConflict { conflict -> + conflictOrder.add(conflict) + when (conflict) { + is Conflict.TargetFolder -> ConflictResolution.MERGE + is Conflict.TargetFile -> ConflictResolution.REPLACE + } + } + } + + assertTrue("expected success but was $result", result.isSuccess) + assertEquals("only in source", File(targetShared, "onlyInSource.txt").readText()) + assertEquals("only in target", File(targetShared, "onlyInTarget.txt").readText()) + assertEquals("NEW common", File(targetShared, "common.txt").readText()) + + assertTrue("resolver should have been consulted at least twice", conflictOrder.size >= 2) + assertTrue( + "first conflict should be TargetFolder but order was $conflictOrder", + conflictOrder.first() is Conflict.TargetFolder, + ) + assertTrue( + "a TargetFile conflict for common.txt should follow, order was $conflictOrder", + conflictOrder.drop(1).any { it is Conflict.TargetFile && it.target.name == "common.txt" }, + ) + } + + // TC-25: Resolver receives correct conflict info + @Test + fun tc25_resolverReceivesCorrectConflictInfo() = runBlocking { + val (_, target, newFile) = setUpSingleFileConflict() + var capturedConflict: Conflict? = null + + val result = + storageFile(newFile).copyTo(storageFile(target)) { + onConflict { conflict -> + capturedConflict = conflict + ConflictResolution.REPLACE + } + } + + assertTrue("expected success but was $result", result.isSuccess) + assertNotNull("resolver should have been invoked", capturedConflict) + val conflict = capturedConflict!! + assertTrue("expected Conflict.TargetFile but was $conflict", conflict is Conflict.TargetFile) + assertEquals("a.txt", conflict.target.name) + assertTrue("target.exists should be true", conflict.target.exists) + } +} diff --git a/storage/src/androidTest/java/com/anggrayudi/storage/FlowFormsTest.kt b/storage/src/androidTest/java/com/anggrayudi/storage/FlowFormsTest.kt new file mode 100644 index 0000000..f1c66f4 --- /dev/null +++ b/storage/src/androidTest/java/com/anggrayudi/storage/FlowFormsTest.kt @@ -0,0 +1,109 @@ +package com.anggrayudi.storage + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.anggrayudi.storage.transfer.TransferEvent +import com.anggrayudi.storage.transfer.TransferResult +import com.anggrayudi.storage.transfer.TransferSpec +import java.io.File +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Group 5 - Flow forms & cancellation (V3_TEST_CASES.md TC-40, TC-41). + */ +@RunWith(AndroidJUnit4::class) +class FlowFormsTest { + + private val context = targetContext() + private lateinit var playground: File + + @Before + fun setUp() { + playground = newPlaygroundDir("tc40_41") + } + + @After + fun tearDown() { + playground.deleteRecursivelyOrThrow() + } + + private fun storageFile(file: File) = StorageFile.from(context, file) + + // TC-40: Event stream shape + @Test + fun tc40_eventStreamShape() = runBlocking { + val source = File(playground, "source").apply { mkdirs() } + val target = File(playground, "target").apply { mkdirs() } + val src = File(source, "a.txt").apply { writeText("hello") } + + val events = mutableListOf() + storageFile(src).copyToAsFlow(storageFile(target)).collect { events.add(it) } + + val completedEvents = events.filterIsInstance>() + assertEquals("expected exactly one Completed event, got: $events", 1, completedEvents.size) + assertTrue( + "the terminal event must be the last one emitted, got: $events", + events.last() is TransferEvent.Completed<*>, + ) + val result = completedEvents.single().result + assertTrue("Completed.result should be Success but was $result", result is TransferResult.Success<*>) + } + + // TC-41: Cancellation + @Test + fun tc41_cancellation() = runBlocking { + val source = File(playground, "source").apply { mkdirs() } + val target = File(playground, "target").apply { mkdirs() } + val bigFile = File(source, "big.bin").apply { writeRandomBytes(50 * 1024 * 1024) } + + val progressSeen = CompletableDeferred() + var crashed: Throwable? = null + + val job: Job = + launch(Dispatchers.Default) { + try { + storageFile(bigFile) + .copyToAsFlow(storageFile(target), TransferSpec().apply { updateInterval = 20 }) + .collect { event -> + if (event is TransferEvent.Progress) { + progressSeen.complete(Unit) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + crashed = e + } + } + + // Wait for the collector to observe the first Progress event, then cancel it externally - + // this is the scenario TC-41 specifies ("cancel the job at first Progress"). + withTimeout(15_000) { progressSeen.await() } + + val cancelStart = System.currentTimeMillis() + job.cancelAndJoin() + val cancelElapsed = System.currentTimeMillis() - cancelStart + + assertTrue("collector must not crash, but got: $crashed", crashed == null) + assertTrue("cancellation join should be prompt, took ${cancelElapsed}ms", cancelElapsed < 2000) + + // Documented behavior: what does the target look like after a mid-copy cancellation? + val leftover = File(target, "big.bin") + val leftoverDescription = + if (!leftover.exists()) "no target file" + else "target file present, size=${leftover.length()} of ${bigFile.length()}" + println("TC-41: leftover target state after cancellation = $leftoverDescription") + } +} diff --git a/storage/src/androidTest/java/com/anggrayudi/storage/MediaStoreTransferTest.kt b/storage/src/androidTest/java/com/anggrayudi/storage/MediaStoreTransferTest.kt new file mode 100644 index 0000000..2424eaa --- /dev/null +++ b/storage/src/androidTest/java/com/anggrayudi/storage/MediaStoreTransferTest.kt @@ -0,0 +1,78 @@ +package com.anggrayudi.storage + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.anggrayudi.storage.media.MediaFile +import com.anggrayudi.storage.transfer.getOrNull +import com.anggrayudi.storage.transfer.isSuccess +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Group 4 - MediaStore transfers (V3_TEST_CASES.md TC-30, TC-31). + */ +@RunWith(AndroidJUnit4::class) +class MediaStoreTransferTest { + + private val context = targetContext() + private lateinit var playground: File + private var media: MediaFile? = null + + @Before + fun setUp() { + playground = newPlaygroundDir("tc30_31") + } + + @After + fun tearDown() { + media?.delete() + playground.deleteRecursivelyOrThrow() + } + + // TC-30: MediaStore -> folder copy + @Test + fun tc30_mediaStoreToFolderCopy() = runBlocking { + val content = "media to folder ${System.nanoTime()}".toByteArray() + val mediaFile = insertDownloadsMedia("tc30_${System.nanoTime()}.txt", content) + media = mediaFile + + val mediaStorageFile = StorageFile.from(context, mediaFile.uri) + assertTrue("MediaStore URI must resolve", mediaStorageFile != null) + + val targetFolder = File(playground, "target").apply { mkdirs() } + val result = mediaStorageFile!!.copyTo(StorageFile.from(context, targetFolder)) + + assertTrue("expected success but was $result", result.isSuccess) + val landedName = result.getOrNull()?.name + assertTrue("copied result should have a name", !landedName.isNullOrEmpty()) + val landed = File(targetFolder, landedName!!) + assertTrue("copied file should exist on disk at $landed", landed.exists()) + assertArrayEquals(content, landed.readBytes()) + } + + // TC-31: deleteRecursively on media + @Test + fun tc31_deleteRecursivelyOnMedia() = runBlocking { + val content = "to be deleted".toByteArray() + val mediaFile = insertDownloadsMedia("tc31_${System.nanoTime()}.txt", content) + media = mediaFile + + val mediaStorageFile = StorageFile.from(context, mediaFile.uri) + assertTrue("MediaStore URI must resolve", mediaStorageFile != null) + + val deleted = mediaStorageFile!!.deleteRecursively() + + assertTrue("deleteRecursively() should return true", deleted) + val stillPresent = + context.contentResolver.query(mediaFile.uri, null, null, null, null)?.use { it.count > 0 } + ?: false + assertFalse("MediaStore row should be gone after delete", stillPresent) + media = null // already gone, don't try to delete again in tearDown + } +} diff --git a/storage/src/androidTest/java/com/anggrayudi/storage/SearchTest.kt b/storage/src/androidTest/java/com/anggrayudi/storage/SearchTest.kt new file mode 100644 index 0000000..5091652 --- /dev/null +++ b/storage/src/androidTest/java/com/anggrayudi/storage/SearchTest.kt @@ -0,0 +1,59 @@ +package com.anggrayudi.storage + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.File +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Group 6 - search, on-device regression of the 2.3.0 recursive-duplication fix + * (V3_TEST_CASES.md TC-50). The bug (`ANALYSIS.md`, "Bug: Duplikasi Hasil `search()` Rekursif") + * was `fileTree.addAll(walkFileTreeForSearch(fileTree, ...))` duplicating the accumulator on every + * subfolder visited; the current source no longer does this (see + * `DocumentFileExt.kt` `walkFileTreeForSearch`), so this is confirming the fix holds on a real + * device, not just in the JVM simulation the analysis was based on. + */ +@RunWith(AndroidJUnit4::class) +class SearchTest { + + private val context = targetContext() + private lateinit var playground: File + + @Before + fun setUp() { + playground = newPlaygroundDir("tc50") + } + + @After + fun tearDown() { + playground.deleteRecursivelyOrThrow() + } + + // TC-50: Recursive search, no duplicates + @Test + fun tc50_recursiveSearchNoDuplicates() = runBlocking { + val root = File(playground, "root").apply { mkdirs() } + File(root, "file1.txt").writeText("1") + val folderA = File(root, "folderA").apply { mkdirs() } + File(folderA, "file2.txt").writeText("2") + val folderB = File(root, "folderB").apply { mkdirs() } + File(folderB, "file3.txt").writeText("3") + + val rootStorageFile = StorageFile.from(context, root) + val emissions = rootStorageFile.search(recursive = true).toList() + val terminal = emissions.last() + + assertEquals( + "expected exactly 5 unique results, got: ${terminal.map { it.name }}", + 5, + terminal.size, + ) + val uniqueUris = terminal.map { it.uri }.toSet() + assertEquals("results must not contain duplicates", 5, uniqueUris.size) + } +} diff --git a/storage/src/androidTest/java/com/anggrayudi/storage/StorageFileFactoryTest.kt b/storage/src/androidTest/java/com/anggrayudi/storage/StorageFileFactoryTest.kt new file mode 100644 index 0000000..7da3829 --- /dev/null +++ b/storage/src/androidTest/java/com/anggrayudi/storage/StorageFileFactoryTest.kt @@ -0,0 +1,117 @@ +package com.anggrayudi.storage + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.anggrayudi.storage.file.StorageId +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Group 1 - StorageFile factories & metadata (V3_TEST_CASES.md TC-01..TC-04). Runs on-device + * against `context.getExternalFilesDir(null)`, no SAF grant required. + */ +@RunWith(AndroidJUnit4::class) +class StorageFileFactoryTest { + + private val context = targetContext() + private lateinit var playground: File + + @Before + fun setUp() { + playground = newPlaygroundDir("tc01_04") + } + + @After + fun tearDown() { + playground.deleteRecursivelyOrThrow() + } + + // TC-01: Raw file metadata + @Test + fun tc01_rawFileMetadata() { + val file = File(playground, "a.txt").apply { writeText("hello") } + + val storageFile = StorageFile.from(context, file) + + assertEquals("a.txt", storageFile.name) + assertTrue("expected isFile", storageFile.isFile) + assertTrue("expected exists", storageFile.exists) + assertEquals(5L, storageFile.length) + assertTrue( + "mimeType should be text/plain or null but was ${storageFile.mimeType}", + storageFile.mimeType == "text/plain" || storageFile.mimeType == null, + ) + assertNotNull("absolutePath should be non-null", storageFile.absolutePath) + assertEquals(file.absolutePath, storageFile.absolutePath) + assertNotNull("path should be non-null", storageFile.path) + assertEquals(StorageId.PRIMARY, storageFile.path?.storageId) + } + + // TC-02: fromPath round-trip + @Test + fun tc02_fromPathRoundTrip() { + val file = File(playground, "a.txt").apply { writeText("hello") } + val original = StorageFile.from(context, file) + + val resolved = StorageFile.fromPath(context, file.absolutePath) + assertNotNull("existing path should resolve", resolved) + assertEquals(original.uri, resolved!!.uri) + + val nonexistent = File(playground, "does_not_exist.txt") + val resolvedMissing = StorageFile.fromPath(context, nonexistent.absolutePath) + assertNull("nonexistent path should resolve to null", resolvedMissing) + } + + // TC-03: MediaStore backend + @Test + fun tc03_mediaStoreBackend() = runBlocking { + val content = "media hello world".toByteArray() + val mediaFile = insertDownloadsMedia("tc03_${System.nanoTime()}.txt", content) + try { + val storageFile = StorageFile.from(context, mediaFile.uri) + assertNotNull("MediaStore URI should resolve to a StorageFile", storageFile) + assertNotNull("asMediaFile() should be non-null for a MediaStore-backed file", storageFile!!.asMediaFile()) + assertTrue("expected isFile", storageFile.isFile) + assertEquals(mediaFile.fullName, storageFile.name) + assertEquals(content.size.toLong(), storageFile.length) + val readBack = storageFile.openInputStream()?.use { it.readBytes() } + assertNotNull("openInputStream() should return the written bytes", readBack) + assertArrayEquals(content, readBack) + } finally { + mediaFile.delete() + } + } + + // TC-04: Children & child() + @Test + fun tc04_childrenAndChild() { + val folder = File(playground, "folder").apply { mkdirs() } + File(folder, "x.txt").writeText("x") + File(folder, "y.txt").writeText("y") + val sub = File(folder, "sub").apply { mkdirs() } + File(sub, "z.txt").writeText("z") + + val storageFolder = StorageFile.from(context, folder) + val children = storageFolder.list() + assertEquals(3, children.size) + + val nested = storageFolder.child("sub/z.txt") + assertNotNull("nested child should resolve", nested) + assertEquals("z.txt", nested!!.name) + assertTrue(nested.isFile) + + val missing = storageFolder.child("sub/does_not_exist.txt") + assertNull("missing child should be null", missing) + } +} + +private fun assertArrayEquals(expected: ByteArray, actual: ByteArray?) { + org.junit.Assert.assertArrayEquals(expected, actual) +} diff --git a/storage/src/androidTest/java/com/anggrayudi/storage/TestUtils.kt b/storage/src/androidTest/java/com/anggrayudi/storage/TestUtils.kt new file mode 100644 index 0000000..8c920bb --- /dev/null +++ b/storage/src/androidTest/java/com/anggrayudi/storage/TestUtils.kt @@ -0,0 +1,75 @@ +package com.anggrayudi.storage + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import com.anggrayudi.storage.media.FileDescription +import com.anggrayudi.storage.media.MediaFile +import com.anggrayudi.storage.media.MediaStoreCompat +import java.io.File +import java.security.MessageDigest +import java.util.UUID +import kotlin.random.Random + +/** + * Shared helpers for the v3 device test pass (`V3_TEST_CASES.md`, Groups 1-6). Every test uses + * `context.getExternalFilesDir(null)` as its playground so no SAF grant or runtime permission is + * required, per the instructions in `V3_TEST_CASES.md`. + */ +internal fun targetContext(): Context = InstrumentationRegistry.getInstrumentation().targetContext + +/** A fresh, uniquely-named directory under app-external storage for one test to work in. */ +internal fun newPlaygroundDir(prefix: String): File { + val dir = File(targetContext().getExternalFilesDir(null), "${prefix}_${UUID.randomUUID()}") + check(dir.mkdirs()) { "Could not create playground dir $dir" } + return dir +} + +internal fun File.md5(): String = inputStream().use { it.md5() } + +internal fun java.io.InputStream.md5(): String { + val digest = MessageDigest.getInstance("MD5") + val buffer = ByteArray(8 * 1024) + while (true) { + val read = read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + +internal fun StorageFile.md5(): String = + openInputStream()?.use { it.md5() } ?: error("Cannot open input stream for $name") + +/** Writes [sizeBytes] of deterministic pseudo-random content to this file. */ +internal fun File.writeRandomBytes(sizeBytes: Int, seed: Long = 42L) { + val random = Random(seed) + outputStream().use { out -> + val buffer = ByteArray(64 * 1024) + var remaining = sizeBytes + while (remaining > 0) { + val chunk = minOf(buffer.size, remaining) + random.nextBytes(buffer, 0, chunk) + out.write(buffer, 0, chunk) + remaining -= chunk + } + } +} + +internal fun File.deleteRecursivelyOrThrow() { + if (exists() && !deleteRecursively()) { + error("Could not delete $this") + } +} + +/** + * Inserts a new row into `MediaStore.Downloads` scoped to this app's own files (no permission + * required) and writes [content] into it. Caller is responsible for calling `MediaFile.delete()`. + */ +internal fun insertDownloadsMedia(name: String, content: ByteArray): MediaFile { + val media = + MediaStoreCompat.createDownload(targetContext(), FileDescription(name, "SimpleStorageTest")) + ?: error("Could not insert MediaStore row for $name") + media.openOutputStream(append = false)?.use { it.write(content) } + ?: error("Could not open output stream for $name") + return media +} diff --git a/storage/src/androidTest/java/com/anggrayudi/storage/TransferHappyPathTest.kt b/storage/src/androidTest/java/com/anggrayudi/storage/TransferHappyPathTest.kt new file mode 100644 index 0000000..732e3e4 --- /dev/null +++ b/storage/src/androidTest/java/com/anggrayudi/storage/TransferHappyPathTest.kt @@ -0,0 +1,175 @@ +package com.anggrayudi.storage + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.anggrayudi.storage.transfer.TransferErrorCode +import com.anggrayudi.storage.transfer.TransferResult +import com.anggrayudi.storage.transfer.getOrNull +import com.anggrayudi.storage.transfer.isSuccess +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Group 2 - One-shot transfers, happy paths (V3_TEST_CASES.md TC-10..TC-15). + */ +@RunWith(AndroidJUnit4::class) +class TransferHappyPathTest { + + private val context = targetContext() + private lateinit var playground: File + + @Before + fun setUp() { + playground = newPlaygroundDir("tc10_15") + } + + @After + fun tearDown() { + playground.deleteRecursivelyOrThrow() + } + + private fun storageFile(file: File) = StorageFile.from(context, file) + + // TC-10: copyTo file + @Test + fun tc10_copyToFile() = runBlocking { + val source = File(playground, "source").apply { mkdirs() } + val target = File(playground, "target").apply { mkdirs() } + val src = File(source, "a.txt").apply { writeRandomBytes(4096) } + val expectedMd5 = src.md5() + + val result = storageFile(src).copyTo(storageFile(target)) + + assertTrue("expected success but was $result", result.isSuccess) + assertEquals("a.txt", result.getOrNull()?.name) + val copied = File(target, "a.txt") + assertTrue(copied.exists()) + assertEquals(expectedMd5, copied.md5()) + assertTrue("source should remain intact", src.exists()) + assertEquals(expectedMd5, src.md5()) + } + + // TC-11: moveTo file + @Test + fun tc11_moveToFile() = runBlocking { + val source = File(playground, "source").apply { mkdirs() } + val target = File(playground, "target").apply { mkdirs() } + val src = File(source, "a.txt").apply { writeRandomBytes(4096) } + val expectedMd5 = src.md5() + + val result = storageFile(src).moveTo(storageFile(target)) + + assertTrue("expected success but was $result", result.isSuccess) + val moved = File(target, "a.txt") + assertTrue(moved.exists()) + assertEquals(expectedMd5, moved.md5()) + assertFalse("source should be gone", src.exists()) + } + + // TC-12: copyTo folder recursive + @Test + fun tc12_copyToFolderRecursive() = runBlocking { + val root = File(playground, "root").apply { mkdirs() } + File(root, "file1.txt").writeRandomBytes(100, seed = 1) + val subA = File(root, "subA").apply { mkdirs() } + File(subA, "file2.txt").writeRandomBytes(200, seed = 2) + val subB = File(subA, "subB").apply { mkdirs() } + File(subB, "file3.txt").writeRandomBytes(300, seed = 3) + File(subB, "file4.txt").writeRandomBytes(400, seed = 4) + File(subA, "emptyFolder").mkdirs() + + val target = File(playground, "target").apply { mkdirs() } + val result = storageFile(root).copyTo(storageFile(target)) + + assertTrue("expected success but was $result", result.isSuccess) + val copiedRoot = File(target, "root") + val files = copiedRoot.walkTopDown().filter { it.isFile }.toList() + assertEquals(4, files.size) + assertEquals(File(root, "file1.txt").md5(), File(copiedRoot, "file1.txt").md5()) + assertEquals(File(subA, "file2.txt").md5(), File(copiedRoot, "subA/file2.txt").md5()) + assertEquals(File(subB, "file3.txt").md5(), File(copiedRoot, "subA/subB/file3.txt").md5()) + assertEquals(File(subB, "file4.txt").md5(), File(copiedRoot, "subA/subB/file4.txt").md5()) + + // Documented behavior: default spec has skipEmptyFiles = true, but that flag only governs + // zero-length *files*, not empty *folders* - record what actually happens on disk. + val emptyFolderCopied = File(copiedRoot, "subA/emptyFolder").exists() + println("TC-12: empty folder present in copy target = $emptyFolderCopied") + } + + // TC-13: zip -> unzip round-trip + @Test + fun tc13_zipUnzipRoundTrip() = runBlocking { + val root = File(playground, "root").apply { mkdirs() } + File(root, "file1.txt").writeRandomBytes(100, seed = 1) + val subA = File(root, "subA").apply { mkdirs() } + File(subA, "file2.txt").writeRandomBytes(200, seed = 2) + val subB = File(subA, "subB").apply { mkdirs() } + File(subB, "file3.txt").writeRandomBytes(300, seed = 3) + File(subB, "file4.txt").writeRandomBytes(400, seed = 4) + File(subA, "emptyFolder").mkdirs() + + val zipFile = File(playground, "archive.zip").apply { createNewFile() } + val zipResult = listOf(storageFile(root)).zipTo(storageFile(zipFile)) + assertTrue("zip failed: $zipResult", zipResult.isSuccess) + assertEquals(4, (zipResult as TransferResult.Success<*>).stats.filesTransferred) + + val unzipDir = File(playground, "unzipped").apply { mkdirs() } + val unzipResult = storageFile(zipFile).unzipTo(storageFile(unzipDir)) + assertTrue("unzip failed: $unzipResult", unzipResult.isSuccess) + + val extracted = unzipDir.walkTopDown().filter { it.isFile }.associateBy { it.name } + assertEquals(setOf("file1.txt", "file2.txt", "file3.txt", "file4.txt"), extracted.keys) + assertEquals(File(root, "file1.txt").md5(), extracted.getValue("file1.txt").md5()) + assertEquals(File(subA, "file2.txt").md5(), extracted.getValue("file2.txt").md5()) + assertEquals(File(subB, "file3.txt").md5(), extracted.getValue("file3.txt").md5()) + assertEquals(File(subB, "file4.txt").md5(), extracted.getValue("file4.txt").md5()) + } + + // TC-14: Invalid target + @Test + fun tc14_invalidTarget() = runBlocking { + val source = File(playground, "source").apply { mkdirs() } + val src = File(source, "a.txt").apply { writeText("hello") } + val notAFolder = File(playground, "not_a_folder.txt").apply { writeText("i am a file") } + + val result = storageFile(src).copyTo(storageFile(notAFolder)) + + assertTrue("expected Failure but was $result", result is TransferResult.Failure) + assertEquals(TransferErrorCode.INVALID_TARGET, (result as TransferResult.Failure).errorCode) + } + + // TC-15: Progress events + @Test + fun tc15_progressEvents() = runBlocking { + val source = File(playground, "source").apply { mkdirs() } + val target = File(playground, "target").apply { mkdirs() } + val bigFile = File(source, "big.bin").apply { writeRandomBytes(20 * 1024 * 1024) } + + val progressEvents = mutableListOf() + val result = + storageFile(bigFile).copyTo(storageFile(target)) { + updateInterval = 100 + onProgress { progressEvents.add(it) } + } + + assertTrue("expected success but was $result", result.isSuccess) + assertEquals(bigFile.md5(), File(target, "big.bin").md5()) + + val validProgress = progressEvents.filter { it.percent > 0f && it.percent <= 100f && it.bytesPerSecond > 0 } + println( + "TC-15: captured ${progressEvents.size} progress events, " + + "${validProgress.size} satisfy 00: $progressEvents" + ) + assertTrue( + "expected at least one Progress with 00, " + + "got $progressEvents", + validProgress.isNotEmpty(), + ) + } +} diff --git a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt b/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt index 5e8dc3d..6bcfa89 100644 --- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt +++ b/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt @@ -48,6 +48,10 @@ import java.io.File * @author Anggrayudi Hardiannico A. (anggrayudi.hardiannico@dana.id) * @version SimpleStorage, v 0.0.1 09/08/20 19.08 by Anggrayudi Hardiannico A. */ +@Deprecated( + "Superseded in v3 by StorageAccessManager and the contracts in com.anggrayudi.storage.contract. This class still relies on startActivityForResult and request codes. See MIGRATION.md.", + ReplaceWith("com.anggrayudi.storage.access.StorageAccessManager"), +) class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { // For unknown Activity type @@ -191,7 +195,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { /** * Show interactive UI to create a file. * - * @param initialPath only takes effect on API 26+ */ @Deprecated( "This function doesn't follow Google's latest method, because it still uses startActivityForResult() manually.", @@ -222,7 +225,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { createFileCallback?.onActivityHandlerNotFound(requestCode, intent) } - /** @param initialPath only works for API 26+ */ @Deprecated( "This function doesn't follow Google's latest method, because it still uses startActivityForResult() manually.", ReplaceWith("OpenFolderPickerContract() with ActivityResultLauncher"), @@ -259,7 +261,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { private var lastVisitedFolder: File = Environment.getExternalStorageDirectory() - /** @param initialPath only takes effect on API 26+ */ @Deprecated( "This function doesn't follow Google's latest method, because it still uses startActivityForResult() manually.", ReplaceWith("OpenFilePickerContract() with ActivityResultLauncher"), @@ -564,16 +565,12 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { get() = Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED @JvmStatic - @SuppressLint("InlinedApi") fun getDefaultExternalStorageIntent(context: Context): Intent { - return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { - if (Build.VERSION.SDK_INT >= 26) { - putExtra( - DocumentsContract.EXTRA_INITIAL_URI, - context.fromTreeUri(DocumentFileCompat.createDocumentUri(PRIMARY))?.uri, - ) - } - } + return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .putExtra( + DocumentsContract.EXTRA_INITIAL_URI, + context.fromTreeUri(DocumentFileCompat.createDocumentUri(PRIMARY))?.uri, + ) } /** For read and write permissions */ diff --git a/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt b/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt index 4523c07..366feff 100644 --- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt +++ b/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt @@ -35,6 +35,10 @@ import com.anggrayudi.storage.permission.PermissionResult * * @author Anggrayudi H. */ +@Deprecated( + "Superseded in v3 by StorageAccessManager, which is contracts-based, dialog-free, and exposes suspend functions instead of callbacks. See MIGRATION.md.", + ReplaceWith("com.anggrayudi.storage.access.StorageAccessManager"), +) class SimpleStorageHelper { val storage: SimpleStorage diff --git a/storage/src/main/java/com/anggrayudi/storage/StorageFile.kt b/storage/src/main/java/com/anggrayudi/storage/StorageFile.kt new file mode 100644 index 0000000..56aa73f --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/StorageFile.kt @@ -0,0 +1,292 @@ +package com.anggrayudi.storage + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.file.DocumentFileCompat +import com.anggrayudi.storage.file.PublicDirectory +import com.anggrayudi.storage.extension.openInputStream +import com.anggrayudi.storage.extension.openOutputStream +import com.anggrayudi.storage.file.child +import com.anggrayudi.storage.file.getAbsolutePath +import com.anggrayudi.storage.file.getBasePath +import com.anggrayudi.storage.file.getStorageId +import com.anggrayudi.storage.file.isWritable +import com.anggrayudi.storage.file.toMediaFile +import com.anggrayudi.storage.file.toRawFile +import com.anggrayudi.storage.media.MediaFile +import java.io.File +import java.io.InputStream +import java.io.OutputStream + +/** + * A single abstraction over the three file worlds on Android — SAF ([DocumentFile]), MediaStore + * ([MediaFile]), and direct paths ([java.io.File]) — so callers no longer need to know which one + * they are holding. Obtain instances via the factories in [StorageFile.Companion], then operate + * with the extension functions in `StorageFileTransfer.kt` (`copyTo`, `moveTo`, `zipTo`, …). + * + * Implementations hold an application [Context] internally, so no function here asks for one. + * + * @author Anggrayudi H + */ +sealed interface StorageFile { + + val uri: Uri + val name: String + + /** MIME type, or `null` for folders and unknown types. */ + val mimeType: String? + + val length: Long + val isDirectory: Boolean + val isFile: Boolean + val exists: Boolean + + /** Milliseconds since epoch, or `0` when unknown. */ + val lastModified: Long + + /** + * Absolute filesystem path like `/storage/emulated/0/Download/movie.mp4`, or `null` when the + * file has no resolvable physical path (e.g. a `SingleDocumentFile` from the downloads + * provider). This replaces v2's empty-string convention. + */ + val absolutePath: String? + + /** [StoragePath] form of [absolutePath], or `null` for the same reason. */ + val path: StoragePath? + + val canRead: Boolean + val canWrite: Boolean + + fun openInputStream(): InputStream? + + fun openOutputStream(append: Boolean = false): OutputStream? + + /** Direct children when [isDirectory], empty otherwise. */ + fun list(): List + + /** Resolves a direct or nested child by path like `docs/report.pdf`. */ + fun child(path: String, requiresWriteAccess: Boolean = false): StorageFile? + + fun delete(): Boolean + + // Escape hatches to the underlying worlds: + fun asDocumentFile(): DocumentFile? + + fun asMediaFile(): MediaFile? + + fun asRawFile(): File? + + companion object { + /** + * Wraps any URI this library understands: SAF tree/single URIs, `file://` URIs, and + * MediaStore URIs (`content://media/...`). + */ + @JvmStatic + fun from(context: Context, uri: Uri): StorageFile? { + val appContext = context.applicationContext + if (uri.authority == MEDIA_AUTHORITY) { + return MediaStorageFile(appContext, MediaFile(appContext, uri)) + } + return DocumentFileCompat.fromUri(appContext, uri)?.let { DocumentStorageFile(appContext, it) } + } + + @JvmStatic + fun from(context: Context, file: File): StorageFile = + DocumentStorageFile(context.applicationContext, DocumentFile.fromFile(file)) + + /** Resolves a [StoragePath]; returns `null` when the path is not accessible. */ + @JvmStatic + @JvmOverloads + fun fromPath( + context: Context, + path: StoragePath, + requiresWriteAccess: Boolean = false, + ): StorageFile? { + val appContext = context.applicationContext + return DocumentFileCompat.fromSimplePath( + appContext, + path.storageId, + path.basePath, + requiresWriteAccess = requiresWriteAccess, + ) + ?.let { DocumentStorageFile(appContext, it) } + } + + /** Resolves an absolute path like `/storage/emulated/0/Download/movie.mp4`. */ + @JvmStatic + @JvmOverloads + fun fromPath( + context: Context, + absolutePath: String, + requiresWriteAccess: Boolean = false, + ): StorageFile? { + val appContext = context.applicationContext + return DocumentFileCompat.fromFullPath( + appContext, + absolutePath, + requiresWriteAccess = requiresWriteAccess, + ) + ?.let { DocumentStorageFile(appContext, it) } + } + + @JvmStatic + @JvmOverloads + fun fromPublicDirectory( + context: Context, + type: PublicDirectory, + subFile: String = "", + requiresWriteAccess: Boolean = false, + ): StorageFile? { + val appContext = context.applicationContext + return DocumentFileCompat.fromPublicFolder(appContext, type, subFile, requiresWriteAccess) + ?.let { DocumentStorageFile(appContext, it) } + } + + private const val MEDIA_AUTHORITY = "media" + } +} + +fun DocumentFile.toStorageFile(context: Context): StorageFile = + DocumentStorageFile(context.applicationContext, this) + +fun MediaFile.toStorageFile(context: Context): StorageFile = + MediaStorageFile(context.applicationContext, this) + +fun File.toStorageFile(context: Context): StorageFile = StorageFile.from(context, this) + +fun Uri.toStorageFile(context: Context): StorageFile? = StorageFile.from(context, this) + +internal class DocumentStorageFile( + internal val context: Context, + internal val doc: DocumentFile, +) : StorageFile { + + override val uri: Uri + get() = doc.uri + + override val name: String + get() = doc.name.orEmpty() + + override val mimeType: String? + get() = doc.type + + override val length: Long + get() = doc.length() + + override val isDirectory: Boolean + get() = doc.isDirectory + + override val isFile: Boolean + get() = doc.isFile + + override val exists: Boolean + get() = doc.exists() + + override val lastModified: Long + get() = doc.lastModified() + + override val absolutePath: String? + get() = doc.getAbsolutePath(context).takeIf { it.isNotEmpty() } + + override val path: StoragePath? + get() = + if (absolutePath == null) null + else StoragePath(doc.getStorageId(context), doc.getBasePath(context)) + + override val canRead: Boolean + get() = doc.canRead() + + override val canWrite: Boolean + get() = doc.isWritable(context) + + override fun openInputStream(): InputStream? = doc.uri.openInputStream(context) + + override fun openOutputStream(append: Boolean): OutputStream? = + doc.uri.openOutputStream(context, append) + + override fun list(): List = + if (isDirectory) doc.listFiles().map { DocumentStorageFile(context, it) } else emptyList() + + override fun child(path: String, requiresWriteAccess: Boolean): StorageFile? = + doc.child(context, path, requiresWriteAccess)?.let { DocumentStorageFile(context, it) } + + override fun delete(): Boolean = doc.delete() + + override fun asDocumentFile(): DocumentFile = doc + + override fun asMediaFile(): MediaFile? = doc.toMediaFile(context) + + override fun asRawFile(): File? = doc.toRawFile(context) + + override fun equals(other: Any?): Boolean = + other is DocumentStorageFile && other.uri == uri + + override fun hashCode(): Int = uri.hashCode() + + override fun toString(): String = uri.toString() +} + +internal class MediaStorageFile( + internal val context: Context, + internal val media: MediaFile, +) : StorageFile { + + override val uri: Uri + get() = media.uri + + override val name: String + get() = media.fullName + + override val mimeType: String? + get() = media.type + + override val length: Long + get() = media.length + + override val isDirectory: Boolean + get() = false + + override val isFile: Boolean + get() = true + + override val exists: Boolean + get() = media.toRawFile()?.exists() ?: (media.length > 0 || media.presentsInSafDatabase) + + override val lastModified: Long + get() = media.lastModified + + override val absolutePath: String? + get() = media.absolutePath.takeIf { it.isNotEmpty() } + + override val path: StoragePath? + get() = absolutePath?.let { StoragePath.fromAbsolutePath(context, it) } + + override val canRead: Boolean + get() = media.toRawFile()?.canRead() ?: true + + override val canWrite: Boolean + get() = media.toRawFile()?.canWrite() ?: media.isMine + + override fun openInputStream(): InputStream? = media.openInputStream() + + override fun openOutputStream(append: Boolean): OutputStream? = media.openOutputStream(append) + + override fun list(): List = emptyList() + + override fun child(path: String, requiresWriteAccess: Boolean): StorageFile? = null + + override fun delete(): Boolean = media.delete() + + override fun asDocumentFile(): DocumentFile? = media.toDocumentFile() + + override fun asMediaFile(): MediaFile = media + + override fun asRawFile(): File? = media.toRawFile() + + override fun equals(other: Any?): Boolean = other is MediaStorageFile && other.uri == uri + + override fun hashCode(): Int = uri.hashCode() + + override fun toString(): String = uri.toString() +} diff --git a/storage/src/main/java/com/anggrayudi/storage/StorageFileTransfer.kt b/storage/src/main/java/com/anggrayudi/storage/StorageFileTransfer.kt new file mode 100644 index 0000000..602e6c5 --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/StorageFileTransfer.kt @@ -0,0 +1,577 @@ +package com.anggrayudi.storage + +import android.content.Context +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.callback.SingleFileConflictCallback +import com.anggrayudi.storage.callback.SingleFolderConflictCallback +import com.anggrayudi.storage.file.CheckFileSize +import com.anggrayudi.storage.file.DocumentFileType +import com.anggrayudi.storage.file.compressToZip +import com.anggrayudi.storage.file.copyFileTo +import com.anggrayudi.storage.file.copyFolderTo +import com.anggrayudi.storage.file.decompressZip +import com.anggrayudi.storage.file.defaultFileSizeChecker +import com.anggrayudi.storage.file.deleteRecursively +import com.anggrayudi.storage.file.moveFileTo +import com.anggrayudi.storage.file.moveFolderTo +import com.anggrayudi.storage.file.search +import com.anggrayudi.storage.media.MediaFile +import com.anggrayudi.storage.media.decompressZip +import com.anggrayudi.storage.result.FolderErrorCode +import com.anggrayudi.storage.result.SingleFileErrorCode +import com.anggrayudi.storage.result.SingleFileResult +import com.anggrayudi.storage.result.SingleFolderResult +import com.anggrayudi.storage.result.ZipCompressionErrorCode +import com.anggrayudi.storage.result.ZipCompressionResult +import com.anggrayudi.storage.result.ZipDecompressionErrorCode +import com.anggrayudi.storage.result.ZipDecompressionResult +import com.anggrayudi.storage.transfer.Conflict +import com.anggrayudi.storage.transfer.ConflictResolution +import com.anggrayudi.storage.transfer.TransferErrorCode +import com.anggrayudi.storage.transfer.TransferEvent +import com.anggrayudi.storage.transfer.TransferPhase +import com.anggrayudi.storage.transfer.TransferResult +import com.anggrayudi.storage.transfer.TransferSpec +import com.anggrayudi.storage.transfer.TransferStats +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Transfer operations for [StorageFile]. Every operation exists in two forms: + * - a **one-shot suspend function** (`copyTo`, `moveTo`, `zipTo`, `unzipTo`) that is main-safe and + * returns a [TransferResult], with optional progress/conflict callbacks via [TransferSpec]; + * - a **Flow form** (`copyToAsFlow`, …) that emits every [TransferEvent] for callers who need the + * full stream (e.g. WorkManager notifications). + * + * @author Anggrayudi H + */ + +// region One-shot operations + +/** + * Copies this file or folder into [targetFolder]. + * + * ```kotlin + * val result = file.copyTo(downloads) { + * onConflict { ConflictResolution.REPLACE } + * onProgress { progressBar.progress = it.percent.toInt() } + * } + * ``` + */ +suspend fun StorageFile.copyTo( + targetFolder: StorageFile, + configure: TransferSpec.() -> Unit = {}, +): TransferResult { + val spec = TransferSpec().apply(configure) + return spec.await(copyToAsFlow(targetFolder, spec)) +} + +/** Moves this file or folder into [targetFolder]. */ +suspend fun StorageFile.moveTo( + targetFolder: StorageFile, + configure: TransferSpec.() -> Unit = {}, +): TransferResult { + val spec = TransferSpec().apply(configure) + return spec.await(moveToAsFlow(targetFolder, spec)) +} + +/** Compresses these files/folders into [targetZipFile], which must already exist. */ +suspend fun List.zipTo( + targetZipFile: StorageFile, + configure: TransferSpec.() -> Unit = {}, +): TransferResult { + val spec = TransferSpec().apply(configure) + return spec.await(zipToAsFlow(targetZipFile, spec)) +} + +/** Extracts this ZIP file into [targetFolder]. */ +suspend fun StorageFile.unzipTo( + targetFolder: StorageFile, + configure: TransferSpec.() -> Unit = {}, +): TransferResult { + val spec = TransferSpec().apply(configure) + return spec.await(unzipToAsFlow(targetFolder, spec)) +} + +/** Recursively deletes this file or folder. Main-safe. */ +suspend fun StorageFile.deleteRecursively(childrenOnly: Boolean = false): Boolean = + withContext(Dispatchers.IO) { + when (this@deleteRecursively) { + is DocumentStorageFile -> doc.deleteRecursively(context, childrenOnly) + is MediaStorageFile -> media.delete() + } + } + +// endregion + +// region Flow operations + +fun StorageFile.copyToAsFlow( + targetFolder: StorageFile, + spec: TransferSpec = TransferSpec(), +): Flow = transferFlow(this, targetFolder, spec, move = false) + +fun StorageFile.moveToAsFlow( + targetFolder: StorageFile, + spec: TransferSpec = TransferSpec(), +): Flow = transferFlow(this, targetFolder, spec, move = true) + +fun List.zipToAsFlow( + targetZipFile: StorageFile, + spec: TransferSpec = TransferSpec(), +): Flow = channelFlow { + val context = firstOrNull()?.appContext + if (context == null) { + send(failure(TransferErrorCode.SOURCE_NOT_FOUND, "No files to compress")) + return@channelFlow + } + val sources = ArrayList(size) + for (file in this@zipToAsFlow) { + val doc = file.asDocumentFile() + if (doc == null) { + send(failure(TransferErrorCode.SOURCE_NOT_FOUND, "Cannot resolve ${file.name}")) + return@channelFlow + } + sources.add(doc) + } + val targetDoc = targetZipFile.asDocumentFile() + if (targetDoc == null) { + send(failure(TransferErrorCode.INVALID_TARGET, "Target ZIP file is not accessible")) + return@channelFlow + } + sources + .compressToZip( + context, + targetDoc, + spec.deleteSourceOnSuccess, + spec.updateInterval, + spec.sizeChecker(), + ) + .collect { send(it.toTransferEvent(context, spec)) } +} + +fun StorageFile.unzipToAsFlow( + targetFolder: StorageFile, + spec: TransferSpec = TransferSpec(), +): Flow = channelFlow { + val context = appContext + val targetDoc = targetFolder.asDocumentFile() + if (targetDoc == null || !targetDoc.isDirectory) { + send(failure(TransferErrorCode.INVALID_TARGET, "Target must be an accessible folder")) + return@channelFlow + } + when (val source = this@unzipToAsFlow) { + is DocumentStorageFile -> + source.doc + .decompressZip( + context, + targetDoc, + spec.updateInterval, + spec.sizeChecker(), + fileConflictAdapter(context, spec, this), + ) + .collect { send(it.toTransferEvent(context)) } + is MediaStorageFile -> + source.media.decompressZip(context, targetDoc, spec.updateInterval).collect { + send(it.toTransferEvent(context)) + } + } +} + +/** Searches inside this folder. Emits snapshots every [updateInterval] ms when it is > 0. */ +@JvmOverloads +fun StorageFile.search( + recursive: Boolean = true, + documentType: DocumentFileType = DocumentFileType.ANY, + mimeTypes: Array? = null, + name: String = "", + regex: Regex? = null, + updateInterval: Long = 0, +): Flow> = + when (this) { + is DocumentStorageFile -> + doc.search(recursive, documentType, mimeTypes, name, regex, updateInterval).map { files -> + files.map { it.toStorageFile(context) } + } + is MediaStorageFile -> flowOf(emptyList()) + } + +// endregion + +// region Internals + +internal val StorageFile.appContext: Context + get() = + when (this) { + is DocumentStorageFile -> context + is MediaStorageFile -> context + } + +private fun transferFlow( + source: StorageFile, + target: StorageFile, + spec: TransferSpec, + move: Boolean, +): Flow = channelFlow { + val context = source.appContext + val targetDoc = target.asDocumentFile() + if (targetDoc == null || !targetDoc.isDirectory) { + send(failure(TransferErrorCode.INVALID_TARGET, "Target must be an accessible folder")) + return@channelFlow + } + val checker = spec.sizeChecker() + when (source) { + is DocumentStorageFile -> { + if (source.doc.isDirectory) { + val flow = + if (move) { + source.doc.moveFolderTo( + context, + targetDoc, + spec.skipEmptyFiles, + spec.fileDescription?.name, + spec.updateInterval, + checker, + folderConflictAdapter(context, spec, this), + ) + } else { + source.doc.copyFolderTo( + context, + targetDoc, + spec.skipEmptyFiles, + spec.fileDescription?.name, + spec.updateInterval, + checker, + folderConflictAdapter(context, spec, this), + ) + } + flow.collect { send(it.toTransferEvent(context)) } + } else { + val flow = + if (move) { + source.doc.moveFileTo( + context, + targetDoc, + spec.fileDescription, + spec.updateInterval, + checker, + fileConflictAdapter(context, spec, this), + ) + } else { + source.doc.copyFileTo( + context, + targetDoc, + spec.fileDescription, + spec.updateInterval, + checker, + fileConflictAdapter(context, spec, this), + ) + } + flow.collect { send(it.toTransferEvent(context, spec)) } + } + } + is MediaStorageFile -> { + val flow = + if (move) { + source.media.moveTo( + targetDoc, + spec.fileDescription, + spec.updateInterval, + checker, + fileConflictAdapter(context, spec, this), + ) + } else { + source.media.copyTo( + targetDoc, + spec.fileDescription, + spec.updateInterval, + checker, + fileConflictAdapter(context, spec, this), + ) + } + flow.collect { send(it.toTransferEvent(context, spec)) } + } + } +} + +private suspend fun TransferSpec.await(flow: Flow): TransferResult { + var terminal: TransferResult? = null + flow.collect { event -> + when (event) { + is TransferEvent.Progress -> progressListener?.invoke(event) + is TransferEvent.Completed<*> -> { + @Suppress("UNCHECKED_CAST") + terminal = event.result as TransferResult + } + is TransferEvent.PhaseChanged -> Unit + } + } + return terminal + ?: TransferResult.Failure( + TransferErrorCode.UNKNOWN_IO_ERROR, + "Transfer finished without a terminal event", + ) +} + +private fun TransferSpec.sizeChecker(): CheckFileSize = + if (checkAvailableSpace) defaultFileSizeChecker else { _, _ -> true } + +private fun failure(code: TransferErrorCode, message: String? = null) = + TransferEvent.Completed(TransferResult.Failure(code, message)) + +private fun phase(phase: TransferPhase) = TransferEvent.PhaseChanged(phase) + +private fun fileConflictAdapter( + context: Context, + spec: TransferSpec, + scope: CoroutineScope, +): SingleFileConflictCallback = + object : SingleFileConflictCallback(scope) { + override fun onFileConflict(destinationFile: DocumentFile, action: FileConflictAction) { + scope.launch { + val resolution = + spec.conflictResolver.resolve(Conflict.TargetFile(destinationFile.toStorageFile(context))) + action.confirmResolution(resolution.toV2FileResolution()) + } + } + } + +private fun folderConflictAdapter( + context: Context, + spec: TransferSpec, + scope: CoroutineScope, +): SingleFolderConflictCallback = + object : SingleFolderConflictCallback(scope) { + override fun onParentConflict( + destinationFolder: DocumentFile, + action: ParentFolderConflictAction, + canMerge: Boolean, + ) { + scope.launch { + val resolution = + spec.conflictResolver.resolve( + Conflict.TargetFolder(destinationFolder.toStorageFile(context), canMerge) + ) + val v2 = + when (resolution) { + com.anggrayudi.storage.transfer.ConflictResolution.REPLACE -> + SingleFolderConflictCallback.ConflictResolution.REPLACE + com.anggrayudi.storage.transfer.ConflictResolution.MERGE -> + if (canMerge) SingleFolderConflictCallback.ConflictResolution.MERGE + else SingleFolderConflictCallback.ConflictResolution.CREATE_NEW + com.anggrayudi.storage.transfer.ConflictResolution.CREATE_NEW -> + SingleFolderConflictCallback.ConflictResolution.CREATE_NEW + com.anggrayudi.storage.transfer.ConflictResolution.SKIP -> + SingleFolderConflictCallback.ConflictResolution.SKIP + } + action.confirmResolution(v2) + } + } + + override fun onContentConflict( + destinationFolder: DocumentFile, + conflictedFiles: MutableList, + action: FolderContentConflictAction, + ) { + scope.launch { + conflictedFiles.forEach { conflict -> + conflict.solution = + spec.conflictResolver + .resolve(Conflict.TargetFile(conflict.target.toStorageFile(context))) + .toV2FileResolution() + } + action.confirmResolution(conflictedFiles) + } + } + } + +private fun ConflictResolution.toV2FileResolution(): SingleFileConflictCallback.ConflictResolution = + when (this) { + ConflictResolution.REPLACE -> SingleFileConflictCallback.ConflictResolution.REPLACE + ConflictResolution.MERGE, + ConflictResolution.CREATE_NEW -> SingleFileConflictCallback.ConflictResolution.CREATE_NEW + ConflictResolution.SKIP -> SingleFileConflictCallback.ConflictResolution.SKIP + } + +private fun bytesPerSecond(bytesPerInterval: Int, updateInterval: Long): Long = + if (updateInterval <= 0) 0 else bytesPerInterval * 1000L / updateInterval + +private fun Any.wrapResult(context: Context): TransferResult = + when (this) { + is DocumentFile -> + TransferResult.Success(toStorageFile(context), TransferStats(1, 1, length())) + is MediaFile -> TransferResult.Success(toStorageFile(context), TransferStats(1, 1, length)) + else -> + TransferResult.Failure( + TransferErrorCode.UNKNOWN_IO_ERROR, + "Unexpected result type: ${this::class.qualifiedName}", + ) + } + +private fun SingleFileResult.toTransferEvent(context: Context, spec: TransferSpec): TransferEvent = + when (this) { + SingleFileResult.Validating -> phase(TransferPhase.VALIDATING) + SingleFileResult.Preparing -> phase(TransferPhase.PREPARING) + SingleFileResult.CountingFiles -> phase(TransferPhase.COUNTING_FILES) + SingleFileResult.DeletingConflictedFile -> phase(TransferPhase.DELETING_CONFLICTED_FILES) + is SingleFileResult.Starting -> phase(TransferPhase.STARTING) + is SingleFileResult.InProgress -> + TransferEvent.Progress( + progress, + bytesMoved, + bytesPerSecond(writeSpeed, spec.updateInterval), + filesCompleted = 0, + totalFiles = 1, + ) + is SingleFileResult.Completed -> TransferEvent.Completed(result.wrapResult(context)) + is SingleFileResult.Error -> + TransferEvent.Completed( + TransferResult.Failure(errorCode.toTransferError(), message, cause) + ) + } + +private fun SingleFolderResult.toTransferEvent(context: Context): TransferEvent = + when (this) { + SingleFolderResult.Validating -> phase(TransferPhase.VALIDATING) + SingleFolderResult.Preparing -> phase(TransferPhase.PREPARING) + SingleFolderResult.CountingFiles -> phase(TransferPhase.COUNTING_FILES) + SingleFolderResult.DeletingConflictedFiles -> phase(TransferPhase.DELETING_CONFLICTED_FILES) + is SingleFolderResult.Starting -> phase(TransferPhase.STARTING) + is SingleFolderResult.InProgress -> + TransferEvent.Progress(progress, bytesMoved, writeSpeed.toLong(), fileCount, 0) + is SingleFolderResult.Completed -> { + val stats = TransferStats(totalFilesToCopy, totalCopiedFiles, 0) + if (success) { + TransferEvent.Completed(TransferResult.Success(folder.toStorageFile(context), stats)) + } else { + TransferEvent.Completed( + TransferResult.Failure( + TransferErrorCode.UNKNOWN_IO_ERROR, + "Some files could not be transferred", + partialStats = stats, + ) + ) + } + } + is SingleFolderResult.Error -> + TransferEvent.Completed( + TransferResult.Failure( + errorCode.toTransferError(), + message, + cause, + completedData?.let { TransferStats(it.totalFilesToCopy, it.totalCopiedFiles, 0) }, + ) + ) + } + +private fun ZipCompressionResult.toTransferEvent( + context: Context, + spec: TransferSpec, +): TransferEvent = + when (this) { + ZipCompressionResult.CountingFiles -> phase(TransferPhase.COUNTING_FILES) + ZipCompressionResult.DeletingEntryFiles -> phase(TransferPhase.DELETING_SOURCE_FILES) + is ZipCompressionResult.Compressing -> + TransferEvent.Progress( + progress, + bytesCompressed, + bytesPerSecond(writeSpeed, spec.updateInterval), + fileCount, + 0, + ) + is ZipCompressionResult.Completed -> + TransferEvent.Completed( + TransferResult.Success( + zipFile.toStorageFile(context), + TransferStats(totalFilesCompressed, totalFilesCompressed, bytesCompressed), + ) + ) + is ZipCompressionResult.Error -> + TransferEvent.Completed( + TransferResult.Failure(errorCode.toTransferError(), message, cause) + ) + } + +private fun ZipDecompressionResult.toTransferEvent(context: Context): TransferEvent = + when (this) { + ZipDecompressionResult.Validating -> phase(TransferPhase.VALIDATING) + is ZipDecompressionResult.Decompressing -> + // ZIP entries don't expose a total size up front, so percent is indeterminate (-1). + TransferEvent.Progress(-1f, bytesDecompressed, writeSpeed.toLong(), fileCount, 0) + is ZipDecompressionResult.Completed -> + TransferEvent.Completed( + TransferResult.Success( + targetFolder.toStorageFile(context), + TransferStats(totalFilesDecompressed, totalFilesDecompressed, bytesDecompressed), + ) + ) + is ZipDecompressionResult.Error -> + TransferEvent.Completed( + TransferResult.Failure(errorCode.toTransferError(), message, cause) + ) + } + +private fun SingleFileErrorCode.toTransferError(): TransferErrorCode = + when (this) { + SingleFileErrorCode.STORAGE_PERMISSION_DENIED -> TransferErrorCode.STORAGE_PERMISSION_DENIED + SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET -> + TransferErrorCode.CANNOT_CREATE_FILE_IN_TARGET + SingleFileErrorCode.SOURCE_FILE_NOT_FOUND -> TransferErrorCode.SOURCE_NOT_FOUND + SingleFileErrorCode.TARGET_FILE_NOT_FOUND, + SingleFileErrorCode.TARGET_FOLDER_NOT_FOUND -> TransferErrorCode.TARGET_NOT_FOUND + SingleFileErrorCode.UNKNOWN_IO_ERROR -> TransferErrorCode.UNKNOWN_IO_ERROR + SingleFileErrorCode.CANCELED -> TransferErrorCode.CANCELED + SingleFileErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER -> + TransferErrorCode.TARGET_SAME_AS_SOURCE + SingleFileErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH -> TransferErrorCode.NO_SPACE_LEFT_ON_TARGET + } + +private fun FolderErrorCode.toTransferError(): TransferErrorCode = + when (this) { + FolderErrorCode.STORAGE_PERMISSION_DENIED -> TransferErrorCode.STORAGE_PERMISSION_DENIED + FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET -> TransferErrorCode.CANNOT_CREATE_FILE_IN_TARGET + FolderErrorCode.SOURCE_FOLDER_NOT_FOUND, + FolderErrorCode.SOURCE_FILE_NOT_FOUND -> TransferErrorCode.SOURCE_NOT_FOUND + FolderErrorCode.INVALID_TARGET_FOLDER -> TransferErrorCode.INVALID_TARGET + FolderErrorCode.UNKNOWN_IO_ERROR -> TransferErrorCode.UNKNOWN_IO_ERROR + FolderErrorCode.CANCELED -> TransferErrorCode.CANCELED + FolderErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER -> + TransferErrorCode.TARGET_SAME_AS_SOURCE + FolderErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH -> TransferErrorCode.NO_SPACE_LEFT_ON_TARGET + } + +private fun ZipCompressionErrorCode.toTransferError(): TransferErrorCode = + when (this) { + ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED -> + TransferErrorCode.STORAGE_PERMISSION_DENIED + ZipCompressionErrorCode.CANNOT_CREATE_FILE_IN_TARGET -> + TransferErrorCode.CANNOT_CREATE_FILE_IN_TARGET + ZipCompressionErrorCode.MISSING_ENTRY_FILE -> TransferErrorCode.MISSING_ZIP_ENTRY + ZipCompressionErrorCode.DUPLICATE_ENTRY_FILE -> TransferErrorCode.DUPLICATE_ZIP_ENTRY + ZipCompressionErrorCode.UNKNOWN_IO_ERROR -> TransferErrorCode.UNKNOWN_IO_ERROR + ZipCompressionErrorCode.CANCELED -> TransferErrorCode.CANCELED + ZipCompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH -> + TransferErrorCode.NO_SPACE_LEFT_ON_TARGET + } + +private fun ZipDecompressionErrorCode.toTransferError(): TransferErrorCode = + when (this) { + ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED -> + TransferErrorCode.STORAGE_PERMISSION_DENIED + ZipDecompressionErrorCode.CANNOT_CREATE_FILE_IN_TARGET -> + TransferErrorCode.CANNOT_CREATE_FILE_IN_TARGET + ZipDecompressionErrorCode.MISSING_ZIP_FILE -> TransferErrorCode.SOURCE_NOT_FOUND + ZipDecompressionErrorCode.NOT_A_ZIP_FILE -> TransferErrorCode.NOT_A_ZIP_FILE + ZipDecompressionErrorCode.UNKNOWN_IO_ERROR -> TransferErrorCode.UNKNOWN_IO_ERROR + ZipDecompressionErrorCode.CANCELED -> TransferErrorCode.CANCELED + ZipDecompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH -> + TransferErrorCode.NO_SPACE_LEFT_ON_TARGET + } + +// endregion diff --git a/storage/src/main/java/com/anggrayudi/storage/StoragePath.kt b/storage/src/main/java/com/anggrayudi/storage/StoragePath.kt new file mode 100644 index 0000000..f34eaf7 --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/StoragePath.kt @@ -0,0 +1,40 @@ +package com.anggrayudi.storage + +import android.content.Context +import com.anggrayudi.storage.file.DocumentFileCompat +import com.anggrayudi.storage.file.StorageId +import java.io.File + +/** + * Identifies a location on a storage volume without holding a [Context]. + * + * A path is a pair of [storageId] (e.g. [StorageId.PRIMARY] or an SD card ID like `AAAA-BBBB`) and + * a [basePath] relative to that volume's root (e.g. `Download/MyMovie.mp4`). This replaces the v2 + * combination of `FileFullPath` and "simple path" strings. + * + * @author Anggrayudi H + */ +data class StoragePath(val storageId: String, val basePath: String = "") { + + /** Resolves this path to an absolute path like `/storage/emulated/0/Download/MyMovie.mp4`. */ + fun toAbsolutePath(context: Context): String = + DocumentFileCompat.buildAbsolutePath(context, storageId, basePath) + + override fun toString(): String = "$storageId:$basePath" + + companion object { + /** Creates a path on the primary/external storage volume. */ + @JvmStatic fun primary(basePath: String = ""): StoragePath = StoragePath(StorageId.PRIMARY, basePath) + + /** Parses an absolute path like `/storage/AAAA-BBBB/Download` or a `storageId:basePath` string. */ + @JvmStatic + fun fromAbsolutePath(context: Context, fullPath: String): StoragePath = + StoragePath( + DocumentFileCompat.getStorageId(context, fullPath), + DocumentFileCompat.getBasePath(context, fullPath), + ) + + @JvmStatic + fun from(context: Context, file: File): StoragePath = fromAbsolutePath(context, file.absolutePath) + } +} diff --git a/storage/src/main/java/com/anggrayudi/storage/access/StorageAccessManager.kt b/storage/src/main/java/com/anggrayudi/storage/access/StorageAccessManager.kt new file mode 100644 index 0000000..074eaf0 --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/access/StorageAccessManager.kt @@ -0,0 +1,279 @@ +package com.anggrayudi.storage.access + +import android.content.ActivityNotFoundException +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import com.anggrayudi.storage.StorageFile +import com.anggrayudi.storage.StoragePath +import com.anggrayudi.storage.contract.FileCreationContract +import com.anggrayudi.storage.contract.FileCreationResult +import com.anggrayudi.storage.contract.FilePickerResult +import com.anggrayudi.storage.contract.FolderPickerResult +import com.anggrayudi.storage.contract.OpenFilePickerContract +import com.anggrayudi.storage.contract.OpenFolderPickerContract +import com.anggrayudi.storage.contract.RequestStorageAccessContract +import com.anggrayudi.storage.contract.RequestStorageAccessResult +import com.anggrayudi.storage.contract.StoragePermissionContract +import com.anggrayudi.storage.contract.StoragePermissionDeniedException +import com.anggrayudi.storage.file.FileFullPath +import com.anggrayudi.storage.toStorageFile +import kotlin.coroutines.resume +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * The outcome of [StorageAccessManager.ensureAccess]. + * + * @author Anggrayudi H + */ +sealed interface AccessResult { + /** URI permission for the requested path is held; [folder] is ready for read/write. */ + data class Granted(val folder: StorageFile) : AccessResult + + /** + * The user granted access to [grantedRoot] (possibly `null` when nothing was resolvable), but it + * does not cover the requested path. Callers usually explain and call + * [StorageAccessManager.ensureAccess] again. + */ + data class WrongRootSelected(val grantedRoot: StorageFile?) : AccessResult + + data object CanceledByUser : AccessResult + + /** Runtime storage permission was denied (only possible on API 26–29). */ + data object PermissionDenied : AccessResult +} + +/** + * Suspend-first replacement for `SimpleStorageHelper`, built purely on + * [ActivityResultContracts]: no request codes, no `onActivityResult`, no + * `onSaveInstanceState` plumbing, and no built-in dialogs to fight with. + * + * Create it during [ComponentActivity.onCreate] (launchers must be registered before the activity + * is started), then call the suspend functions from any coroutine: + * ```kotlin + * class MainActivity : AppCompatActivity() { + * private lateinit var storageAccess: StorageAccessManager + * + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * storageAccess = StorageAccessManager(this) + * ... + * lifecycleScope.launch { + * when (val access = storageAccess.ensureAccess(StoragePath.primary("Documents"))) { + * is AccessResult.Granted -> myFile.copyTo(access.folder) + * else -> showError() + * } + * } + * } + * } + * ``` + * + * @author Anggrayudi H + */ +class StorageAccessManager(activity: ComponentActivity) { + + private val appContext: Context = activity.applicationContext + private val mutex = Mutex() + + private var accessContinuation: CancellableContinuation? = null + private var folderContinuation: CancellableContinuation? = null + private var fileContinuation: CancellableContinuation? = null + private var creationContinuation: CancellableContinuation? = null + private var permissionContinuation: CancellableContinuation>? = null + private var mediaContinuation: CancellableContinuation>? = null + + private val accessLauncher = + activity.registerForActivityResult(RequestStorageAccessContract(appContext)) { result -> + accessContinuation?.resume(result) + accessContinuation = null + } + + private val folderLauncher = + activity.registerForActivityResult(OpenFolderPickerContract(appContext)) { result -> + folderContinuation?.resume(result) + folderContinuation = null + } + + private val fileLauncher = + activity.registerForActivityResult(OpenFilePickerContract(appContext)) { result -> + fileContinuation?.resume(result) + fileContinuation = null + } + + private val creationLauncher = + activity.registerForActivityResult(FileCreationContract(appContext)) { result -> + creationContinuation?.resume(result) + creationContinuation = null + } + + private val permissionLauncher = + activity.registerForActivityResult(StoragePermissionContract()) { result -> + permissionContinuation?.resume(result) + permissionContinuation = null + } + + private val mediaLauncher = + activity.registerForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(MAX_MEDIA_ITEMS) + ) { uris -> + mediaContinuation?.resume(uris) + mediaContinuation = null + } + + /** + * Makes sure this app holds read/write URI permission for [path], asking the user through SAF + * when it does not. Handles the runtime-permission dance on API 26–29 automatically. + */ + suspend fun ensureAccess( + path: StoragePath, + requiresWriteAccess: Boolean = true, + ): AccessResult = + mutex.withLock { + requestAccessLocked(path, requiresWriteAccess, retryAfterPermission = true) + } + + private suspend fun requestAccessLocked( + path: StoragePath, + requiresWriteAccess: Boolean, + retryAfterPermission: Boolean, + ): AccessResult { + StorageFile.fromPath(appContext, path, requiresWriteAccess)?.let { + return AccessResult.Granted(it) + } + + val options = + RequestStorageAccessContract.Options( + initialPath = FileFullPath(appContext, path.storageId, path.basePath) + ) + val result = + try { + awaitResult(accessLauncher, options) { accessContinuation = it } + } catch (_: StoragePermissionDeniedException) { + return if (retryAfterPermission && requestStoragePermission()) { + requestAccessLocked(path, requiresWriteAccess, retryAfterPermission = false) + } else { + AccessResult.PermissionDenied + } + } catch (_: ActivityNotFoundException) { + return AccessResult.CanceledByUser + } + + return when (result) { + is RequestStorageAccessResult.RootPathPermissionGranted -> { + StorageFile.fromPath(appContext, path, requiresWriteAccess)?.let { + AccessResult.Granted(it) + } ?: AccessResult.WrongRootSelected(result.root.toStorageFile(appContext)) + } + is RequestStorageAccessResult.RootPathNotSelected -> + AccessResult.WrongRootSelected(null) + is RequestStorageAccessResult.ExpectedStorageNotSelected -> + AccessResult.WrongRootSelected(result.selectedFolder.toStorageFile(appContext)) + is RequestStorageAccessResult.StoragePermissionDenied -> + if (retryAfterPermission && requestStoragePermission()) { + requestAccessLocked(path, requiresWriteAccess, retryAfterPermission = false) + } else { + AccessResult.PermissionDenied + } + is RequestStorageAccessResult.CanceledByUser -> AccessResult.CanceledByUser + } + } + + /** Opens the SAF folder picker and suspends until the user answers. */ + suspend fun pickFolder(initialPath: StoragePath? = null): FolderPickerResult = + mutex.withLock { + val options = OpenFolderPickerContract.Options(initialPath?.toFileFullPath()) + try { + awaitResult(folderLauncher, options) { folderContinuation = it } + } catch (_: ActivityNotFoundException) { + FolderPickerResult.CanceledByUser + } + } + + /** Opens the SAF file picker and suspends until the user answers. */ + suspend fun pickFiles( + allowMultiple: Boolean = false, + filterMimeTypes: Set = emptySet(), + initialPath: StoragePath? = null, + ): FilePickerResult = + mutex.withLock { + val options = + OpenFilePickerContract.Options(allowMultiple, initialPath?.toFileFullPath(), filterMimeTypes) + try { + awaitResult(fileLauncher, options) { fileContinuation = it } + } catch (_: ActivityNotFoundException) { + FilePickerResult.CanceledByUser + } + } + + /** Lets the user place a new file via SAF and suspends until the user answers. */ + suspend fun createFile( + mimeType: String, + fileName: String? = null, + initialPath: StoragePath? = null, + ): FileCreationResult = + mutex.withLock { + val options = FileCreationContract.Options(mimeType, fileName, initialPath?.toFileFullPath()) + try { + awaitResult(creationLauncher, options) { creationContinuation = it } + } catch (_: ActivityNotFoundException) { + FileCreationResult.CanceledByUser + } + } + + /** + * Opens the system Photo Picker ([ActivityResultContracts.PickVisualMedia]) — no permission and + * no SAF grant needed. Returns the picked media as [StorageFile]s, empty when canceled. + */ + suspend fun pickMedia( + type: ActivityResultContracts.PickVisualMedia.VisualMediaType = + ActivityResultContracts.PickVisualMedia.ImageAndVideo + ): List = + mutex.withLock { + val request = PickVisualMediaRequest(type) + val uris = + try { + awaitResult>(mediaLauncher, request) { mediaContinuation = it } + } catch (_: ActivityNotFoundException) { + emptyList() + } + uris.mapNotNull { StorageFile.from(appContext, it) } + } + + /** Requests READ/WRITE_EXTERNAL_STORAGE. Only meaningful on API 26–29; `true` elsewhere. */ + suspend fun requestStoragePermission(): Boolean { + val result = + try { + awaitResult>(permissionLauncher, Unit) { permissionContinuation = it } + } catch (_: ActivityNotFoundException) { + return false + } + return result.isNotEmpty() && result.values.all { it } + } + + private fun StoragePath.toFileFullPath(): FileFullPath = + FileFullPath(appContext, storageId, basePath) + + private suspend fun awaitResult( + launcher: ActivityResultLauncher, + input: I, + store: (CancellableContinuation?) -> Unit, + ): O = suspendCancellableCoroutine { continuation -> + store(continuation) + continuation.invokeOnCancellation { store(null) } + try { + launcher.launch(input) + } catch (e: Exception) { + store(null) + throw e + } + } + + private companion object { + const val MAX_MEDIA_ITEMS = 100 + } +} diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/CreateFileCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/CreateFileCallback.kt index 25c7cc2..1475b46 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/CreateFileCallback.kt +++ b/storage/src/main/java/com/anggrayudi/storage/callback/CreateFileCallback.kt @@ -8,6 +8,7 @@ import androidx.documentfile.provider.DocumentFile * * @author Anggrayudi H */ +@Deprecated("Superseded in v3 by StorageAccessManager.createFile(), which returns a FileCreationResult instead of using callbacks. See MIGRATION.md.") interface CreateFileCallback { fun onCanceledByUser(requestCode: Int) { diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/FilePickerCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/FilePickerCallback.kt index 9959815..7fa19aa 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/FilePickerCallback.kt +++ b/storage/src/main/java/com/anggrayudi/storage/callback/FilePickerCallback.kt @@ -8,6 +8,7 @@ import androidx.documentfile.provider.DocumentFile * * @author Anggrayudi H */ +@Deprecated("Superseded in v3 by StorageAccessManager.pickFiles(), which returns a FilePickerResult instead of using callbacks. See MIGRATION.md.") interface FilePickerCallback { fun onCanceledByUser(requestCode: Int) { diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/FolderPickerCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/FolderPickerCallback.kt index 35e0ad6..d64dbab 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/FolderPickerCallback.kt +++ b/storage/src/main/java/com/anggrayudi/storage/callback/FolderPickerCallback.kt @@ -10,6 +10,7 @@ import com.anggrayudi.storage.file.StorageType * * @author Anggrayudi H */ +@Deprecated("Superseded in v3 by StorageAccessManager.pickFolder(), which returns a FolderPickerResult instead of using callbacks. See MIGRATION.md.") interface FolderPickerCallback { fun onCanceledByUser(requestCode: Int) { diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/StorageAccessCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/StorageAccessCallback.kt index c5890e4..f6c1a12 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/StorageAccessCallback.kt +++ b/storage/src/main/java/com/anggrayudi/storage/callback/StorageAccessCallback.kt @@ -9,6 +9,7 @@ import com.anggrayudi.storage.file.StorageType * @author Anggrayudi Hardiannico A. (anggrayudi.hardiannico@dana.id) * @version StoragePermissionCallback, v 0.0.1 10/08/20 01.32 by Anggrayudi Hardiannico A. */ +@Deprecated("Superseded in v3 by StorageAccessManager.ensureAccess(), which returns an AccessResult instead of using callbacks. See MIGRATION.md.") interface StorageAccessCallback { fun onCanceledByUser(requestCode: Int) { diff --git a/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt b/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt index 9a94471..c136a80 100644 --- a/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt +++ b/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt @@ -14,7 +14,6 @@ import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions.Companion.ACTION_REQUEST_PERMISSIONS import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions.Companion.EXTRA_PERMISSIONS import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions.Companion.EXTRA_PERMISSION_GRANT_RESULTS -import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.EmptyActivity @@ -69,7 +68,6 @@ internal fun getExternalStorageRootAccessIntent(context: Context): Intent = * != External Storage. */ @Suppress("DEPRECATION") -@RequiresApi(api = Build.VERSION_CODES.N) internal fun getSdCardRootAccessIntent(context: Context): Intent { val sm = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager return sm.storageVolumes @@ -89,10 +87,8 @@ internal fun getSdCardRootAccessIntent(context: Context): Intent { } internal fun addInitialPathToIntent(context: Context, intent: Intent, initialPath: FileFullPath?) { - if (Build.VERSION.SDK_INT >= 26) { - initialPath?.toDocumentUri(context)?.let { - intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) - } + initialPath?.toDocumentUri(context)?.let { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) } } @@ -160,8 +156,7 @@ class OpenFolderPickerContract(context: Context) : it == DocumentFileCompat.DOWNLOADS_TREE_URI || it == DocumentFileCompat.DOCUMENTS_TREE_URI } || DocumentFileCompat.isRootUri(uri) && - (Build.VERSION.SDK_INT < Build.VERSION_CODES.N && storageType == StorageType.SD_CARD || - Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) && + Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && !DocumentFileCompat.isStorageUriPermissionGranted(appContext, storageId) ) { saveUriPermission(appContext, uri) @@ -182,7 +177,6 @@ class OpenFolderPickerContract(context: Context) : class Options @JvmOverloads constructor( - /** It only takes effect on API 26+ */ val initialPath: FileFullPath? = null ) } @@ -223,7 +217,6 @@ class OpenFilePickerContract(context: Context) : @JvmOverloads constructor( val allowMultiple: Boolean = false, - /** It only takes effect on API 26+ */ val initialPath: FileFullPath? = null, val filterMimeTypes: Set = emptySet(), ) @@ -324,7 +317,6 @@ class RequestStorageAccessContract( class Options @JvmOverloads constructor( - /** It only takes effect on API 26+ */ val initialPath: FileFullPath? = null ) @@ -356,9 +348,7 @@ class RequestStorageAccessContract( getExternalStorageRootAccessIntent(context).also { addInitialPathToIntent(context, it, input.initialPath) } - } else if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && expectedStorageType == StorageType.SD_CARD - ) { + } else if (expectedStorageType == StorageType.SD_CARD) { getSdCardRootAccessIntent(context) } else { getExternalStorageRootAccessIntent(context) @@ -465,10 +455,7 @@ class RequestStorageAccessContract( ) } else { var sdCardIntent: Intent? = null - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - Build.VERSION.SDK_INT < Build.VERSION_CODES.Q - ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { val sm = appContext.getSystemService(Context.STORAGE_SERVICE) as StorageManager @Suppress("DEPRECATION") sdCardIntent = sm.storageVolumes.firstOrNull { !it.isPrimary }?.createAccessIntent(null) diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt index 0d2e7ac..e3171f5 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt @@ -2278,11 +2278,7 @@ private fun DocumentFile.tryMoveFolderByRenamingPath( } try { - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - !isRawFile && - writableTargetParentFolder.isTreeDocumentFile - ) { + if (!isRawFile && writableTargetParentFolder.isTreeDocumentFile) { val movedFileUri = parentFile?.uri?.let { DocumentsContract.moveDocument( @@ -2641,6 +2637,12 @@ private fun DocumentFile.copyFolderTo( } it.solution != SingleFileConflictCallback.ConflictResolution.SKIP } + // `finalize()` below is called again after the conflicts above are resolved and copied, and it + // uses `conflictedFiles.isEmpty()` as its "are we really done" guard. Without clearing it here, + // that second call always sees the original (non-empty) list and skips sending + // SingleFolderResult.Completed, so the flow closes with no terminal event even though every + // file - including the conflicted ones - was copied successfully. + conflictedFiles.clear() val leftoverSize = totalSizeToCopy - bytesMoved startTimer(solutions.isNotEmpty() && leftoverSize > 10 * FileSize.MB) @@ -3184,8 +3186,7 @@ private fun DocumentFile.moveFileTo( try { if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - !isRawFile && + !isRawFile && writableTargetFolder.isTreeDocumentFile && getStorageId(context) == targetStorageId ) { diff --git a/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt b/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt index 775d853..6825fcc 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt @@ -70,7 +70,7 @@ fun File.child(path: String) = File(this, path) * @see [Context.getFilesDir] */ val Context.dataDirectory: File - get() = if (Build.VERSION.SDK_INT > 23) dataDir else filesDir.parentFile!! + get() = dataDir fun File.getBasePath(context: Context): String { val externalStoragePath = SimpleStorage.externalStoragePath diff --git a/storage/src/main/java/com/anggrayudi/storage/transfer/Conflict.kt b/storage/src/main/java/com/anggrayudi/storage/transfer/Conflict.kt new file mode 100644 index 0000000..b9862c6 --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/transfer/Conflict.kt @@ -0,0 +1,51 @@ +package com.anggrayudi.storage.transfer + +import com.anggrayudi.storage.StorageFile + +/** + * Raised while transferring when something already exists in the destination. + * + * @author Anggrayudi H + */ +sealed interface Conflict { + /** What already exists in the destination. */ + val target: StorageFile + + /** A file with the same name already exists in the destination folder. */ + data class TargetFile(override val target: StorageFile) : Conflict + + /** + * A folder with the same name already exists in the destination. + * + * @param canMerge `false` when the destination cannot be merged, e.g. a file occupies the + * folder's name; [ConflictResolution.MERGE] is then treated as [ConflictResolution.CREATE_NEW] + */ + data class TargetFolder(override val target: StorageFile, val canMerge: Boolean) : Conflict +} + +enum class ConflictResolution { + /** Delete the target, then transfer. */ + REPLACE, + + /** Folders only: write into the existing folder. On files this falls back to [CREATE_NEW]. */ + MERGE, + + /** Keep both: `ABC.zip` already exists, so create `ABC (1).zip`. */ + CREATE_NEW, + + /** Leave the target alone and skip this source. */ + SKIP, +} + +/** + * Decides what to do when a [Conflict] is found. Being a suspend function, it can freely switch to + * the main dispatcher and show a dialog — no extra [kotlinx.coroutines.CoroutineScope] is needed: + * ```kotlin + * onConflict { conflict -> + * withContext(Dispatchers.Main) { askUser(conflict.target.name) } + * } + * ``` + */ +fun interface ConflictResolver { + suspend fun resolve(conflict: Conflict): ConflictResolution +} diff --git a/storage/src/main/java/com/anggrayudi/storage/transfer/TransferEvent.kt b/storage/src/main/java/com/anggrayudi/storage/transfer/TransferEvent.kt new file mode 100644 index 0000000..872415b --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/transfer/TransferEvent.kt @@ -0,0 +1,84 @@ +package com.anggrayudi.storage.transfer + +/** + * A single vocabulary for events emitted by every long-running operation in this library (copy, + * move, zip, unzip), replacing the parallel v2 hierarchies (`SingleFileResult`, + * `SingleFolderResult`, `MultipleFilesResult`, `ZipCompressionResult`, `ZipDecompressionResult`). + * + * @author Anggrayudi H + */ +sealed interface TransferEvent { + + /** The operation moved to a new [TransferPhase]. */ + data class PhaseChanged(val phase: TransferPhase) : TransferEvent + + /** + * @param percent 0..100 + * @param bytesPerSecond current write speed in bytes per second + */ + data class Progress( + val percent: Float, + val bytesTransferred: Long, + val bytesPerSecond: Long, + val filesCompleted: Int = 0, + val totalFiles: Int = 0, + ) : TransferEvent + + /** Terminal event: the operation finished with [result]. */ + data class Completed(val result: TransferResult) : TransferEvent +} + +enum class TransferPhase { + VALIDATING, + PREPARING, + COUNTING_FILES, + DELETING_CONFLICTED_FILES, + STARTING, + DELETING_SOURCE_FILES, +} + +enum class TransferErrorCode { + STORAGE_PERMISSION_DENIED, + CANNOT_CREATE_FILE_IN_TARGET, + SOURCE_NOT_FOUND, + TARGET_NOT_FOUND, + INVALID_TARGET, + UNKNOWN_IO_ERROR, + CANCELED, + TARGET_SAME_AS_SOURCE, + NO_SPACE_LEFT_ON_TARGET, + MISSING_ZIP_ENTRY, + DUPLICATE_ZIP_ENTRY, + NOT_A_ZIP_FILE, +} + +data class TransferStats( + val totalFiles: Int = 0, + val filesTransferred: Int = 0, + val bytesTransferred: Long = 0, +) + +/** Terminal outcome of a transfer operation. */ +sealed interface TransferResult { + + data class Success(val result: T, val stats: TransferStats = TransferStats()) : + TransferResult + + /** + * @param cause the exception that triggered this failure, if any + * @param partialStats what had been transferred before the failure, if anything + */ + data class Failure( + val errorCode: TransferErrorCode, + val message: String? = null, + val cause: Throwable? = null, + val partialStats: TransferStats? = null, + ) : TransferResult +} + +val TransferResult<*>.isSuccess: Boolean + get() = this is TransferResult.Success + +fun TransferResult.getOrNull(): T? = (this as? TransferResult.Success)?.result + +fun TransferResult<*>.failureOrNull(): TransferResult.Failure? = this as? TransferResult.Failure diff --git a/storage/src/main/java/com/anggrayudi/storage/transfer/TransferSpec.kt b/storage/src/main/java/com/anggrayudi/storage/transfer/TransferSpec.kt new file mode 100644 index 0000000..bd69a12 --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/transfer/TransferSpec.kt @@ -0,0 +1,49 @@ +package com.anggrayudi.storage.transfer + +import com.anggrayudi.storage.media.FileDescription + +/** + * Optional knobs for transfer operations, configured through a lambda: + * ```kotlin + * val result = file.copyTo(target) { + * onConflict { ConflictResolution.REPLACE } + * onProgress { progressBar.progress = it.percent.toInt() } + * updateInterval = 250 + * } + * ``` + * + * @author Anggrayudi H + */ +class TransferSpec { + + /** Interval between [TransferEvent.Progress] emissions, in milliseconds. */ + var updateInterval: Long = 500 + + /** Fail fast with [TransferErrorCode.NO_SPACE_LEFT_ON_TARGET] when the target volume is full. */ + var checkAvailableSpace: Boolean = true + + /** Skip zero-length files when transferring folders. */ + var skipEmptyFiles: Boolean = true + + /** Renames the file (and optionally its MIME type or sub folder) in the destination. */ + var fileDescription: FileDescription? = null + + /** Zip only: delete the source files after the archive is written successfully. */ + var deleteSourceOnSuccess: Boolean = false + + internal var conflictResolver: ConflictResolver = ConflictResolver { + ConflictResolution.CREATE_NEW + } + + internal var progressListener: (suspend (TransferEvent.Progress) -> Unit)? = null + + /** Called when the destination already contains the file/folder. Default: [ConflictResolution.CREATE_NEW]. */ + fun onConflict(resolver: ConflictResolver) { + conflictResolver = resolver + } + + /** Progress callback for the one-shot suspend operations, invoked every [updateInterval] ms. */ + fun onProgress(listener: suspend (TransferEvent.Progress) -> Unit) { + progressListener = listener + } +} diff --git a/storage/src/test/java/com/anggrayudi/storage/StorageFileTest.kt b/storage/src/test/java/com/anggrayudi/storage/StorageFileTest.kt new file mode 100644 index 0000000..1453f77 --- /dev/null +++ b/storage/src/test/java/com/anggrayudi/storage/StorageFileTest.kt @@ -0,0 +1,96 @@ +package com.anggrayudi.storage + +import android.content.Context +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * Created on 7/11/26 + * + * @author Anggrayudi H + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class StorageFileTest { + + @get:Rule val tempFolder = TemporaryFolder() + + private lateinit var context: Context + private lateinit var root: File + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + root = tempFolder.newFolder("root") + File(root, "a.txt").writeText("hello") + File(root, "sub").mkdirs() + File(root, "sub/b.txt").writeText("world") + } + + @Test + fun `raw folder exposes children and metadata`() { + val folder = StorageFile.from(context, root) + assertTrue(folder.isDirectory) + assertFalse(folder.isFile) + assertTrue(folder.exists) + assertEquals("root", folder.name) + assertEquals(setOf("a.txt", "sub"), folder.list().map { it.name }.toSet()) + } + + @Test + fun `child resolves nested paths`() { + val folder = StorageFile.from(context, root) + val nested = folder.child("sub/b.txt") + assertNotNull(nested) + assertEquals("b.txt", nested!!.name) + assertTrue(nested.isFile) + assertEquals(5L, nested.length) + assertNull(folder.child("missing/file.txt")) + } + + @Test + fun `streams round-trip content`() { + val file = StorageFile.from(context, File(root, "a.txt")) + val content = file.openInputStream()!!.use { it.readBytes().decodeToString() } + assertEquals("hello", content) + + file.openOutputStream(append = true)!!.use { it.write(" again".toByteArray()) } + val appended = file.openInputStream()!!.use { it.readBytes().decodeToString() } + assertEquals("hello again", appended) + } + + @Test + fun `delete removes the file`() { + val file = StorageFile.from(context, File(root, "a.txt")) + assertTrue(file.delete()) + assertFalse(file.exists) + } + + @Test + fun `equality follows the underlying uri`() { + val f1 = StorageFile.from(context, File(root, "a.txt")) + val f2 = StorageFile.from(context, File(root, "a.txt")) + val other = StorageFile.from(context, File(root, "sub/b.txt")) + assertEquals(f1, f2) + assertFalse(f1 == other) + } + + @Test + fun `storage path renders storageId colon basePath`() { + val path = StoragePath("AAAA-BBBB", "Download/movie.mp4") + assertEquals("AAAA-BBBB:Download/movie.mp4", path.toString()) + assertEquals("Download", StoragePath.primary("Download").basePath) + } +} diff --git a/storage/src/test/java/com/anggrayudi/storage/StorageFileTransferTest.kt b/storage/src/test/java/com/anggrayudi/storage/StorageFileTransferTest.kt new file mode 100644 index 0000000..9aba375 --- /dev/null +++ b/storage/src/test/java/com/anggrayudi/storage/StorageFileTransferTest.kt @@ -0,0 +1,135 @@ +package com.anggrayudi.storage + +import android.content.Context +import com.anggrayudi.storage.transfer.TransferResult +import com.anggrayudi.storage.transfer.getOrNull +import com.anggrayudi.storage.transfer.isSuccess +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * End-to-end tests for the v3 one-shot transfer operations on the raw-file backend. Conflict + * resolution paths require a live main looper and are covered by instrumentation, not here. + * + * Created on 7/11/26 + * + * @author Anggrayudi H + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class StorageFileTransferTest { + + @get:Rule val tempFolder = TemporaryFolder() + + private lateinit var context: Context + private lateinit var sourceDir: File + private lateinit var targetDir: File + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + sourceDir = tempFolder.newFolder("source") + targetDir = tempFolder.newFolder("target") + File(sourceDir, "a.txt").writeText("hello world") + File(sourceDir, "sub").mkdirs() + File(sourceDir, "sub/b.txt").writeText("nested content") + } + + private fun storageFile(file: File): StorageFile = StorageFile.from(context, file) + + @Test + fun `copyTo copies a single file into the target folder`() = runBlocking { + val result = + storageFile(File(sourceDir, "a.txt")).copyTo(storageFile(targetDir)) { + checkAvailableSpace = false + } + assertTrue("expected success but was $result", result.isSuccess) + val copied = File(targetDir, "a.txt") + assertTrue(copied.exists()) + assertEquals("hello world", copied.readText()) + assertTrue(File(sourceDir, "a.txt").exists()) // source untouched + assertEquals("a.txt", result.getOrNull()?.name) + } + + @Test + fun `moveTo moves a single file and removes the source`() = runBlocking { + val result = + storageFile(File(sourceDir, "a.txt")).moveTo(storageFile(targetDir)) { + checkAvailableSpace = false + } + assertTrue("expected success but was $result", result.isSuccess) + assertEquals("hello world", File(targetDir, "a.txt").readText()) + assertFalse(File(sourceDir, "a.txt").exists()) + } + + @Test + fun `copyTo copies a folder recursively`() = runBlocking { + val result = + storageFile(sourceDir).copyTo(storageFile(targetDir)) { checkAvailableSpace = false } + assertTrue("expected success but was $result", result.isSuccess) + assertEquals("hello world", File(targetDir, "source/a.txt").readText()) + assertEquals("nested content", File(targetDir, "source/sub/b.txt").readText()) + } + + @Test + fun `copyTo into an invalid target fails with INVALID_TARGET`() = runBlocking { + val notAFolder = storageFile(File(sourceDir, "a.txt")) + val result = storageFile(File(sourceDir, "sub/b.txt")).copyTo(notAFolder) + assertTrue(result is TransferResult.Failure) + assertEquals( + com.anggrayudi.storage.transfer.TransferErrorCode.INVALID_TARGET, + (result as TransferResult.Failure).errorCode, + ) + } + + @Test + fun `zipTo then unzipTo round-trips contents`() = runBlocking { + // ZIP entry paths are derived from storage-volume base paths, so the tree must live under + // the (Robolectric-faked) external storage directory rather than a plain JVM temp dir. + val external = android.os.Environment.getExternalStorageDirectory() + val zipSource = File(external, "zipsource").apply { mkdirs() } + File(zipSource, "a.txt").writeText("hello world") + File(zipSource, "sub").mkdirs() + File(zipSource, "sub/b.txt").writeText("nested content") + val zipRaw = File(external, "archive.zip").apply { createNewFile() } + + val zipResult = + listOf(storageFile(zipSource)).zipTo(storageFile(zipRaw)) { checkAvailableSpace = false } + assertTrue("zip failed: $zipResult", zipResult.isSuccess) + assertTrue(zipRaw.length() > 0) + + val unzipDir = File(external, "unzipped").apply { mkdirs() } + val unzipResult = + storageFile(zipRaw).unzipTo(storageFile(unzipDir)) { checkAvailableSpace = false } + assertTrue("unzip failed: $unzipResult", unzipResult.isSuccess) + val extracted = unzipDir.walkTopDown().filter { it.isFile }.map { it.name }.toSet() + assertEquals(setOf("a.txt", "b.txt"), extracted) + } + + @Test + fun `deleteRecursively removes folder tree`() = runBlocking { + assertTrue(storageFile(sourceDir).deleteRecursively()) + assertFalse(sourceDir.exists()) + } + + @Test + fun `search over StorageFile finds nested files`() = runBlocking { + val results = mutableListOf() + storageFile(sourceDir).search(recursive = true, regex = Regex("^.*\\.txt$")).collect { files -> + results.clear() + results.addAll(files.map { it.name }) + } + assertEquals(setOf("a.txt", "b.txt"), results.toSet()) + } +}