From cbe1e6b4708972fb2824f49ad92c2861fb61d431 Mon Sep 17 00:00:00 2001 From: Developer <91611@user.local> Date: Thu, 4 Jun 2026 15:45:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(programs):=20Android=20=E7=AB=AF=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20MediaStore=20=E5=86=99=E5=85=A5=E5=85=AC=E5=85=B1?= =?UTF-8?q?=20Downloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 显示路径;其它平台保留 getDownloadsDirectory 行为。 返回值由 File 改为 String,调用方已同步更新。 --- .../com/xiarui/kuaishai2/MainActivity.kt | 76 +++++++++++++++++++ .../programs/pages/programs_page.dart | 4 +- .../services/excel_template_service.dart | 37 +++++++-- 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/kotlin/com/xiarui/kuaishai2/MainActivity.kt b/android/app/src/main/kotlin/com/xiarui/kuaishai2/MainActivity.kt index 5be51ca..b62b2ae 100644 --- a/android/app/src/main/kotlin/com/xiarui/kuaishai2/MainActivity.kt +++ b/android/app/src/main/kotlin/com/xiarui/kuaishai2/MainActivity.kt @@ -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("filename") + val bytes = call.argument("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) { diff --git a/lib/features/programs/pages/programs_page.dart b/lib/features/programs/pages/programs_page.dart index 1ea5351..49259f2 100644 --- a/lib/features/programs/pages/programs_page.dart +++ b/lib/features/programs/pages/programs_page.dart @@ -454,9 +454,9 @@ class _ProgramsPageState extends ConsumerState { /// 下载 Excel 模板 Future _downloadTemplate(BuildContext context) async { try { - final file = await ExcelTemplateService.instance.generateTemplate(); + final path = await ExcelTemplateService.instance.generateTemplate(); if (!context.mounted) return; - ToastService.showSuccess(context, '模板已保存: ${file.path}'); + ToastService.showSuccess(context, '模板已保存: $path'); } catch (e) { if (!context.mounted) return; ToastService.showError(context, '生成模板失败: ${e.toString()}'); diff --git a/lib/features/programs/services/excel_template_service.dart b/lib/features/programs/services/excel_template_service.dart index 322790d..c61ed8c 100644 --- a/lib/features/programs/services/excel_template_service.dart +++ b/lib/features/programs/services/excel_template_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:excel/excel.dart'; +import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; /// Excel 模板服务 @@ -18,16 +19,40 @@ class ExcelTemplateService { static const String sheetPrograms = 'Programs'; 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'); + + /// 生成模板并保存到下载目录,返回可显示的路径字符串 /// - /// 优先使用系统下载目录;不支持时回退到应用文档目录。 - Future generateTemplate() async { + /// - Android:通过 MediaStore 写入公共 Downloads (`/storage/emulated/0/Download/`) + /// - 其它平台:使用 [getDownloadsDirectory],不可用时回退到应用文档目录 + Future generateTemplate() async { final excel = Excel.createExcel(); 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( + 'saveToDownloads', + { + '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); - return file; + return file.path; } /// 构建模板字节流(可在测试中直接调用)