|
| 1 | +package expo.modules.nativeutil |
| 2 | + |
| 3 | +import android.content.ContentValues |
| 4 | +import android.content.Context |
| 5 | +import android.net.Uri |
| 6 | +import android.os.Build |
| 7 | +import android.os.Environment |
| 8 | +import android.provider.MediaStore |
| 9 | +import androidx.documentfile.provider.DocumentFile |
| 10 | +import java.io.File |
| 11 | +import java.io.IOException |
| 12 | +import java.io.OutputStream |
| 13 | + |
| 14 | +/** |
| 15 | + * 可写入文件系统位置的统一句柄。 |
| 16 | + * |
| 17 | + * 内部封装 SAF (DocumentFile) 和 MediaStore 两种写入路径,对调用方完全透明。 |
| 18 | + * 当目标为 Downloads 根目录且 SAF 写入文件失败时,自动回退到 MediaStore API。 |
| 19 | + * |
| 20 | + * 使用示例: |
| 21 | + * ``` |
| 22 | + * val root = WritableLocation.fromUri(context, destUri) |
| 23 | + * val subdir = root?.createDirectory("subdir") |
| 24 | + * val stream = subdir?.createFile("hello.txt", "text/plain") |
| 25 | + * stream?.use { it.write(data) } |
| 26 | + * ``` |
| 27 | + */ |
| 28 | +class WritableLocation internal constructor( |
| 29 | + private val context: Context, |
| 30 | + private val strategy: WriteStrategy |
| 31 | +) { |
| 32 | + /** 当前是否为目录 */ |
| 33 | + val isDirectory: Boolean get() = strategy.isDirectory |
| 34 | + /** 当前文件/目录名称 */ |
| 35 | + val name: String? get() = strategy.name |
| 36 | + |
| 37 | + /** 检查此位置是否存在 */ |
| 38 | + fun exists(): Boolean = strategy.exists() |
| 39 | + |
| 40 | + /** |
| 41 | + * 在此位置下创建子目录。如果目录已存在则返回已存在的目录句柄。 |
| 42 | + * @return 子目录的 [WritableLocation],失败返回 null |
| 43 | + */ |
| 44 | + fun createDirectory(name: String): WritableLocation? { |
| 45 | + // 先查找是否已存在 |
| 46 | + val existing = findFile(name) |
| 47 | + if (existing != null && existing.isDirectory) return existing |
| 48 | + val child = strategy.createDirectory(context, name) ?: return null |
| 49 | + return WritableLocation(context, child) |
| 50 | + } |
| 51 | + |
| 52 | + /** |
| 53 | + * 在此位置下查找已存在的文件或目录。 |
| 54 | + * @return 找到的 [WritableLocation],未找到返回 null |
| 55 | + */ |
| 56 | + fun findFile(name: String): WritableLocation? { |
| 57 | + val child = strategy.findFile(context, name) ?: return null |
| 58 | + return WritableLocation(context, child) |
| 59 | + } |
| 60 | + |
| 61 | + /** |
| 62 | + * 在此位置下创建文件并打开输出流。 |
| 63 | + * |
| 64 | + * 内部策略: |
| 65 | + * - 优先通过 SAF (DocumentFile) 创建文件并打开流 |
| 66 | + * - 如果 SAF 失败且当前位置在 Downloads 目录树内,自动回退到 MediaStore API |
| 67 | + * |
| 68 | + * @param name 文件名 |
| 69 | + * @param mimeType MIME 类型,默认 "application/octet-stream" |
| 70 | + * @param overwrite 是否覆盖已存在的同名文件;false 时若存在则抛出 [IOException] |
| 71 | + * @return 可写入的 [OutputStream];调用方负责 close() |
| 72 | + * @throws IOException 创建失败或同名文件已存在且 overwrite=false |
| 73 | + */ |
| 74 | + @Throws(IOException::class) |
| 75 | + fun createFile(name: String, mimeType: String = "application/octet-stream", overwrite: Boolean = false): OutputStream { |
| 76 | + val existing = findFile(name) |
| 77 | + if (existing != null && !existing.isDirectory) { |
| 78 | + if (!overwrite) throw IOException("File already exists: $name") |
| 79 | + existing.delete() |
| 80 | + } |
| 81 | + return strategy.createFile(context, name, mimeType) |
| 82 | + ?: throw IOException("Failed to create file: $name") |
| 83 | + } |
| 84 | + |
| 85 | + /** 删除此位置对应的文件或目录 */ |
| 86 | + fun delete(): Boolean = strategy.delete() |
| 87 | + |
| 88 | + companion object { |
| 89 | + /** |
| 90 | + * 根据目标 URI 创建 [WritableLocation]。 |
| 91 | + * |
| 92 | + * 支持两种 URI 格式: |
| 93 | + * - `content://` — SAF tree URI(通过 [DocumentFile.fromTreeUri] 打开) |
| 94 | + * - `file://` 或纯路径 — 本地文件系统路径(通过 [DocumentFile.fromFile] 打开) |
| 95 | + * |
| 96 | + * 自动检测目标是否为 Downloads 根目录,以启用 MediaStore 回退。 |
| 97 | + * |
| 98 | + * @param context Android Context |
| 99 | + * @param uri 目标目录 URI |
| 100 | + * @return 创建成功返回 [WritableLocation],失败返回 null |
| 101 | + */ |
| 102 | + fun fromUri(context: Context, uri: Uri): WritableLocation? { |
| 103 | + return when (uri.scheme) { |
| 104 | + "content" -> fromContentUri(context, uri) |
| 105 | + else -> fromFilePath(context, uri) |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + private fun fromContentUri(context: Context, uri: Uri): WritableLocation? { |
| 110 | + val doc = DocumentFile.fromTreeUri(context, uri) ?: return null |
| 111 | + if (!doc.exists()) return null |
| 112 | + val underDownload = isUriUnderDownloadRoot(uri) |
| 113 | + val strategy = SafStrategy(doc, underDownload, relativePath = "") |
| 114 | + return WritableLocation(context, strategy) |
| 115 | + } |
| 116 | + |
| 117 | + private fun fromFilePath(context: Context, uri: Uri): WritableLocation? { |
| 118 | + val path = uri.path ?: uri.toString().removePrefix("file://") |
| 119 | + val file = File(path) |
| 120 | + if (!file.exists() || !file.isDirectory) return null |
| 121 | + val doc = DocumentFile.fromFile(file) |
| 122 | + val underDownload = isPathUnderDownloadRoot(file) |
| 123 | + val strategy = SafStrategy(doc, underDownload, relativePath = "") |
| 124 | + return WritableLocation(context, strategy) |
| 125 | + } |
| 126 | + |
| 127 | + // ── Downloads 根目录检测 ─────────────────────────────────────── |
| 128 | + |
| 129 | + /** |
| 130 | + * 检测 content:// URI 是否指向 Downloads 根目录或其子目录。 |
| 131 | + * |
| 132 | + * Downloads 根目录可能有两种 URI 形式: |
| 133 | + * - Downloads provider: `.../tree/downloads` |
| 134 | + * - External storage provider: `.../tree/primary%3ADownload`(URL decode → `primary:Download`) |
| 135 | + */ |
| 136 | + private fun isUriUnderDownloadRoot(uri: Uri): Boolean { |
| 137 | + val path = uri.path ?: return false |
| 138 | + val treeMatch = Regex("/tree/([^/?#]+)", RegexOption.IGNORE_CASE).find(path) |
| 139 | + ?: return false |
| 140 | + val treeDocId = Uri.decode(treeMatch.groupValues[1]) |
| 141 | + return treeDocId.equals("downloads", ignoreCase = true) || |
| 142 | + treeDocId.equals("primary:Download", ignoreCase = true) |
| 143 | + } |
| 144 | + |
| 145 | + /** |
| 146 | + * 检测本地文件路径是否位于 Downloads 目录树内。 |
| 147 | + */ |
| 148 | + @Suppress("DEPRECATION") |
| 149 | + private fun isPathUnderDownloadRoot(file: File): Boolean { |
| 150 | + val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) |
| 151 | + return try { |
| 152 | + val canonical = file.canonicalPath |
| 153 | + val downloadsCanonical = downloadsDir.canonicalPath |
| 154 | + canonical == downloadsCanonical || canonical.startsWith("$downloadsCanonical/") |
| 155 | + } catch (_: Exception) { |
| 156 | + false |
| 157 | + } |
| 158 | + } |
| 159 | + } |
| 160 | +} |
| 161 | + |
| 162 | +// ═══════════════════════════════════════════════════════════════════════ |
| 163 | +// 内部策略 |
| 164 | +// ═══════════════════════════════════════════════════════════════════════ |
| 165 | + |
| 166 | +/** |
| 167 | + * 文件写入策略接口:将底层文件系统差异(SAF vs MediaStore)封装在策略实现中。 |
| 168 | + */ |
| 169 | +internal interface WriteStrategy { |
| 170 | + val isDirectory: Boolean |
| 171 | + val name: String? |
| 172 | + fun exists(): Boolean |
| 173 | + fun createDirectory(context: Context, name: String): WriteStrategy? |
| 174 | + fun findFile(context: Context, name: String): WriteStrategy? |
| 175 | + fun createFile(context: Context, name: String, mimeType: String): OutputStream? |
| 176 | + fun delete(): Boolean |
| 177 | +} |
| 178 | + |
| 179 | +/** |
| 180 | + * 基于 SAF (DocumentFile) 的写入策略。 |
| 181 | + * |
| 182 | + * 当 [underDownloadRoot] 为 true 时,记录从 Downloads 根目录到当前位置的 |
| 183 | + * [relativePath],以便在 SAF 文件创建失败时通过 MediaStore API 回退。 |
| 184 | + * |
| 185 | + * @property doc 当前目录的 DocumentFile |
| 186 | + * @property underDownloadRoot 当前位置是否位于 Downloads 目录树内 |
| 187 | + * @property relativePath 从 Downloads 根目录到当前位置的相对路径, |
| 188 | + * 不含 "Download/" 前缀,末尾带 "/" |
| 189 | + * 例如: ""(根), "subdir/", "a/b/" |
| 190 | + */ |
| 191 | +internal class SafStrategy( |
| 192 | + private val doc: DocumentFile, |
| 193 | + private val underDownloadRoot: Boolean, |
| 194 | + private val relativePath: String |
| 195 | +) : WriteStrategy { |
| 196 | + |
| 197 | + override val isDirectory: Boolean get() = doc.isDirectory |
| 198 | + override val name: String? get() = doc.name |
| 199 | + override fun exists(): Boolean = doc.exists() |
| 200 | + |
| 201 | + override fun createDirectory(context: Context, name: String): WriteStrategy? { |
| 202 | + val existing = doc.findFile(name) |
| 203 | + if (existing != null && existing.isDirectory) { |
| 204 | + return SafStrategy(existing, underDownloadRoot, "$relativePath$name/") |
| 205 | + } |
| 206 | + val newDir = doc.createDirectory(name) ?: return null |
| 207 | + return SafStrategy(newDir, underDownloadRoot, "$relativePath$name/") |
| 208 | + } |
| 209 | + |
| 210 | + override fun findFile(context: Context, name: String): WriteStrategy? { |
| 211 | + val found = doc.findFile(name) ?: return null |
| 212 | + // 找到的文件/目录不再传递 underDownloadRoot 和 relativePath, |
| 213 | + // 因为 findFile 通常用于检查存在性或删除,不需要创建文件的回退路径。 |
| 214 | + return SafStrategy(found, underDownloadRoot = false, relativePath = "") |
| 215 | + } |
| 216 | + |
| 217 | + override fun createFile(context: Context, name: String, mimeType: String): OutputStream? { |
| 218 | + // 1. 优先尝试 SAF (DocumentFile) 创建文件 |
| 219 | + try { |
| 220 | + val newFile = doc.createFile(mimeType, name) |
| 221 | + if (newFile != null) { |
| 222 | + val stream = context.contentResolver.openOutputStream(newFile.uri) |
| 223 | + if (stream != null) return stream |
| 224 | + } |
| 225 | + } catch (_: Exception) { |
| 226 | + NativeLogger.w("FileOperations", "SAF createFile failed for '$name' at '$relativePath'") |
| 227 | + } |
| 228 | + |
| 229 | + // 2. SAF 失败:如果在 Downloads 目录树内,回退到 MediaStore |
| 230 | + if (underDownloadRoot && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { |
| 231 | + NativeLogger.d("FileOperations", "Falling back to MediaStore for '$name' at '$relativePath'") |
| 232 | + return createFileViaMediaStore(context, name, mimeType, relativePath) |
| 233 | + } |
| 234 | + |
| 235 | + return null |
| 236 | + } |
| 237 | + |
| 238 | + override fun delete(): Boolean = doc.delete() |
| 239 | +} |
| 240 | + |
| 241 | +// ═══════════════════════════════════════════════════════════════════════ |
| 242 | +// MediaStore 工具函数 |
| 243 | +// ═══════════════════════════════════════════════════════════════════════ |
| 244 | + |
| 245 | +/** |
| 246 | + * 通过 [MediaStore.Downloads] 创建文件并返回可写入的 [OutputStream]。 |
| 247 | + * |
| 248 | + * 返回的 OutputStream 在 close() 时会自动将 IS_PENDING 置为 0; |
| 249 | + * 若写入过程中发生异常则删除已创建的条目。 |
| 250 | + * |
| 251 | + * @param relativePath 相对于 Downloads 的路径(不含 "Download/" 前缀), |
| 252 | + * 末尾带 "/",例如 "" 表示 Download/ 根目录,"subdir/" 表示 Download/subdir/ |
| 253 | + */ |
| 254 | +internal fun createFileViaMediaStore( |
| 255 | + context: Context, |
| 256 | + fileName: String, |
| 257 | + mimeType: String, |
| 258 | + relativePath: String |
| 259 | +): OutputStream? { |
| 260 | + val resolver = context.contentResolver |
| 261 | + val fullPath = "Download/$relativePath" |
| 262 | + |
| 263 | + // 先删除已存在的同名文件,避免重复条目 |
| 264 | + deleteMediaStoreFileByPath(resolver, fileName, fullPath) |
| 265 | + |
| 266 | + val values = ContentValues().apply { |
| 267 | + put(MediaStore.Downloads.DISPLAY_NAME, fileName) |
| 268 | + put(MediaStore.Downloads.MIME_TYPE, mimeType) |
| 269 | + put(MediaStore.Downloads.RELATIVE_PATH, fullPath) |
| 270 | + put(MediaStore.Downloads.IS_PENDING, 1) |
| 271 | + } |
| 272 | + |
| 273 | + val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) |
| 274 | + val item = resolver.insert(collection, values) ?: return null |
| 275 | + |
| 276 | + val delegate = resolver.openOutputStream(item) ?: run { |
| 277 | + resolver.delete(item, null, null) |
| 278 | + return null |
| 279 | + } |
| 280 | + |
| 281 | + return object : OutputStream() { |
| 282 | + private var closed = false |
| 283 | + |
| 284 | + override fun write(b: Int) { |
| 285 | + if (closed) throw IOException("stream closed") |
| 286 | + delegate.write(b) |
| 287 | + } |
| 288 | + |
| 289 | + override fun write(b: ByteArray) { |
| 290 | + if (closed) throw IOException("stream closed") |
| 291 | + delegate.write(b) |
| 292 | + } |
| 293 | + |
| 294 | + override fun write(b: ByteArray, off: Int, len: Int) { |
| 295 | + if (closed) throw IOException("stream closed") |
| 296 | + delegate.write(b, off, len) |
| 297 | + } |
| 298 | + |
| 299 | + override fun flush() = delegate.flush() |
| 300 | + |
| 301 | + override fun close() { |
| 302 | + if (closed) return |
| 303 | + closed = true |
| 304 | + try { |
| 305 | + delegate.close() |
| 306 | + val updateValues = ContentValues().apply { |
| 307 | + put(MediaStore.Downloads.IS_PENDING, 0) |
| 308 | + } |
| 309 | + resolver.update(item, updateValues, null, null) |
| 310 | + } catch (e: Exception) { |
| 311 | + // 写入失败时清理残留条目 |
| 312 | + try { resolver.delete(item, null, null) } catch (_: Exception) {} |
| 313 | + throw e |
| 314 | + } |
| 315 | + } |
| 316 | + } |
| 317 | +} |
| 318 | + |
| 319 | +/** |
| 320 | + * 从 MediaStore.Downloads 中删除指定路径和文件名的文件。 |
| 321 | + */ |
| 322 | +private fun deleteMediaStoreFileByPath( |
| 323 | + resolver: android.content.ContentResolver, |
| 324 | + fileName: String, |
| 325 | + relativePath: String |
| 326 | +) { |
| 327 | + val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) |
| 328 | + val selection = "${MediaStore.Downloads.DISPLAY_NAME} = ? AND ${MediaStore.Downloads.RELATIVE_PATH} = ?" |
| 329 | + val selectionArgs = arrayOf(fileName, relativePath) |
| 330 | + |
| 331 | + resolver.query(collection, arrayOf(MediaStore.Downloads._ID), selection, selectionArgs, null) |
| 332 | + ?.use { cursor -> |
| 333 | + if (cursor.moveToFirst()) { |
| 334 | + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Downloads._ID)) |
| 335 | + resolver.delete(collection, "${MediaStore.Downloads._ID} = ?", arrayOf(id.toString())) |
| 336 | + } |
| 337 | + } |
| 338 | +} |
0 commit comments