From 256eadfbca3bdd6806ea45806f1304b4b2742a4c Mon Sep 17 00:00:00 2001 From: Ken Jenney Date: Sat, 9 May 2026 05:28:24 -0400 Subject: [PATCH 1/3] Progress --- PLAN.md | 111 ++ README.md | 97 + app/.idea/.gitignore | 3 + app/.idea/AndroidProjectSystem.xml | 6 + app/.idea/caches/deviceStreaming.xml | 1725 +++++++++++++++++ app/.idea/deviceManager.xml | 13 + app/.idea/gradle.xml | 13 + app/.idea/misc.xml | 4 + app/.idea/modules.xml | 8 + app/.idea/runConfigurations.xml | 17 + app/.idea/vcs.xml | 6 + app/src/main/AndroidManifest.xml | 3 + .../files/filelist/FileListFragment.kt | 8 + .../files/navigation/NavigationItems.kt | 22 + .../files/provider/FileSystemProviders.kt | 2 + .../provider/common/NoOpPathObservable.kt | 12 + .../files/provider/common/PathExtensions.kt | 7 +- .../provider/mediastore/MediaStoreCategory.kt | 94 + .../mediastore/MediaStoreFileSystem.kt | 86 + .../MediaStoreFileSystemProvider.kt | 208 ++ .../provider/mediastore/MediaStoreLister.kt | 36 + .../provider/mediastore/MediaStorePath.kt | 115 ++ .../mediastore/MediaStoreRootAttributes.kt | 21 + .../main/res/drawable/apk_icon_white_24dp.xml | 18 + app/src/main/res/values/strings.xml | 5 + 25 files changed, 2637 insertions(+), 3 deletions(-) create mode 100644 PLAN.md create mode 100644 app/.idea/.gitignore create mode 100644 app/.idea/AndroidProjectSystem.xml create mode 100644 app/.idea/caches/deviceStreaming.xml create mode 100644 app/.idea/deviceManager.xml create mode 100644 app/.idea/gradle.xml create mode 100644 app/.idea/misc.xml create mode 100644 app/.idea/modules.xml create mode 100644 app/.idea/runConfigurations.xml create mode 100644 app/.idea/vcs.xml create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/common/NoOpPathObservable.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreCategory.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreFileSystem.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreFileSystemProvider.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreLister.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStorePath.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreRootAttributes.kt create mode 100644 app/src/main/res/drawable/apk_icon_white_24dp.xml diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..77216054b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,111 @@ +# Plan: Device-wide media category entries (Images/Videos/Audio/Documents/APKs) + +## Context + +MaterialFiles' navigation drawer currently exposes path-bound "Standard Directories" — Pictures, Movies, Music, Documents — that only show files inside those specific folders on primary external storage. Users want categories that aggregate files of a given type (images, videos, audio, documents, APKs) **across the entire device**, including subfolders, secondary storage, and any folder regardless of name. Users should be able to tap an entry and get a single flat list of every matching file. + +This change adds five new drawer entries that populate from `MediaStore.Files` and surface results as ordinary `LinuxPath`-backed `FileItem`s, so all existing per-file actions (open, share, delete, properties, copy/move, viewer) continue to work without modification. The app already holds `MANAGE_EXTERNAL_STORAGE`, so listing arbitrary on-disk paths from `MediaStore.MediaColumns.DATA` is permitted. + +## Approach + +A new minimal `FileSystemProvider` (scheme `mediastore`) exposes five virtual roots (`mediastore:/images`, `/videos`, `/audio`, `/documents`, `/apks`). Its only meaningful operation is `newDirectoryStream`, which queries `MediaStore.Files` with a per-category selection and yields `LinuxPath`s for each result. All other provider operations throw `UnsupportedOperationException` (consumer never calls them on the synthetic root; mutations always target the linux-backed children). This isolates the synthetic mode from path-keyed subsystems (sort, view-type, breadcrumbs, bookmarks) — each category gets its own stable URI key. + +Search inside a category is gated off in v1 (the existing `WalkFileTreeSearchable` cannot walk a synthetic root cheaply). + +## Files to create + +| Path | Purpose | +|------|---------| +| `app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreCategory.kt` | Enum `IMAGES, VIDEOS, AUDIO, DOCUMENTS, APKS` with `segment: String`, `selection: String`, `selectionArgs`, `iconRes`, `titleRes`. Companion `fromSegment(String)`. | +| `app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreFileSystem.kt` | Singleton `FileSystem`. Mirrors `ContentFileSystem` shape; provides `getPath(first, vararg)` returning `MediaStorePath`. | +| `app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStorePath.kt` | `ByteStringListPath` subclass, `Parcelable`, `uriScheme = "mediastore"`. Five well-known absolute single-segment paths. `toFile`, `toRealPath`, `register` throw `UnsupportedOperationException`. | +| `app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreFileSystemProvider.kt` | `object : FileSystemProvider()`. Modeled on `provider/content/ContentFileSystemProvider.kt`. Implements `getScheme="mediastore"`, `getPath(URI)`, `newFileSystem`/`getFileSystem`, `newDirectoryStream`, `readAttributes` (returns `MediaStoreRootAttributes` with `isDirectory=true`), `checkAccess` (no-op), `isHidden=false`, `isSameFile==`. All mutating overrides throw `UnsupportedOperationException` after the standard `ProviderMismatchException` guard. | +| `app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreLister.kt` | Helper: `query(category): List`. Calls `appContext.contentResolver.query(MediaStore.Files.getContentUri("external"), arrayOf(MediaColumns.DATA), category.selection, category.selectionArgs, null)`. Wraps cursor in `use`, filters `null/blank` and non-existent files (`File(path).isFile`), maps each to `Paths.get(file)` (linux path). | +| `app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreRootAttributes.kt` | Tiny `BasicFileAttributes` with `isDirectory=true`, others zero/`FileTime.fromMillis(0)`. | +| `app/src/main/res/drawable/apk_icon_white_24dp.xml` | Drawer-style 24dp white-tinted vector for APKs (parallel to `image_icon_white_24dp.xml`). Reuse path data from `file_apk_icon.xml` but tint white at 24dp. | + +## Files to modify + +### `app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt` +Within the `if (!isRunningAsRoot)` block (~line 36-51), add: +```kotlin +FileSystemProvider.installProvider(MediaStoreFileSystemProvider) +``` +parallel to the existing `ContentFileSystemProvider` registration. + +### `app/src/main/java/me/zhanghai/android/files/navigation/NavigationItems.kt` +1. Add private getter `mediaCategoryItems: List` returning `MediaStoreCategory.values().map { MediaCategoryItem(it) }`. +2. Add private class `MediaCategoryItem(category)` extending `PathItem(MediaStoreFileSystemProvider.getCategoryPath(category))` and implementing `NavigationRoot` so breadcrumbs show the category title rather than `/images`. Override `id`, `iconRes`, `getTitle`, `getName`. +3. In `navigationItems` getter (line 39-63), insert a new section **after `AddStorageItem`** and **before `standardDirectoryItems`**: + ```kotlin + add(null) + addAll(mediaCategoryItems) + ``` + +### `app/src/main/res/values/strings.xml` (around line 688) +Add: +```xml +Images +Videos +Audio +Documents +APKs +``` +(Localized translations can follow in later commits — defaults fall back fine.) + +### `app/src/main/AndroidManifest.xml` (around line 24) +Add (alongside existing storage perms): +```xml + + + +``` +No new runtime permission flow — `MANAGE_EXTERNAL_STORAGE` (already granted via existing flow) covers MediaStore reads. These declarations future-proof if the user revokes All Files Access. + +### `app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt` +Locate the search menu-item visibility logic (search for `R.id.action_search` in `onPrepareOptionsMenu` / menu inflation). Hide the search action when `viewModel.currentPath is MediaStorePath`. Same for "create file/folder", "paste", "select all" if those would behave incorrectly on the synthetic root — verify by inspection during implementation; default to hiding any item whose enable-state is path-bound. + +## MediaStore selection per category + +| Category | Selection | API notes | +|---------|-----------|-----------| +| IMAGES | `MEDIA_TYPE = ?` arg = `MEDIA_TYPE_IMAGE` | All API levels | +| VIDEOS | `MEDIA_TYPE = ?` arg = `MEDIA_TYPE_VIDEO` | All API levels | +| AUDIO | `MEDIA_TYPE = ?` arg = `MEDIA_TYPE_AUDIO` | All API levels | +| DOCUMENTS | API 30+: `MEDIA_TYPE = ?` arg = `MEDIA_TYPE_DOCUMENT` (`6`); API 23-29: `MIME_TYPE IN (...)` allowlist (pdf, msword, vnd.openxmlformats-officedocument.*, ms-excel, ms-powerpoint, vnd.oasis.opendocument.*, rtf, plain, csv, epub, application/json, application/xml) | Branch on `Build.VERSION.SDK_INT` | +| APKS | `MIME_TYPE = ?` arg = `application/vnd.android.package-archive` | All API levels | + +All queries also AND `DATA IS NOT NULL`. Cursor read is wrapped in `use {}`. + +## Reused existing code + +- `provider/content/ContentFileSystemProvider.kt` — template (read-only provider with mostly throwing overrides). +- `provider/common/ByteStringListPath.kt` — base class for `MediaStorePath`. +- `file/FileItem.kt:46-70` `loadFileItem(path)` — unchanged; consumes `LinuxPath`s from the listing. +- `filelist/FileListLiveData.kt:42` — unchanged; calls `path.newDirectoryStream()` via the new provider. +- `app/SystemServices.kt:9` `appContext.contentResolver` — used by `MediaStoreLister`. +- `compat/MimeTypeMapCompat.kt`, `file/MimeType.kt` — for selection-clause MIME constants. +- Existing drawer drawables `image_icon_white_24dp.xml`, `video_icon_white_24dp.xml`, `audio_icon_white_24dp.xml`, `document_icon_white_24dp.xml` — reused for category items. + +## Verification + +1. `./gradlew :app:assembleDebug` builds clean. +2. `adb install -r app/build/outputs/apk/foss/debug/app-foss-debug.apk` (or whichever variant is active). +3. Launch app → grant All Files Access if prompted (existing flow). +4. Open navigation drawer → confirm 5 new entries below "Add storage" with proper icons + labels. +5. Tap **Images** → file list populates with images from `/sdcard/DCIM`, `/sdcard/Pictures`, `/sdcard/Download`, app-private screenshots, etc. Verify count plausibly matches a manual `find /sdcard -iname '*.jpg' -o -iname '*.png' | wc -l`. +6. Tap a listed image → opens in `ImageViewerActivity`; swipe shows neighbors from the same listing. +7. Long-press an item → confirm Cut/Copy/Delete/Properties/Share/Open With render and execute against the real linux path. +8. Tap **Videos**, **Audio**, **Documents**, **APKs** in turn — each populates appropriately. APKs entry should include downloaded `.apk` files. Documents entry should include PDFs and Office docs. +9. Rotate device while in a category → list survives (parcel round-trip on `MediaStorePath`). +10. Sort options changed inside Images do not bleed into Videos or into a real directory listing. +11. Search action either hidden or disabled inside a category; back button exits to last real screen / drawer. +12. Repeat smoke test on an API 23 emulator to confirm the API-29 documents fallback selection works. +13. Repeat smoke test on an API 33+ device with All Files Access revoked → MediaStore queries should still succeed via `READ_MEDIA_*`. + +## Out of scope (future work) + +- Per-category enable/disable in `Settings → Standard Directories` UI. +- Searching within a category (would require flat in-memory filter). +- Grouping by date / album / artist for media categories. +- Custom category sort options persisted separately from path-bound sorts. diff --git a/README.md b/README.md index 81ae655a4..a0dd213b4 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,103 @@ Because I know people can do it right. So, it's time for yet another Android file manager. +## Development + +### Prerequisites + +- Android Studio Ladybug or newer +- Android SDK with API level 36 (compileSdk) +- Android NDK 28.1.13356709 (required for native code — install via SDK Manager → SDK Tools → NDK) +- JDK 17+ + +### Building an APK + +**Debug APK** (no signing config needed): + +```bash +./gradlew assembleDebug +``` + +Output: `app/build/outputs/apk/debug/app-debug.apk` + +**Release APK** (requires signing): + +1. Copy `signing.properties.example` to `signing.properties` and fill in your keystore details: + +```properties +storeFile=/path/to/your.keystore +storePassword=yourStorePassword +keyAlias=yourKeyAlias +keyPassword=yourKeyPassword +``` + +2. Build: + +```bash +./gradlew assembleRelease +``` + +Output: `app/build/outputs/apk/release/app-release.apk` + +Alternatively, pass signing config via environment variables (useful for CI): + +```bash +STORE_FILE=/path/to/your.keystore \ +STORE_PASSWORD=... \ +KEY_ALIAS=... \ +KEY_PASSWORD=... \ +./gradlew assembleRelease +``` + +### Running in Android Studio + +1. Open Android Studio and select **File → Open**, then choose this project's root directory. +2. Wait for Gradle sync to complete (first sync downloads dependencies and compiles NDK code — may take several minutes). +3. Connect a device or start an emulator (API 23+ required). +4. Click **Run → Run 'app'** or press `Shift+F10`. + +To install a pre-built APK directly via ADB: + +```bash +adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +### Debugging Crashes + +**Logcat (runtime logs and crash stack traces):** + +In Android Studio, open **View → Tool Windows → Logcat**. Filter by package `me.zhanghai.android.files` or tag `AndroidRuntime` to isolate crash output. + +From the command line: + +```bash +adb logcat --pid=$(adb shell pidof -s me.zhanghai.android.files) | grep -E "E/|F/" +``` + +**Attaching the debugger:** + +1. Run the app in debug mode (`Shift+F10` or `Run → Debug 'app'`). +2. Set breakpoints in the source by clicking the gutter next to any line. +3. Use **Run → Attach Debugger to Android Process** to attach to an already-running process. + +**Native crash debugging (NDK):** + +The app contains native code compiled with CMake. For native crashes: + +1. Enable **LLDB** in the run configuration: **Run → Edit Configurations → Debugger tab → set Debug type to "Dual"**. +2. Native stack frames appear in the debugger automatically on crash. +3. Symbolicate a tombstone manually with `ndk-stack`: + +```bash +adb logcat | ndk-stack -sym app/build/intermediates/cmake/debug/obj// +``` + +Replace `` with `arm64-v8a`, `armeabi-v7a`, `x86`, or `x86_64` as appropriate. + +**Firebase Crashlytics (release builds):** + +Release builds include Crashlytics. Crashes are uploaded automatically to the Firebase console. Native symbol upload is enabled (`nativeSymbolUploadEnabled true`), so native crashes are symbolicated server-side. + ## Inclusion in custom ROMs Thank you if you choose to include Material Files in your custom ROM! However since I've received several user complaints due to improper inclusion, I'd like to offer some suggestions on including this app properly for the good of end users: diff --git a/app/.idea/.gitignore b/app/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/app/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/app/.idea/AndroidProjectSystem.xml b/app/.idea/AndroidProjectSystem.xml new file mode 100644 index 000000000..4a53bee8c --- /dev/null +++ b/app/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/.idea/caches/deviceStreaming.xml b/app/.idea/caches/deviceStreaming.xml new file mode 100644 index 000000000..7d628b3b2 --- /dev/null +++ b/app/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1725 @@ + + + + + + \ No newline at end of file diff --git a/app/.idea/deviceManager.xml b/app/.idea/deviceManager.xml new file mode 100644 index 000000000..91f95584d --- /dev/null +++ b/app/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/.idea/gradle.xml b/app/.idea/gradle.xml new file mode 100644 index 000000000..773e83780 --- /dev/null +++ b/app/.idea/gradle.xml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/app/.idea/misc.xml b/app/.idea/misc.xml new file mode 100644 index 000000000..6ed36dd36 --- /dev/null +++ b/app/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/.idea/modules.xml b/app/.idea/modules.xml new file mode 100644 index 000000000..8c4259da4 --- /dev/null +++ b/app/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/.idea/runConfigurations.xml b/app/.idea/runConfigurations.xml new file mode 100644 index 000000000..16660f1d8 --- /dev/null +++ b/app/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/.idea/vcs.xml b/app/.idea/vcs.xml new file mode 100644 index 000000000..6c0b86358 --- /dev/null +++ b/app/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4b7b77d5..cc3fe2fe3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,9 @@ android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> + + + diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt b/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt index df31b7a92..b6c2bab37 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt @@ -81,6 +81,7 @@ import me.zhanghai.android.files.navigation.NavigationRootMapLiveData import me.zhanghai.android.files.provider.archive.createArchiveRootPath import me.zhanghai.android.files.provider.archive.isArchivePath import me.zhanghai.android.files.provider.linux.isLinuxPath +import me.zhanghai.android.files.provider.mediastore.isMediaStorePath import me.zhanghai.android.files.settings.Settings import me.zhanghai.android.files.terminal.Terminal import me.zhanghai.android.files.ui.AppBarLayoutExpandHackListener @@ -441,6 +442,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. updateViewSortMenuItems() updateSelectAllMenuItem() updateShowHiddenFilesMenuItem() + updateSearchMenuItem() } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -580,6 +582,12 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. private fun onCurrentPathChanged(path: Path) { updateOverlayToolbar() updateBottomToolbar() + updateSearchMenuItem() + } + + private fun updateSearchMenuItem() { + if (!this::menuBinding.isInitialized) return + menuBinding.searchItem.isVisible = !viewModel.currentPath.isMediaStorePath } private fun onSearchViewExpandedChanged(expanded: Boolean) { diff --git a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItems.kt b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItems.kt index f9851b69c..8dd1c3c56 100644 --- a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItems.kt +++ b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItems.kt @@ -17,6 +17,8 @@ import java8.nio.file.Path import java8.nio.file.Paths import me.zhanghai.android.files.R import me.zhanghai.android.files.about.AboutActivity +import me.zhanghai.android.files.provider.mediastore.MediaStoreCategory +import me.zhanghai.android.files.provider.mediastore.MediaStoreFileSystemProvider import me.zhanghai.android.files.compat.getDescriptionCompat import me.zhanghai.android.files.compat.isPrimaryCompat import me.zhanghai.android.files.compat.pathCompat @@ -48,6 +50,8 @@ val navigationItems: List addAll(storageVolumeItems) } add(AddStorageItem()) + add(null) + addAll(mediaCategoryItems) val standardDirectoryItems = standardDirectoryItems if (standardDirectoryItems.isNotEmpty()) { add(null) @@ -200,6 +204,24 @@ private class AddStorageItem : NavigationItem() { } } +private val mediaCategoryItems: List + get() = MediaStoreCategory.values().map { MediaCategoryItem(it) } + +private class MediaCategoryItem( + private val category: MediaStoreCategory +) : PathItem(MediaStoreFileSystemProvider.getCategoryPath(category)), NavigationRoot { + override val id: Long + get() = category.titleRes.toLong() + + override val iconRes: Int + @DrawableRes + get() = category.iconRes + + override fun getTitle(context: Context): String = context.getString(category.titleRes) + + override fun getName(context: Context): String = getTitle(context) +} + private val standardDirectoryItems: List @Size(min = 0) get() = diff --git a/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt b/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt index 7126ca270..8d9aa1d8a 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt @@ -11,6 +11,7 @@ import java8.nio.file.spi.FileSystemProvider import me.zhanghai.android.files.provider.archive.ArchiveFileSystemProvider import me.zhanghai.android.files.provider.common.AndroidFileTypeDetector import me.zhanghai.android.files.provider.content.ContentFileSystemProvider +import me.zhanghai.android.files.provider.mediastore.MediaStoreFileSystemProvider import me.zhanghai.android.files.provider.document.DocumentFileSystemProvider import me.zhanghai.android.files.provider.ftp.FtpFileSystemProvider import me.zhanghai.android.files.provider.ftp.FtpesFileSystemProvider @@ -38,6 +39,7 @@ object FileSystemProviders { FileSystemProvider.installProvider(ArchiveFileSystemProvider) if (!isRunningAsRoot) { FileSystemProvider.installProvider(ContentFileSystemProvider) + FileSystemProvider.installProvider(MediaStoreFileSystemProvider) FileSystemProvider.installProvider(DocumentFileSystemProvider) FileSystemProvider.installProvider(FtpFileSystemProvider) FileSystemProvider.installProvider(FtpsFileSystemProvider) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/NoOpPathObservable.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/NoOpPathObservable.kt new file mode 100644 index 000000000..4af9d9f30 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/NoOpPathObservable.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.common + +class NoOpPathObservable : PathObservable { + override fun addObserver(observer: () -> Unit) {} + override fun removeObserver(observer: () -> Unit) {} + override fun close() {} +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt index c3d56b485..aa6ed23c2 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt @@ -275,8 +275,9 @@ private fun ClosedByInterruptException.toInterruptedIOException(): InterruptedIO } @Throws(IOException::class) -fun Path.observe(intervalMillis: Long): PathObservable = - (provider as PathObservableProvider).observe(this, intervalMillis) +fun Path.observe(intervalMillis: Long): PathObservable { + return (provider as? PathObservableProvider)?.observe(this, intervalMillis) ?: NoOpPathObservable() +} val Path.provider: FileSystemProvider get() = fileSystem.provider() @@ -340,7 +341,7 @@ fun Path.resolveForeign(other: Path): Path { @Throws(IOException::class) fun Path.search(query: String, intervalMillis: Long, listener: (List) -> Unit) { - (provider as Searchable).search(this, query, intervalMillis, listener) + (provider as? Searchable)?.search(this, query, intervalMillis, listener) ?: listener(emptyList()) } @Throws(IOException::class) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreCategory.kt b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreCategory.kt new file mode 100644 index 000000000..20039777b --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreCategory.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.mediastore + +import android.os.Build +import android.provider.MediaStore +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import me.zhanghai.android.files.R + +enum class MediaStoreCategory( + val segment: String, + @DrawableRes val iconRes: Int, + @StringRes val titleRes: Int +) { + IMAGES( + "images", + R.drawable.image_icon_white_24dp, + R.string.navigation_media_category_images + ), + VIDEOS( + "videos", + R.drawable.video_icon_white_24dp, + R.string.navigation_media_category_videos + ), + AUDIO( + "audio", + R.drawable.audio_icon_white_24dp, + R.string.navigation_media_category_audio + ), + DOCUMENTS( + "documents", + R.drawable.document_icon_white_24dp, + R.string.navigation_media_category_documents + ), + APKS( + "apks", + R.drawable.apk_icon_white_24dp, + R.string.navigation_media_category_apks + ); + + fun getSelection(): String = when (this) { + IMAGES, VIDEOS, AUDIO -> "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?" + DOCUMENTS -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?" + } else { + val placeholders = DOCUMENT_MIME_TYPES.joinToString(",") { "?" } + "${MediaStore.MediaColumns.MIME_TYPE} IN ($placeholders)" + } + APKS -> "${MediaStore.MediaColumns.MIME_TYPE} = ?" + } + + fun getSelectionArgs(): Array = when (this) { + IMAGES -> arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString()) + VIDEOS -> arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()) + AUDIO -> arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_AUDIO.toString()) + DOCUMENTS -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + arrayOf(MEDIA_TYPE_DOCUMENT.toString()) + } else { + DOCUMENT_MIME_TYPES.toTypedArray() + } + APKS -> arrayOf("application/vnd.android.package-archive") + } + + companion object { + // MediaStore.Files.FileColumns.MEDIA_TYPE_DOCUMENT, added in API 30 + private const val MEDIA_TYPE_DOCUMENT = 6 + + private val DOCUMENT_MIME_TYPES = listOf( + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/rtf", + "text/plain", + "text/csv", + "application/epub+zip", + "application/json", + "application/xml" + ) + + fun fromSegment(segment: String): MediaStoreCategory? = + values().find { it.segment == segment } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreFileSystem.kt b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreFileSystem.kt new file mode 100644 index 000000000..703785fdc --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreFileSystem.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.mediastore + +import android.os.Parcel +import android.os.Parcelable +import java8.nio.file.FileStore +import java8.nio.file.FileSystem +import java8.nio.file.Path +import java8.nio.file.PathMatcher +import java8.nio.file.WatchService +import java8.nio.file.attribute.UserPrincipalLookupService +import java8.nio.file.spi.FileSystemProvider +import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.ByteStringListPathCreator +import java.io.IOException + +class MediaStoreFileSystem( + private val provider: MediaStoreFileSystemProvider +) : FileSystem(), ByteStringListPathCreator, Parcelable { + + override fun provider(): FileSystemProvider = provider + + override fun close() { + throw UnsupportedOperationException() + } + + override fun isOpen(): Boolean = true + + override fun isReadOnly(): Boolean = true + + override fun getSeparator(): String = "/" + + override fun getRootDirectories(): Iterable = + MediaStoreCategory.values().map { MediaStorePath(this, it) } + + override fun getFileStores(): Iterable { + throw UnsupportedOperationException() + } + + override fun supportedFileAttributeViews(): Set = emptySet() + + override fun getPath(first: String, vararg more: String): MediaStorePath { + if (more.isNotEmpty()) throw UnsupportedOperationException() + val segment = first.trimStart('/') + val category = MediaStoreCategory.fromSegment(segment) + ?: throw IllegalArgumentException("Unknown MediaStore category: $first") + return MediaStorePath(this, category) + } + + override fun getPath(first: ByteString, vararg more: ByteString): MediaStorePath = + getPath(first.toString()) + + override fun getPathMatcher(syntaxAndPattern: String): PathMatcher { + throw UnsupportedOperationException() + } + + override fun getUserPrincipalLookupService(): UserPrincipalLookupService { + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun newWatchService(): WatchService { + throw UnsupportedOperationException() + } + + fun getCategoryPath(category: MediaStoreCategory): MediaStorePath = + MediaStorePath(this, category) + + override fun describeContents(): Int = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) {} + + companion object { + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): MediaStoreFileSystem = + MediaStoreFileSystemProvider.fileSystem + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreFileSystemProvider.kt new file mode 100644 index 000000000..b54ab351c --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreFileSystemProvider.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.mediastore + +import java8.nio.channels.FileChannel +import java8.nio.channels.SeekableByteChannel +import java8.nio.file.AccessMode +import java8.nio.file.CopyOption +import java8.nio.file.DirectoryStream +import java8.nio.file.FileStore +import java8.nio.file.FileSystem +import java8.nio.file.FileSystemAlreadyExistsException +import java8.nio.file.LinkOption +import java8.nio.file.OpenOption +import java8.nio.file.Path +import java8.nio.file.ProviderMismatchException +import java8.nio.file.attribute.BasicFileAttributes +import java8.nio.file.attribute.FileAttribute +import java8.nio.file.attribute.FileAttributeView +import java8.nio.file.spi.FileSystemProvider +import me.zhanghai.android.files.provider.common.PathListDirectoryStream +import me.zhanghai.android.files.provider.common.decodedPathByteString +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.URI + +object MediaStoreFileSystemProvider : FileSystemProvider() { + const val SCHEME = "mediastore" + + internal val fileSystem = MediaStoreFileSystem(this) + + override fun getScheme(): String = SCHEME + + override fun newFileSystem(uri: URI, env: Map): FileSystem { + uri.requireSameScheme() + throw FileSystemAlreadyExistsException() + } + + override fun getFileSystem(uri: URI): FileSystem { + uri.requireSameScheme() + return fileSystem + } + + override fun getPath(uri: URI): Path { + uri.requireSameScheme() + val segment = uri.decodedPathByteString?.toString()?.trimStart('/') + ?: throw IllegalArgumentException("URI has no path: $uri") + val category = MediaStoreCategory.fromSegment(segment) + ?: throw IllegalArgumentException("Unknown MediaStore category: $segment") + return fileSystem.getCategoryPath(category) + } + + fun getCategoryPath(category: MediaStoreCategory): MediaStorePath = + fileSystem.getCategoryPath(category) + + private fun URI.requireSameScheme() { + val scheme = scheme + require(scheme == SCHEME) { "URI scheme $scheme must be $SCHEME" } + } + + @Throws(IOException::class) + override fun newInputStream(file: Path, vararg options: OpenOption): InputStream { + file as? MediaStorePath ?: throw ProviderMismatchException(file.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun newOutputStream(file: Path, vararg options: OpenOption): OutputStream { + file as? MediaStorePath ?: throw ProviderMismatchException(file.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun newFileChannel( + file: Path, + options: Set, + vararg attributes: FileAttribute<*> + ): FileChannel { + file as? MediaStorePath ?: throw ProviderMismatchException(file.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun newByteChannel( + file: Path, + options: Set, + vararg attributes: FileAttribute<*> + ): SeekableByteChannel { + file as? MediaStorePath ?: throw ProviderMismatchException(file.toString()) + throw UnsupportedOperationException() + } + + override fun newDirectoryStream( + directory: Path, + filter: DirectoryStream.Filter + ): DirectoryStream { + directory as? MediaStorePath ?: throw ProviderMismatchException(directory.toString()) + val paths = MediaStoreLister.query(directory.category) + return PathListDirectoryStream(paths, filter) + } + + override fun createDirectory(directory: Path, vararg attributes: FileAttribute<*>) { + directory as? MediaStorePath ?: throw ProviderMismatchException(directory.toString()) + throw UnsupportedOperationException() + } + + override fun createSymbolicLink( + link: Path, + target: Path, + vararg attributes: FileAttribute<*> + ) { + link as? MediaStorePath ?: throw ProviderMismatchException(link.toString()) + throw UnsupportedOperationException() + } + + override fun createLink(link: Path, existing: Path) { + link as? MediaStorePath ?: throw ProviderMismatchException(link.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun delete(path: Path) { + path as? MediaStorePath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } + + override fun readSymbolicLink(link: Path): Path { + link as? MediaStorePath ?: throw ProviderMismatchException(link.toString()) + throw UnsupportedOperationException() + } + + override fun copy(source: Path, target: Path, vararg options: CopyOption) { + source as? MediaStorePath ?: throw ProviderMismatchException(source.toString()) + throw UnsupportedOperationException() + } + + override fun move(source: Path, target: Path, vararg options: CopyOption) { + source as? MediaStorePath ?: throw ProviderMismatchException(source.toString()) + throw UnsupportedOperationException() + } + + override fun isSameFile(path: Path, path2: Path): Boolean { + path as? MediaStorePath ?: throw ProviderMismatchException(path.toString()) + return path == path2 + } + + override fun isHidden(path: Path): Boolean { + path as? MediaStorePath ?: throw ProviderMismatchException(path.toString()) + return false + } + + override fun getFileStore(path: Path): FileStore { + path as? MediaStorePath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun checkAccess(path: Path, vararg modes: AccessMode) { + path as? MediaStorePath ?: throw ProviderMismatchException(path.toString()) + // Virtual roots always exist; no real access check needed. + } + + override fun getFileAttributeView( + path: Path, + type: Class, + vararg options: LinkOption + ): V? { + path as? MediaStorePath ?: throw ProviderMismatchException(path.toString()) + return null + } + + @Throws(IOException::class) + override fun readAttributes( + path: Path, + type: Class, + vararg options: LinkOption + ): A { + path as? MediaStorePath ?: throw ProviderMismatchException(path.toString()) + if (!type.isAssignableFrom(MediaStoreRootAttributes::class.java)) { + throw UnsupportedOperationException(type.toString()) + } + @Suppress("UNCHECKED_CAST") + return MediaStoreRootAttributes() as A + } + + override fun readAttributes( + path: Path, + attributes: String, + vararg options: LinkOption + ): Map { + path as? MediaStorePath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } + + override fun setAttribute( + path: Path, + attribute: String, + value: Any, + vararg options: LinkOption + ) { + path as? MediaStorePath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreLister.kt b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreLister.kt new file mode 100644 index 000000000..90477a7e7 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreLister.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.mediastore + +import android.provider.MediaStore +import java8.nio.file.Path +import java8.nio.file.Paths +import me.zhanghai.android.files.app.contentResolver + +object MediaStoreLister { + fun query(category: MediaStoreCategory): List { + val categorySelection = category.getSelection() + val categoryArgs = category.getSelectionArgs() + val selection = "($categorySelection) AND ${MediaStore.MediaColumns.DATA} IS NOT NULL" + val paths = mutableListOf() + contentResolver.query( + MediaStore.Files.getContentUri("external"), + arrayOf(MediaStore.MediaColumns.DATA), + selection, + categoryArgs, + null + )?.use { cursor -> + val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) + while (cursor.moveToNext()) { + val data = cursor.getString(dataIndex) ?: continue + if (data.isBlank()) continue + if (!java.io.File(data).isFile) continue + paths.add(Paths.get(data)) + } + } + return paths + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStorePath.kt b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStorePath.kt new file mode 100644 index 000000000..bd6a80463 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStorePath.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.mediastore + +import android.os.Parcel +import android.os.Parcelable +import java8.nio.file.FileSystem +import java8.nio.file.InvalidPathException +import java8.nio.file.LinkOption +import java8.nio.file.Path +import java8.nio.file.WatchEvent +import java8.nio.file.WatchKey +import java8.nio.file.WatchService +import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.ByteStringListPath +import me.zhanghai.android.files.provider.common.toByteString +import me.zhanghai.android.files.util.readParcelable +import java.io.File + +class MediaStorePath : ByteStringListPath { + private val fileSystem: MediaStoreFileSystem + val category: MediaStoreCategory + + constructor( + fileSystem: MediaStoreFileSystem, + category: MediaStoreCategory + ) : super( + '/'.code.toByte(), + true, + listOf(category.segment.toByteString()) + ) { + this.fileSystem = fileSystem + this.category = category + } + + override fun isPathAbsolute(path: ByteString): Boolean = + path.startsWith("/".toByteString()) + + override fun createPath(path: ByteString): MediaStorePath { + val str = path.toString().trimStart('/') + val cat = MediaStoreCategory.fromSegment(str) + ?: throw InvalidPathException(path.toString(), "Unknown MediaStore category: $str") + return MediaStorePath(fileSystem, cat) + } + + override fun createPath(absolute: Boolean, segments: List): MediaStorePath { + if (absolute && segments.size == 1) { + val cat = MediaStoreCategory.fromSegment(segments[0].toString()) + if (cat != null) return MediaStorePath(fileSystem, cat) + } + throw UnsupportedOperationException( + "Cannot create MediaStorePath: absolute=$absolute, segments=$segments" + ) + } + + override val defaultDirectory: MediaStorePath + get() = throw UnsupportedOperationException() + + override fun getFileSystem(): FileSystem = fileSystem + + override fun getRoot(): MediaStorePath = this + + override fun getParent(): MediaStorePath? = null + + override fun normalize(): MediaStorePath = this + + override fun toAbsolutePath(): MediaStorePath { + check(isAbsolute) { "MediaStorePath must always be absolute" } + return this + } + + override fun toRealPath(vararg options: LinkOption): MediaStorePath { + throw UnsupportedOperationException() + } + + override fun toFile(): File { + throw UnsupportedOperationException() + } + + override fun register( + watcher: WatchService, + events: Array>, + vararg modifiers: WatchEvent.Modifier + ): WatchKey { + throw UnsupportedOperationException() + } + + private constructor(source: Parcel) : super(source) { + fileSystem = source.readParcelable()!! + val categoryName = source.readString()!! + category = MediaStoreCategory.valueOf(categoryName) + } + + override fun writeToParcel(dest: Parcel, flags: Int) { + super.writeToParcel(dest, flags) + dest.writeParcelable(fileSystem, flags) + dest.writeString(category.name) + } + + override fun describeContents(): Int = 0 + + companion object { + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): MediaStorePath = MediaStorePath(source) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} + +val Path.isMediaStorePath: Boolean + get() = this is MediaStorePath diff --git a/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreRootAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreRootAttributes.kt new file mode 100644 index 000000000..155eeece8 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreRootAttributes.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.mediastore + +import java8.nio.file.attribute.BasicFileAttributes +import java8.nio.file.attribute.FileTime + +class MediaStoreRootAttributes : BasicFileAttributes { + override fun lastModifiedTime(): FileTime = FileTime.fromMillis(0) + override fun lastAccessTime(): FileTime = FileTime.fromMillis(0) + override fun creationTime(): FileTime = FileTime.fromMillis(0) + override fun isRegularFile(): Boolean = false + override fun isDirectory(): Boolean = true + override fun isSymbolicLink(): Boolean = false + override fun isOther(): Boolean = false + override fun size(): Long = 0 + override fun fileKey(): Any? = null +} diff --git a/app/src/main/res/drawable/apk_icon_white_24dp.xml b/app/src/main/res/drawable/apk_icon_white_24dp.xml new file mode 100644 index 000000000..8f0eec99f --- /dev/null +++ b/app/src/main/res/drawable/apk_icon_white_24dp.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 964179529..7b11c128f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -686,6 +686,11 @@ QQ TIM WeChat + Images + Videos + Audio + Documents + APKs Bookmark folder Name Path From 756743e6cccc7f8c95ffd5e77173705fe7d2415a Mon Sep 17 00:00:00 2001 From: Ken Jenney Date: Sun, 10 May 2026 16:34:41 -0400 Subject: [PATCH 2/3] Add Docker support for builds --- Dockerfile | 29 +++++++++++++++++++++++++++++ docker-build.sh | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 Dockerfile create mode 100755 docker-build.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..2b6e12bed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM eclipse-temurin:21-jdk + +# Install dependencies +RUN apt-get update && apt-get install -y \ + wget \ + unzip \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Set environment variables +ENV ANDROID_HOME=/opt/android-sdk +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${ANDROID_HOME}/build-tools/37.0.0 + +# Install Android SDK Command-line tools +RUN mkdir -p ${ANDROID_HOME}/cmdline-tools && \ + wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \ + unzip /tmp/cmdline-tools.zip -d ${ANDROID_HOME}/cmdline-tools && \ + rm /tmp/cmdline-tools.zip && \ + mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest + +# Accept licenses and install SDK components +RUN yes | sdkmanager --licenses && \ + sdkmanager "platform-tools" \ + "platforms;android-36" \ + "build-tools;37.0.0" \ + "ndk;28.1.13356709" + +WORKDIR /app diff --git a/docker-build.sh b/docker-build.sh new file mode 100755 index 000000000..6e71f64a7 --- /dev/null +++ b/docker-build.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# Default build type is debug +BUILD_TYPE=${1:-debug} +IMAGE_NAME="material-files-builder" + +if ! docker info > /dev/null 2>&1; then + echo "Docker daemon is not running." + exit 1 +fi + +echo "Building Docker image ${IMAGE_NAME}..." +docker build --platform linux/amd64 -t ${IMAGE_NAME} . + +echo "Running build for ${BUILD_TYPE}..." +# Mount current directory to /app in container. +# Output files will be written back to the host system. +docker run --rm \ + --platform linux/amd64 \ + -v "$(pwd):/app" \ + -w /app \ + -e STORE_FILE="${STORE_FILE}" \ + -e STORE_PASSWORD="${STORE_PASSWORD}" \ + -e KEY_ALIAS="${KEY_ALIAS}" \ + -e KEY_PASSWORD="${KEY_PASSWORD}" \ + ${IMAGE_NAME} \ + ./gradlew assemble$(echo ${BUILD_TYPE} | awk '{print toupper(substr($0,1,1)) substr($0,2)}') + +if [ $? -eq 0 ]; then + echo "Build successful!" +else + echo "Build failed!" + exit 1 +fi From cf6ca48bda43418eddbead4b10c1c8cd9c6acab4 Mon Sep 17 00:00:00 2001 From: Ken Jenney Date: Sun, 10 May 2026 16:35:00 -0400 Subject: [PATCH 3/3] Add MediaStore Library support --- README.md | 33 + app/.idea/caches/deviceStreaming.xml | 1725 ----------------- app/.idea/misc.xml | 1 - .../FileListFragmentImagesNavigationTest.kt | 60 + .../files/filelist/FileListFragment.kt | 21 +- .../provider/mediastore/MediaStoreCategory.kt | 6 +- .../provider/mediastore/MediaStoreLister.kt | 71 +- .../file_list_fragment_content_include.xml | 21 + app/src/main/res/values/strings.xml | 1 + 9 files changed, 207 insertions(+), 1732 deletions(-) delete mode 100644 app/.idea/caches/deviceStreaming.xml create mode 100644 app/src/androidTest/java/me/zhanghai/android/files/filelist/FileListFragmentImagesNavigationTest.kt diff --git a/README.md b/README.md index a0dd213b4..bdc23e5b8 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,29 @@ KEY_PASSWORD=... \ ./gradlew assembleRelease ``` +### Building with Docker + +Use the provided script to build APKs in a consistent environment without installing SDK/NDK locally: + +**Debug APK**: +```bash +./docker-build.sh debug +``` + +**Release APK**: +```bash +STORE_FILE=/path/to/your.keystore \ +STORE_PASSWORD=... \ +KEY_ALIAS=... \ +KEY_PASSWORD=... \ +./docker-build.sh release +``` + +The output APKs will be available on your local system in `app/build/outputs/apk/...` due to volume mounting. + ### Running in Android Studio + 1. Open Android Studio and select **File → Open**, then choose this project's root directory. 2. Wait for Gradle sync to complete (first sync downloads dependencies and compiles NDK code — may take several minutes). 3. Connect a device or start an emulator (API 23+ required). @@ -115,6 +136,18 @@ To install a pre-built APK directly via ADB: adb install -r app/build/outputs/apk/debug/app-debug.apk ``` +To uninstall the app from a device or emulator: + +```bash +adb uninstall me.zhanghai.android.files +``` + +### Testing MediaStore + +```bash +adb push /sdcard/Download/ +``` + ### Debugging Crashes **Logcat (runtime logs and crash stack traces):** diff --git a/app/.idea/caches/deviceStreaming.xml b/app/.idea/caches/deviceStreaming.xml deleted file mode 100644 index 7d628b3b2..000000000 --- a/app/.idea/caches/deviceStreaming.xml +++ /dev/null @@ -1,1725 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/.idea/misc.xml b/app/.idea/misc.xml index 6ed36dd36..6dc906734 100644 --- a/app/.idea/misc.xml +++ b/app/.idea/misc.xml @@ -1,4 +1,3 @@ - \ No newline at end of file diff --git a/app/src/androidTest/java/me/zhanghai/android/files/filelist/FileListFragmentImagesNavigationTest.kt b/app/src/androidTest/java/me/zhanghai/android/files/filelist/FileListFragmentImagesNavigationTest.kt new file mode 100644 index 000000000..04c7a0ae0 --- /dev/null +++ b/app/src/androidTest/java/me/zhanghai/android/files/filelist/FileListFragmentImagesNavigationTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.filelist + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import me.zhanghai.android.files.provider.mediastore.MediaStoreFileSystemProvider +import me.zhanghai.android.files.provider.mediastore.MediaStoreCategory +import me.zhanghai.android.files.provider.mediastore.isMediaStorePath + +/** + * Test that Images navigation (MediaStore paths) do not include buttons to add files or folders. + */ +@RunWith(AndroidJUnit4::class) +class FileListFragmentImagesNavigationTest { + + @Test + fun imagesPathIsRecognizedAsMediaStorePath() { + val imagesPath = MediaStoreFileSystemProvider.getCategoryPath(MediaStoreCategory.IMAGES) + assertTrue("Images path should be recognized as MediaStore path", imagesPath.isMediaStorePath) + } + + @Test + fun videosPathIsRecognizedAsMediaStorePath() { + val videosPath = MediaStoreFileSystemProvider.getCategoryPath(MediaStoreCategory.VIDEOS) + assertTrue("Videos path should be recognized as MediaStore path", videosPath.isMediaStorePath) + } + + @Test + fun audioPathIsRecognizedAsMediaStorePath() { + val audioPath = MediaStoreFileSystemProvider.getCategoryPath(MediaStoreCategory.AUDIO) + assertTrue("Audio path should be recognized as MediaStore path", audioPath.isMediaStorePath) + } + + @Test + fun documentsPathIsRecognizedAsMediaStorePath() { + val documentsPath = MediaStoreFileSystemProvider.getCategoryPath(MediaStoreCategory.DOCUMENTS) + assertTrue("Documents path should be recognized as MediaStore path", documentsPath.isMediaStorePath) + } + + @Test + fun apksPathIsRecognizedAsMediaStorePath() { + val apksPath = MediaStoreFileSystemProvider.getCategoryPath(MediaStoreCategory.APKS) + assertTrue("APKs path should be recognized as MediaStore path", apksPath.isMediaStorePath) + } + + @Test + fun normalPathIsNotRecognizedAsMediaStorePath() { + // Test that a normal file system path is NOT a MediaStore path + // Using a generic path representation - actual path structure may vary + val normalPath = java8.nio.file.Paths.get("/storage/emulated/0") + assertFalse("Normal path should not be recognized as MediaStore path", normalPath.isMediaStorePath) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt b/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt index b6c2bab37..5c6b51b4c 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt @@ -583,6 +583,15 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. updateOverlayToolbar() updateBottomToolbar() updateSearchMenuItem() + updateSpeedDial() + } + + private fun updateSpeedDial() { + binding.speedDialView.visibility = if (viewModel.currentPath.isMediaStorePath) { + View.GONE + } else { + View.VISIBLE + } } private fun updateSearchMenuItem() { @@ -597,14 +606,18 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. private fun onFileListChanged(stateful: Stateful>) { val files = stateful.value val isSearching = viewModel.searchState.isSearching + val isMediaStore = viewModel.currentPath.isMediaStorePath + val isScanning = stateful is Loading && isMediaStore && !(files != null && files.isNotEmpty()) when { stateful is Failure -> binding.toolbar.setSubtitle(R.string.error) + isScanning -> binding.toolbar.setSubtitle(R.string.file_list_scanning_media) stateful is Loading && !isSearching -> binding.toolbar.setSubtitle(R.string.loading) else -> binding.toolbar.subtitle = getSubtitle(files!!) } val hasFiles = !files.isNullOrEmpty() - binding.swipeRefreshLayout.isRefreshing = stateful is Loading && (hasFiles || isSearching) - binding.progress.fadeToVisibilityUnsafe(stateful is Loading && !(hasFiles || isSearching)) + binding.swipeRefreshLayout.isRefreshing = stateful is Loading && (hasFiles || isSearching) && !isScanning + binding.scanningView.fadeToVisibilityUnsafe(isScanning) + binding.progress.fadeToVisibilityUnsafe(stateful is Loading && !(hasFiles || isSearching) && !isScanning) binding.errorText.fadeToVisibilityUnsafe(stateful is Failure && !hasFiles) val throwable = (stateful as? Failure)?.throwable if (throwable != null) { @@ -1674,6 +1687,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. val breadcrumbLayout: BreadcrumbLayout, val contentLayout: ViewGroup, val progress: ProgressBar, + val scanningView: View, val errorText: TextView, val emptyView: View, val swipeRefreshLayout: SwipeRefreshLayout, @@ -1701,7 +1715,8 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. includeBinding.persistentBarLayout, appBarBinding.appBarLayout, appBarBinding.toolbar, appBarBinding.overlayToolbar, appBarBinding.breadcrumbLayout, contentBinding.contentLayout, - contentBinding.progress, contentBinding.errorText, contentBinding.emptyView, + contentBinding.progress, contentBinding.scanningView, + contentBinding.errorText, contentBinding.emptyView, contentBinding.swipeRefreshLayout, contentBinding.recyclerView, bottomBarBinding.bottomBarLayout, bottomBarBinding.bottomToolbar, bottomBarBinding.bottomCreateFileNameEdit, speedDialBinding.speedDialView diff --git a/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreCategory.kt b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreCategory.kt index 20039777b..3c9cc76e8 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreCategory.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreCategory.kt @@ -43,7 +43,9 @@ enum class MediaStoreCategory( ); fun getSelection(): String = when (this) { - IMAGES, VIDEOS, AUDIO -> "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?" + IMAGES -> "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?" + VIDEOS -> "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.MediaColumns.MIME_TYPE} LIKE ?)" + AUDIO -> "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?" DOCUMENTS -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?" } else { @@ -55,7 +57,7 @@ enum class MediaStoreCategory( fun getSelectionArgs(): Array = when (this) { IMAGES -> arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString()) - VIDEOS -> arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()) + VIDEOS -> arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString(), "video%") AUDIO -> arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_AUDIO.toString()) DOCUMENTS -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { arrayOf(MEDIA_TYPE_DOCUMENT.toString()) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreLister.kt b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreLister.kt index 90477a7e7..1bdf5bb1d 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreLister.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/mediastore/MediaStoreLister.kt @@ -5,13 +5,23 @@ package me.zhanghai.android.files.provider.mediastore +import android.media.MediaScannerConnection +import android.os.Environment import android.provider.MediaStore import java8.nio.file.Path import java8.nio.file.Paths +import me.zhanghai.android.files.app.application import me.zhanghai.android.files.app.contentResolver +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit object MediaStoreLister { + private const val SCAN_TIMEOUT_SECONDS = 120L + private const val MAX_FILES_TO_SCAN = 50_000 + fun query(category: MediaStoreCategory): List { + scanMediaStorageSync() val categorySelection = category.getSelection() val categoryArgs = category.getSelectionArgs() val selection = "($categorySelection) AND ${MediaStore.MediaColumns.DATA} IS NOT NULL" @@ -27,10 +37,69 @@ object MediaStoreLister { while (cursor.moveToNext()) { val data = cursor.getString(dataIndex) ?: continue if (data.isBlank()) continue - if (!java.io.File(data).isFile) continue + if (!File(data).isFile) continue paths.add(Paths.get(data)) } } return paths } + + private fun scanMediaStorageSync() { + val files = collectFilesToScan() + if (files.isEmpty()) return + val latch = CountDownLatch(files.size) + MediaScannerConnection.scanFile( + application, + files.toTypedArray(), + null + ) { _, _ -> latch.countDown() } + try { + latch.await(SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + private fun collectFilesToScan(): List { + val rootDirs = listOfNotNull( + getPublicDir(Environment.DIRECTORY_DCIM), + getPublicDir(Environment.DIRECTORY_MOVIES), + getPublicDir(Environment.DIRECTORY_PICTURES), + getPublicDir(Environment.DIRECTORY_DOWNLOADS), + getPublicDir(Environment.DIRECTORY_MUSIC), + getPublicDir(Environment.DIRECTORY_PODCASTS), + getPublicDir(Environment.DIRECTORY_ALARMS), + getPublicDir(Environment.DIRECTORY_RINGTONES), + getPublicDir(Environment.DIRECTORY_NOTIFICATIONS), + getPublicDir(Environment.DIRECTORY_DOCUMENTS) + ) + val out = mutableListOf() + for (root in rootDirs) { + walk(root, out) + if (out.size >= MAX_FILES_TO_SCAN) break + } + return out + } + + private fun getPublicDir(name: String): File? = + try { + Environment.getExternalStoragePublicDirectory(name).takeIf { it.isDirectory } + } catch (e: Exception) { + null + } + + private fun walk(dir: File, out: MutableList) { + if (out.size >= MAX_FILES_TO_SCAN) return + val children = dir.listFiles() ?: return + for (child in children) { + if (out.size >= MAX_FILES_TO_SCAN) return + val name = child.name + if (name.startsWith(".")) continue + if (child.isDirectory) { + walk(child, out) + } else if (child.isFile) { + out.add(child.absolutePath) + } + } + } } diff --git a/app/src/main/res/layout/file_list_fragment_content_include.xml b/app/src/main/res/layout/file_list_fragment_content_include.xml index 539ee9bfa..1a0283b1d 100644 --- a/app/src/main/res/layout/file_list_fragment_content_include.xml +++ b/app/src/main/res/layout/file_list_fragment_content_include.xml @@ -22,6 +22,27 @@ android:layout_gravity="center" android:visibility="gone" /> + + + + + + + Audio Documents APKs + Scanning for files… Bookmark folder Name Path