feat(programs): Android 端通过 MediaStore 写入公共 Downloads

Android 10+ 受 scoped storage 限制,getDownloadsDirectory() 返回的是
app-specific 目录 (/storage/emulated/0/Android/data/.../files/Download/),
而非用户可见的 /storage/emulated/0/Download/。

新增 MainActivity 端 MethodChannel com.xiarui.kuaishai2/downloads:
- API 29+:MediaStore.Downloads 写入公共 Downloads,无需权限
- API <=28:直接写 /storage/emulated/0/Download/(需 WRITE_EXTERNAL_STORAGE)

Dart 端 ExcelTemplateService 改用 MethodChannel,Android 平台返回
Download/<filename> 显示路径;其它平台保留 getDownloadsDirectory 行为。
返回值由 File 改为 String,调用方已同步更新。
This commit is contained in:
Developer
2026-06-04 15:45:23 +08:00
parent 16fbb7d54b
commit cbe1e6b470
3 changed files with 109 additions and 8 deletions

View File

@@ -1,10 +1,18 @@
package com.xiarui.kuaishai2
import android.content.ContentValues
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileOutputStream
/**
* Kiosk 模式主 Activity
@@ -14,12 +22,80 @@ import io.flutter.embedding.android.FlutterActivity
*/
class MainActivity : FlutterActivity() {
private val downloadsChannel = "com.xiarui.kuaishai2/downloads"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 保持屏幕常亮
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, downloadsChannel)
.setMethodCallHandler { call, result ->
when (call.method) {
"saveToDownloads" -> handleSaveToDownloads(call, result)
else -> result.notImplemented()
}
}
}
private fun handleSaveToDownloads(
call: io.flutter.plugin.common.MethodCall,
result: MethodChannel.Result,
) {
val filename = call.argument<String>("filename")
val bytes = call.argument<ByteArray>("bytes")
if (filename.isNullOrEmpty() || bytes == null) {
result.error("ARG_ERROR", "filename 和 bytes 必填", null)
return
}
try {
val savedPath = saveToDownloads(filename, bytes)
result.success(savedPath)
} catch (e: Exception) {
result.error("SAVE_FAILED", e.message ?: "保存失败", null)
}
}
/**
* 保存到公共 Downloads 目录。
* - Android 10+:通过 MediaStore.Downloads 写入,无需 WRITE_EXTERNAL_STORAGE 权限
* - Android 9 及以下:直接写 /storage/emulated/0/Download/,需 WRITE_EXTERNAL_STORAGE
*/
private fun saveToDownloads(filename: String, bytes: ByteArray): String {
val mimeType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, filename)
put(MediaStore.Downloads.MIME_TYPE, mimeType)
put(MediaStore.Downloads.IS_PENDING, 1)
}
val resolver = contentResolver
val uri = resolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
values,
) ?: throw IllegalStateException("无法创建 MediaStore 记录")
resolver.openOutputStream(uri)?.use { it.write(bytes) }
?: throw IllegalStateException("无法打开输出流")
values.clear()
values.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, values, null, null)
"${Environment.DIRECTORY_DOWNLOADS}/$filename"
} else {
@Suppress("DEPRECATION")
val dir = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS,
)
if (!dir.exists()) dir.mkdirs()
val file = File(dir, filename)
FileOutputStream(file).use { it.write(bytes) }
file.absolutePath
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {