add files listing

This commit is contained in:
m
2026-07-27 20:50:23 +02:00
parent 0cc261105a
commit 5c85ffa5fe
25 changed files with 1312 additions and 32 deletions
@@ -0,0 +1,15 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'host.exp.exponent'
version = '0.1.0'
android {
namespace "expo.modules.downloaddetect"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
}
@@ -0,0 +1,3 @@
<manifest>
</manifest>
@@ -0,0 +1,77 @@
package expo.modules.downloaddetect
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
data class DetectedFile(
val id: String,
val uri: String,
val name: String,
val mimeType: String,
val size: Long,
val createdAt: Long
)
class DownloadBroadcastReceiver(
private val onFileDetected: (DetectedFile) -> Unit
) : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (downloadId == -1L) return
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val query = DownloadManager.Query().setFilterById(downloadId)
dm.query(query)?.use { cursor ->
if (!cursor.moveToFirst()) return
val uriIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
val titleIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TITLE)
val mimeTypeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_MEDIA_TYPE)
val sizeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val dateIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP)
val uri = if (uriIndex >= 0) cursor.getString(uriIndex) ?: "" else ""
val title = if (titleIndex >= 0) cursor.getString(titleIndex) ?: "unknown" else "unknown"
val mimeType = if (mimeTypeIndex >= 0) cursor.getString(mimeTypeIndex) ?: "application/octet-stream" else "application/octet-stream"
val size = if (sizeIndex >= 0) cursor.getLong(sizeIndex) else 0L
val date = if (dateIndex >= 0) cursor.getLong(dateIndex) else System.currentTimeMillis()
// Extract filename from URI
val name = extractFileName(uri, title)
onFileDetected(DetectedFile(
id = "download_$downloadId",
uri = uri,
name = name,
mimeType = mimeType,
size = size,
createdAt = date
))
}
}
private fun extractFileName(uri: String, fallback: String): String {
if (uri.isNotEmpty()) {
// content://media/external/downloads/123 or file:///storage/...
val path = uri.substringAfterLast("/")
if (path.isNotEmpty() && path.all { it.isDigit() }.not()) {
return decodeFileName(path)
}
}
return fallback
}
private fun decodeFileName(encoded: String): String {
return try {
java.net.URLDecoder.decode(encoded, "UTF-8")
} catch (_: Exception) {
encoded
}
}
}
@@ -0,0 +1,169 @@
package expo.modules.downloaddetect
import android.app.DownloadManager
import android.content.Context
import android.content.IntentFilter
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoDownloadDetectModule : Module() {
private var broadcastReceiver: DownloadBroadcastReceiver? = null
private var contentObserver: MediaStoreObserver? = null
private var isWatching = false
override fun definition() = ModuleDefinition {
Name("ExpoDownloadDetect")
Events("onNewFile")
Function("startWatching") {
if (!isWatching) startObserving()
null
}
Function("stopWatching") {
stopObserving()
}
AsyncFunction("getRecentDownloads") {
queryRecentFiles()
}
}
private val context: Context
get() = requireNotNull(appContext.reactContext)
private fun startObserving() {
val ctx = context
// Register BroadcastReceiver for DownloadManager
broadcastReceiver = DownloadBroadcastReceiver { event ->
sendEvent("onNewFile", bundleOf(
"id" to event.id,
"uri" to event.uri,
"name" to event.name,
"mimeType" to event.mimeType,
"size" to event.size,
"createdAt" to event.createdAt,
"source" to "download"
))
}
val intentFilter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
ctx.registerReceiver(broadcastReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
// Register ContentObserver on MediaStore
val handler = Handler(Looper.getMainLooper())
contentObserver = MediaStoreObserver(handler, ctx) { newFiles ->
for (file in newFiles) {
sendEvent("onNewFile", bundleOf(
"id" to file.id,
"uri" to file.uri,
"name" to file.name,
"mimeType" to file.mimeType,
"size" to file.size,
"createdAt" to file.createdAt,
"source" to "mediastore"
))
}
}
val uri = MediaStore.Files.getContentUri("external")
ctx.contentResolver.registerContentObserver(uri, true, contentObserver!!)
// Scan existing files on startup
val existingFiles = queryRecentFiles()
for (file in existingFiles) {
sendEvent("onNewFile", bundleOf(
"id" to file.id,
"uri" to file.uri,
"name" to file.name,
"mimeType" to file.mimeType,
"size" to file.size,
"createdAt" to file.createdAt,
"source" to "startup"
))
}
isWatching = true
}
private fun stopObserving() {
val ctx = context
broadcastReceiver?.let {
try {
ctx.unregisterReceiver(it)
} catch (_: Exception) {}
broadcastReceiver = null
}
contentObserver?.let {
ctx.contentResolver.unregisterContentObserver(it)
contentObserver = null
}
isWatching = false
}
private fun queryRecentFiles(): List<DetectedFile> {
val files = mutableListOf<DetectedFile>()
val projection = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
MediaStore.Files.FileColumns.MIME_TYPE,
MediaStore.Files.FileColumns.SIZE,
MediaStore.Files.FileColumns.DATE_ADDED
)
val selection = "${MediaStore.Files.FileColumns.SIZE} > 0"
val sortOrder = "${MediaStore.Files.FileColumns.DATE_ADDED} DESC"
val limit = 100
context.contentResolver.query(
MediaStore.Files.getContentUri("external"),
projection,
selection,
null,
sortOrder
)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE)
val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.SIZE)
val dateCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
var count = 0
while (cursor.moveToNext() && count < limit) {
val id = cursor.getLong(idCol)
val name = cursor.getString(nameCol) ?: continue
val mimeType = cursor.getString(mimeCol) ?: "application/octet-stream"
val size = cursor.getLong(sizeCol)
val dateAdded = cursor.getLong(dateCol)
val uri = Uri.withAppendedPath(
MediaStore.Files.getContentUri("external"),
id.toString()
).toString()
files.add(DetectedFile(
id = "media_$id",
uri = uri,
name = name,
mimeType = mimeType,
size = size,
createdAt = dateAdded * 1000L
))
count++
}
}
return files
}
}
@@ -0,0 +1,105 @@
package expo.modules.downloaddetect
import android.content.ContentResolver
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.os.Handler
import android.provider.MediaStore
class MediaStoreObserver(
handler: Handler,
private val context: Context,
private val onNewFiles: (List<DetectedFile>) -> Unit
) : ContentObserver(handler) {
private var lastScanTimestamp: Long = System.currentTimeMillis() / 1000L
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
queryNewFiles()
}
override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) {
super.onChange(selfChange, uri, flags)
queryNewFiles()
}
private fun queryNewFiles() {
val files = mutableListOf<DetectedFile>()
val projection = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
MediaStore.Files.FileColumns.MIME_TYPE,
MediaStore.Files.FileColumns.SIZE,
MediaStore.Files.FileColumns.DATE_ADDED
)
val selection = "${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND ${MediaStore.Files.FileColumns.SIZE} > 0"
val selectionArgs = arrayOf(lastScanTimestamp.toString())
val sortOrder = "${MediaStore.Files.FileColumns.DATE_ADDED} DESC"
val resolver: ContentResolver = context.contentResolver
var cursor: Cursor? = null
try {
// Try MediaStore.Downloads first
cursor = try {
resolver.query(
MediaStore.Downloads.getContentUri("external"),
projection, selection, selectionArgs, sortOrder
)
} catch (_: Exception) {
null
}
// Fallback to MediaStore.Files if Downloads didn't work
if (cursor == null || cursor.count == 0) {
cursor?.close()
cursor = resolver.query(
MediaStore.Files.getContentUri("external"),
projection, selection, selectionArgs, sortOrder
)
}
cursor?.use { c ->
val idCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
val nameCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
val mimeCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE)
val sizeCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.SIZE)
val dateCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
while (c.moveToNext()) {
val id = c.getLong(idCol)
val name = c.getString(nameCol) ?: continue
val mimeType = c.getString(mimeCol) ?: "application/octet-stream"
val size = c.getLong(sizeCol)
val dateAdded = c.getLong(dateCol)
val fileUri = Uri.withAppendedPath(
MediaStore.Files.getContentUri("external"),
id.toString()
).toString()
files.add(DetectedFile(
id = "media_$id",
uri = fileUri,
name = name,
mimeType = mimeType,
size = size,
createdAt = dateAdded * 1000L
))
}
}
} catch (_: Exception) {
} finally {
cursor?.close()
}
if (files.isNotEmpty()) {
lastScanTimestamp = System.currentTimeMillis() / 1000L
onNewFiles(files)
}
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.downloaddetect.ExpoDownloadDetectModule"]
}
}
@@ -0,0 +1,2 @@
export { ExpoDownloadDetectModule } from './src';
export type { FileDetectedEvent } from './src';
@@ -0,0 +1,12 @@
{
"name": "expo-download-detect",
"version": "0.1.0",
"description": "Detect new files via DownloadManager and MediaStore ContentObserver",
"main": "index.ts",
"android": {
"sourceDir": "android"
},
"peerDependencies": {
"expo": ">=57.0.0"
}
}
@@ -0,0 +1,13 @@
export type FileDetectedEvent = {
id: string;
uri: string;
name: string;
mimeType: string;
size: number;
createdAt: number;
source: 'download' | 'mediastore' | 'startup';
};
export type ExpoDownloadDetectModuleEvents = {
onNewFile: (event: FileDetectedEvent) => void;
};
@@ -0,0 +1,10 @@
import { NativeModule, requireNativeModule } from 'expo';
import { ExpoDownloadDetectModuleEvents, FileDetectedEvent } from './ExpoDownloadDetect.types';
declare class ExpoDownloadDetectModule extends NativeModule<ExpoDownloadDetectModuleEvents> {
startWatching(): void;
stopWatching(): void;
getRecentDownloads(): Promise<FileDetectedEvent[]>;
}
export default requireNativeModule<ExpoDownloadDetectModule>('ExpoDownloadDetect');
@@ -0,0 +1,2 @@
export { default as ExpoDownloadDetectModule } from './ExpoDownloadDetectModule';
export type { FileDetectedEvent, ExpoDownloadDetectModuleEvents } from './ExpoDownloadDetect.types';