Skip to content

Commit 0eb7309

Browse files
committed
feat: kt 侧添加 WritableLocation 类以统一处理文件写入路径,支持 SAF 和 MediaStore 回退
1 parent 22ebe67 commit 0eb7309

5 files changed

Lines changed: 415 additions & 194 deletions

File tree

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
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

Comments
 (0)