feat: choose download location (sd card)

This commit is contained in:
Fredrik Burmester
2025-11-14 21:34:13 +01:00
parent 631a5ef94e
commit 259306df52
10 changed files with 545 additions and 18 deletions

View File

@@ -4,11 +4,16 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Environment
import android.os.IBinder
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import android.util.Log
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
data class DownloadTaskInfo(
val url: String,
@@ -142,6 +147,105 @@ class BackgroundDownloaderModule : Module() {
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
}
}
AsyncFunction("getAvailableStorageLocations") { promise: Promise ->
try {
val storageLocations = mutableListOf<Map<String, Any>>()
// Use getExternalFilesDirs which works reliably across all Android versions
// This returns app-specific directories on both internal and external storage
val externalDirs = context.getExternalFilesDirs(null)
Log.d(TAG, "getExternalFilesDirs returned ${externalDirs.size} locations")
// Also check with StorageManager for additional info
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
val volumes = storageManager.storageVolumes
Log.d(TAG, "StorageManager reports ${volumes.size} volumes")
for ((i, vol) in volumes.withIndex()) {
Log.d(TAG, " Volume $i: isPrimary=${vol.isPrimary}, isRemovable=${vol.isRemovable}, state=${vol.state}, uuid=${vol.uuid}")
}
}
for ((index, dir) in externalDirs.withIndex()) {
try {
if (dir == null) {
Log.w(TAG, "Directory at index $index is null - SD card may not be mounted")
continue
}
if (!dir.exists()) {
Log.w(TAG, "Directory at index $index does not exist: ${dir.absolutePath}")
continue
}
val isPrimary = index == 0
val isRemovable = !isPrimary && Environment.isExternalStorageRemovable(dir)
// Get volume UUID for better identification
val volumeId = if (isPrimary) {
"internal"
} else {
// Try to get a stable UUID for the SD card
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
try {
val storageVolume = storageManager.getStorageVolume(dir)
storageVolume?.uuid ?: "sdcard_$index"
} catch (e: Exception) {
"sdcard_$index"
}
} else {
"sdcard_$index"
}
}
// Get human-readable label
val label = if (isPrimary) {
"Internal Storage"
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
try {
val storageVolume = storageManager.getStorageVolume(dir)
storageVolume?.getDescription(context) ?: "SD Card"
} catch (e: Exception) {
"SD Card"
}
} else {
"SD Card"
}
}
val totalSpace = dir.totalSpace
val freeSpace = dir.freeSpace
Log.d(TAG, "Storage location: $label (id: $volumeId, path: ${dir.absolutePath}, removable: $isRemovable)")
storageLocations.add(
mapOf(
"id" to volumeId,
"path" to dir.absolutePath,
"type" to (if (isRemovable || !isPrimary) "external" else "internal"),
"label" to label,
"totalSpace" to totalSpace,
"freeSpace" to freeSpace
)
)
} catch (e: Exception) {
Log.e(TAG, "Error processing storage at index $index: ${e.message}", e)
continue
}
}
Log.d(TAG, "Returning ${storageLocations.size} storage locations")
promise.resolve(storageLocations)
} catch (e: Exception) {
Log.e(TAG, "Error getting storage locations: ${e.message}", e)
promise.reject("ERROR", "Failed to get storage locations: ${e.message}", e)
}
}
}
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {

View File

@@ -5,6 +5,7 @@ import type {
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
StorageLocation,
} from "./src/BackgroundDownloader.types";
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
@@ -15,6 +16,7 @@ export interface BackgroundDownloader {
cancelQueuedDownload(url: string): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
getAvailableStorageLocations(): Promise<StorageLocation[]>;
addProgressListener(
listener: (event: DownloadProgressEvent) => void,
@@ -64,6 +66,10 @@ const BackgroundDownloader: BackgroundDownloader = {
return await BackgroundDownloaderModule.getActiveDownloads();
},
async getAvailableStorageLocations(): Promise<StorageLocation[]> {
return await BackgroundDownloaderModule.getAvailableStorageLocations();
},
addProgressListener(
listener: (event: DownloadProgressEvent) => void,
): EventSubscription {
@@ -106,4 +112,5 @@ export type {
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
StorageLocation,
};

View File

@@ -29,6 +29,15 @@ export interface ActiveDownload {
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
}
export interface StorageLocation {
id: string;
path: string;
type: "internal" | "external";
label: string;
totalSpace: number;
freeSpace: number;
}
export interface BackgroundDownloaderModuleType {
startDownload(url: string, destinationPath?: string): Promise<number>;
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
@@ -36,6 +45,7 @@ export interface BackgroundDownloaderModuleType {
cancelQueuedDownload(url: string): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
getAvailableStorageLocations(): Promise<StorageLocation[]>;
addListener(
eventName: string,
listener: (event: any) => void,

View File

@@ -18,6 +18,7 @@ export type {
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
StorageLocation,
} from "./background-downloader";
// Background Downloader
export { default as BackgroundDownloader } from "./background-downloader";