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:
@@ -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) {
|
||||||
|
|||||||
@@ -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()}');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建模板字节流(可在测试中直接调用)
|
/// 构建模板字节流(可在测试中直接调用)
|
||||||
|
|||||||
Reference in New Issue
Block a user