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 package com.xiarui.kuaishai2
import android.content.ContentValues
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity 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 * Kiosk 模式主 Activity
@@ -14,12 +22,80 @@ import io.flutter.embedding.android.FlutterActivity
*/ */
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val downloadsChannel = "com.xiarui.kuaishai2/downloads"
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// 保持屏幕常亮 // 保持屏幕常亮
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 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) { override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus) super.onWindowFocusChanged(hasFocus)
if (hasFocus) { if (hasFocus) {

View File

@@ -454,9 +454,9 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
/// 下载 Excel 模板 /// 下载 Excel 模板
Future<void> _downloadTemplate(BuildContext context) async { Future<void> _downloadTemplate(BuildContext context) async {
try { try {
final file = await ExcelTemplateService.instance.generateTemplate(); final path = await ExcelTemplateService.instance.generateTemplate();
if (!context.mounted) return; if (!context.mounted) return;
ToastService.showSuccess(context, '模板已保存: ${file.path}'); ToastService.showSuccess(context, '模板已保存: $path');
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
ToastService.showError(context, '生成模板失败: ${e.toString()}'); ToastService.showError(context, '生成模板失败: ${e.toString()}');

View File

@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:excel/excel.dart'; import 'package:excel/excel.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
/// Excel 模板服务 /// Excel 模板服务
@@ -18,16 +19,40 @@ class ExcelTemplateService {
static const String sheetPrograms = 'Programs'; static const String sheetPrograms = 'Programs';
static const String sheetSteps = 'Steps'; static const String sheetSteps = 'Steps';
/// 生成模板并保存到下载目录,返回文件对象 /// 模板文件名
static const String templateFilename = 'program_template.xlsx';
/// Android 端写入公共 Downloads 的 MethodChannel对应 MainActivity.kt
static const MethodChannel _downloadsChannel =
MethodChannel('com.xiarui.kuaishai2/downloads');
/// 生成模板并保存到下载目录,返回可显示的路径字符串
/// ///
/// 优先使用系统下载目录;不支持时回退到应用文档目录。 /// - Android通过 MediaStore 写入公共 Downloads (`/storage/emulated/0/Download/`)
Future<File> generateTemplate() async { /// - 其它平台:使用 [getDownloadsDirectory],不可用时回退到应用文档目录
Future<String> generateTemplate() async {
final excel = Excel.createExcel(); final excel = Excel.createExcel();
final bytes = _buildTemplateBytes(excel); final bytes = _buildTemplateBytes(excel);
final dir = await getDownloadsDirectory() ?? await getApplicationDocumentsDirectory();
final file = File('${dir.path}/program_template.xlsx'); if (Platform.isAndroid) {
final saved = await _downloadsChannel.invokeMethod<String>(
'saveToDownloads',
<String, dynamic>{
'filename': templateFilename,
'bytes': bytes,
},
);
if (saved == null) {
throw StateError('保存到 Downloads 失败:未返回路径');
}
return saved;
}
final dir =
await getDownloadsDirectory() ?? await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$templateFilename');
await file.writeAsBytes(bytes, flush: true); await file.writeAsBytes(bytes, flush: true);
return file; return file.path;
} }
/// 构建模板字节流(可在测试中直接调用) /// 构建模板字节流(可在测试中直接调用)