Handle download in dart to support customized request

This commit is contained in:
Ming Ming 2021-11-03 21:38:15 +08:00
parent 15523c8bda
commit 50a80fe2a3
9 changed files with 141 additions and 353 deletions

View file

@ -1,109 +0,0 @@
package com.nkming.nc_photos
import android.app.Activity
import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Download a file
*
* Methods:
* Download a file at @a url. If @a shouldNotify is false, no progress
* notifications would be shown
* fun downloadUrl(url: String, headers: Map<String, String>?,
* mimeType: String?, filename: String, shouldNotify: Boolean?): String
*/
class DownloadChannelHandler(activity: Activity) :
MethodChannel.MethodCallHandler {
companion object {
@JvmStatic
val CHANNEL = "com.nkming.nc_photos/download"
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"downloadUrl" -> {
try {
downloadUrl(
call.argument("url")!!,
call.argument("headers"),
call.argument("mimeType"),
call.argument("filename")!!,
call.argument("shouldNotify"),
result
)
} catch (e: Throwable) {
result.error("systemException", e.toString(), null)
}
}
"cancel" -> {
try {
cancel(
call.argument("id")!!, result
)
} catch (e: Throwable) {
result.error("systemException", e.toString(), null)
}
}
else -> {
result.notImplemented()
}
}
}
private fun downloadUrl(
url: String,
headers: Map<String, String>?,
mimeType: String?,
filename: String,
shouldNotify: Boolean?,
result: MethodChannel.Result
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
if (!PermissionHandler.ensureWriteExternalStorage(_activity)) {
result.error("permissionError", "Permission not granted", null)
return
}
}
val uri = Uri.parse(url)
val req = DownloadManager.Request(uri).apply {
setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS, filename
)
for (h in headers ?: mapOf()) {
addRequestHeader(h.key, h.value)
}
if (mimeType != null) {
setMimeType(mimeType)
}
setVisibleInDownloadsUi(true)
setNotificationVisibility(
if (shouldNotify == false) DownloadManager.Request.VISIBILITY_HIDDEN
else DownloadManager.Request.VISIBILITY_VISIBLE
)
allowScanningByMediaScanner()
}
val id = _downloadManager.enqueue(req)
result.success(id)
}
private fun cancel(
id: Long, result: MethodChannel.Result
) {
val count = _downloadManager.remove(id)
result.success(count > 0)
}
private val _activity = activity
private val _context get() = _activity
private val _downloadManager by lazy {
_context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
}
}

View file

@ -1,99 +1,10 @@
package com.nkming.nc_photos package com.nkming.nc_photos
import android.app.DownloadManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.net.Uri
import androidx.core.content.FileProvider
import io.flutter.Log
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import java.io.File
/**
* Send DownloadManager ACTION_DOWNLOAD_COMPLETE events to flutter
*/
class DownloadEventCompleteChannelHandler(context: Context) :
BroadcastReceiver(), EventChannel.StreamHandler {
companion object {
@JvmStatic
val CHANNEL =
"com.nkming.nc_photos/download_event/action_download_complete"
}
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE || !intent.hasExtra(
DownloadManager.EXTRA_DOWNLOAD_ID
)
) {
return
}
val downloadId =
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0)
// check the status of the job and retrieve the local URI
val c = _downloadManager.query(
DownloadManager.Query().setFilterById(downloadId)
)
if (c.moveToFirst()) {
val status =
c.getInt(c.getColumnIndex(DownloadManager.COLUMN_STATUS))
if (status == DownloadManager.STATUS_SUCCESSFUL) {
val uri =
c.getString(c.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
val contentUri = FileProvider.getUriForFile(
_context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
File(Uri.parse(uri).path!!)
)
_eventSink?.success(
mapOf(
"downloadId" to downloadId,
"uri" to contentUri.toString()
)
)
} else if (status == DownloadManager.STATUS_FAILED) {
val reason =
c.getInt(c.getColumnIndex(DownloadManager.COLUMN_REASON))
_eventSink?.error(
"downloadError",
"Download #$downloadId was not successful, status: $status, reason: $reason",
mapOf(
"downloadId" to downloadId
)
)
}
} else {
Log.i(
"DownloadEventCompleteChannelHandler.onReceive",
"ID #$downloadId not found, user canceled the job?"
)
_eventSink?.error(
"userCanceled", "Download #$downloadId was canceled", mapOf(
"downloadId" to downloadId
)
)
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
_context.registerReceiver(
this, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
)
_eventSink = events
}
override fun onCancel(arguments: Any?) {
_context.unregisterReceiver(this)
}
private val _context = context
private var _eventSink: EventChannel.EventSink? = null
private val _downloadManager by lazy {
_context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
}
}
class DownloadEventCancelChannelHandler(context: Context) : BroadcastReceiver(), class DownloadEventCancelChannelHandler(context: Context) : BroadcastReceiver(),
EventChannel.StreamHandler { EventChannel.StreamHandler {

View file

@ -9,9 +9,6 @@ import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger,
DownloadChannelHandler.CHANNEL).setMethodCallHandler(
DownloadChannelHandler(this))
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MethodChannel(flutterEngine.dartExecutor.binaryMessenger,
MediaStoreChannelHandler.CHANNEL).setMethodCallHandler( MediaStoreChannelHandler.CHANNEL).setMethodCallHandler(
MediaStoreChannelHandler(this)) MediaStoreChannelHandler(this))
@ -25,9 +22,6 @@ class MainActivity : FlutterActivity() {
ShareChannelHandler.CHANNEL).setMethodCallHandler( ShareChannelHandler.CHANNEL).setMethodCallHandler(
ShareChannelHandler(this)) ShareChannelHandler(this))
EventChannel(flutterEngine.dartExecutor.binaryMessenger,
DownloadEventCompleteChannelHandler.CHANNEL).setStreamHandler(
DownloadEventCompleteChannelHandler(this))
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EventChannel(flutterEngine.dartExecutor.binaryMessenger,
DownloadEventCancelChannelHandler.CHANNEL).setStreamHandler( DownloadEventCancelChannelHandler.CHANNEL).setStreamHandler(
DownloadEventCancelChannelHandler(this)) DownloadEventCancelChannelHandler(this))

View file

@ -11,9 +11,7 @@ import androidx.annotation.RequiresApi
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.BufferedOutputStream import java.io.*
import java.io.File
import java.io.FileOutputStream
/* /*
* Save downloaded item on device * Save downloaded item on device
@ -31,8 +29,8 @@ class MediaStoreChannelHandler(activity: Activity) :
} }
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "saveFileToDownload") { when (call.method) {
try { "saveFileToDownload" -> try {
saveFileToDownload( saveFileToDownload(
call.argument("fileName")!!, call.argument("fileName")!!,
call.argument("content")!!, call.argument("content")!!,
@ -41,24 +39,47 @@ class MediaStoreChannelHandler(activity: Activity) :
} catch (e: Throwable) { } catch (e: Throwable) {
result.error("systemException", e.message, null) result.error("systemException", e.message, null)
} }
} else { "copyFileToDownload" -> try {
result.notImplemented() copyFileToDownload(
call.argument("toFileName")!!,
call.argument("fromFilePath")!!,
result
)
} catch (e: Throwable) {
result.error("systemException", e.message, null)
}
else -> result.notImplemented()
} }
} }
private fun saveFileToDownload( private fun saveFileToDownload(
fileName: String, content: ByteArray, result: MethodChannel.Result fileName: String, content: ByteArray, result: MethodChannel.Result
) {
val stream = ByteArrayInputStream(content)
writeFileToDownload(fileName, stream, result)
}
private fun copyFileToDownload(
toFileName: String, fromFilePath: String, result: MethodChannel.Result
) {
val file = File(fromFilePath)
val stream = file.inputStream()
writeFileToDownload(toFileName, stream, result)
}
private fun writeFileToDownload(
fileName: String, data: InputStream, result: MethodChannel.Result
) { ) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveFileToDownload29(fileName, content, result) writeFileToDownload29(fileName, data, result)
} else { } else {
saveFileToDownload0(fileName, content, result) writeFileToDownload0(fileName, data, result)
} }
} }
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
private fun saveFileToDownload29( private fun writeFileToDownload29(
fileName: String, content: ByteArray, result: MethodChannel.Result fileName: String, data: InputStream, result: MethodChannel.Result
) { ) {
// Add a media item that other apps shouldn't see until the item is // Add a media item that other apps shouldn't see until the item is
// fully written to the media store. // fully written to the media store.
@ -77,14 +98,14 @@ class MediaStoreChannelHandler(activity: Activity) :
resolver.openFileDescriptor(contentUri!!, "w", null).use { pfd -> resolver.openFileDescriptor(contentUri!!, "w", null).use { pfd ->
// Write data into the pending audio file. // Write data into the pending audio file.
BufferedOutputStream(FileOutputStream(pfd!!.fileDescriptor)).use { stream -> BufferedOutputStream(FileOutputStream(pfd!!.fileDescriptor)).use { stream ->
stream.write(content) data.copyTo(stream)
} }
} }
result.success(contentUri.toString()) result.success(contentUri.toString())
} }
private fun saveFileToDownload0( private fun writeFileToDownload0(
fileName: String, content: ByteArray, result: MethodChannel.Result fileName: String, data: InputStream, result: MethodChannel.Result
) { ) {
if (!PermissionHandler.ensureWriteExternalStorage(_activity)) { if (!PermissionHandler.ensureWriteExternalStorage(_activity)) {
result.error("permissionError", "Permission not granted", null) result.error("permissionError", "Permission not granted", null)
@ -103,7 +124,7 @@ class MediaStoreChannelHandler(activity: Activity) :
++count ++count
} }
BufferedOutputStream(FileOutputStream(file)).use { stream -> BufferedOutputStream(FileOutputStream(file)).use { stream ->
stream.write(content) data.copyTo(stream)
} }
val fileUri = Uri.fromFile(file) val fileUri = Uri.fromFile(file)

View file

@ -2,66 +2,13 @@ import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
class Download {
static Future<int> downloadUrl({
required String url,
Map<String, String>? headers,
String? mimeType,
required String filename,
bool? shouldNotify,
}) async {
return (await _channel.invokeMethod<int>("downloadUrl", <String, dynamic>{
"url": url,
"headers": headers,
"mimeType": mimeType,
"filename": filename,
"shouldNotify": shouldNotify,
}))!;
}
static Future<bool> cancel({
required int id,
}) async {
return (await _channel.invokeMethod<bool>("cancel", <String, dynamic>{
"id": id,
}))!;
}
/// The download job has failed
static const exceptionCodeDownloadError = "downloadError";
static const _channel = MethodChannel("com.nkming.nc_photos/download");
}
class DownloadEvent { class DownloadEvent {
static StreamSubscription<DownloadCompleteEvent> listenDownloadComplete() =>
_completeStream.listen(null);
static StreamSubscription<DownloadCancelEvent> listenDownloadCancel() => static StreamSubscription<DownloadCancelEvent> listenDownloadCancel() =>
_cancelStream.listen(null); _cancelStream.listen(null);
/// User canceled the download job /// User canceled the download job
static const exceptionCodeUserCanceled = "userCanceled"; static const exceptionCodeUserCanceled = "userCanceled";
static const _downloadCompleteChannel = EventChannel(
"com.nkming.nc_photos/download_event/action_download_complete");
static late final _completeStream = _downloadCompleteChannel
.receiveBroadcastStream()
.map((data) => DownloadCompleteEvent(
data["downloadId"],
data["uri"],
))
.handleError(
(e, stackTrace) {
throw AndroidDownloadError(e.details["downloadId"], e, stackTrace);
},
test: (e) =>
e is PlatformException &&
e.details is Map &&
e.details["downloadId"] is int,
);
static const _downloadCancelChannel = EventChannel( static const _downloadCancelChannel = EventChannel(
"com.nkming.nc_photos/download_event/action_download_cancel"); "com.nkming.nc_photos/download_event/action_download_cancel");
@ -72,21 +19,6 @@ class DownloadEvent {
)); ));
} }
class DownloadCompleteEvent {
const DownloadCompleteEvent(this.downloadId, this.uri);
final int downloadId;
final String uri;
}
class AndroidDownloadError implements Exception {
const AndroidDownloadError(this.downloadId, this.error, this.stackTrace);
final int downloadId;
final dynamic error;
final StackTrace stackTrace;
}
class DownloadCancelEvent { class DownloadCancelEvent {
const DownloadCancelEvent(this.notificationId); const DownloadCancelEvent(this.notificationId);

View file

@ -14,5 +14,14 @@ class MediaStore {
}))!; }))!;
} }
static Future<String> copyFileToDownload(
String toFileName, String fromFilePath) async {
return (await _channel
.invokeMethod<String>("copyFileToDownload", <String, dynamic>{
"toFileName": toFileName,
"fromFilePath": fromFilePath,
}))!;
}
static const _channel = MethodChannel("com.nkming.nc_photos/media_store"); static const _channel = MethodChannel("com.nkming.nc_photos/media_store");
} }

View file

@ -1,12 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart'; import 'package:http/http.dart' as http;
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:nc_photos/exception.dart'; import 'package:nc_photos/exception.dart';
import 'package:nc_photos/mobile/android/download.dart' as android;
import 'package:nc_photos/mobile/android/media_store.dart'; import 'package:nc_photos/mobile/android/media_store.dart';
import 'package:nc_photos/platform/download.dart' as itf; import 'package:nc_photos/platform/download.dart' as itf;
import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
class DownloadBuilder extends itf.DownloadBuilder { class DownloadBuilder extends itf.DownloadBuilder {
@override @override
@ -45,77 +47,99 @@ class _AndroidDownload extends itf.Download {
@override @override
call() async { call() async {
final String path; if (_isInitialDownload) {
if (parentDir?.isNotEmpty == true) { await _cleanUp();
path = "$parentDir/$filename"; _isInitialDownload = false;
} else {
path = filename;
} }
final file = await _createTempFile();
try { try {
_log.info("[call] Start downloading '$url'"); // download file to a temp dir
_downloadId = await android.Download.downloadUrl( final fileWrite = file.openWrite();
url: url,
headers: headers,
mimeType: mimeType,
filename: path,
shouldNotify: shouldNotify,
);
_log.info("[call] #$_downloadId -> '$url'");
late final String uri;
final completer = Completer();
onDownloadComplete(android.DownloadCompleteEvent ev) {
if (ev.downloadId == _downloadId) {
_log.info("[call] Finished downloading '$url' to '${ev.uri}'");
uri = ev.uri;
completer.complete();
}
}
StreamSubscription<android.DownloadCompleteEvent>? subscription;
try { try {
subscription = android.DownloadEvent.listenDownloadComplete() final uri = Uri.parse(url);
..onData(onDownloadComplete) final req = http.Request("GET", uri)..headers.addAll(headers ?? {});
..onError((e, stackTrace) { final response = await http.Client().send(req);
if (e is android.AndroidDownloadError) { bool isEnd = false;
if (e.downloadId != _downloadId) { Object? error;
// not us, ignore final subscription = response.stream.listen(
return; fileWrite.add,
} onDone: () {
completer.completeError(e.error, e.stackTrace); isEnd = true;
} else { },
completer.completeError(e, stackTrace); onError: (e, stackTrace) {
} _log.severe("Failed while request", e, stackTrace);
}); isEnd = true;
await completer.future; error = e;
},
cancelOnError: true,
);
// wait until download finished
while (!isEnd) {
if (shouldInterrupt) {
await subscription.cancel();
break;
}
await Future.delayed(const Duration(seconds: 1));
}
if (error != null) {
throw error!;
}
} finally { } finally {
subscription?.cancel(); fileWrite.close();
} }
return uri; if (shouldInterrupt) {
} on PlatformException catch (e) { throw JobCanceledException();
switch (e.code) {
case MediaStore.exceptionCodePermissionError:
throw PermissionException();
case android.Download.exceptionCodeDownloadError:
throw DownloadException(e.message);
case android.DownloadEvent.exceptionCodeUserCanceled:
throw JobCanceledException(e.message);
default:
rethrow;
} }
// copy the file to the actual dir
final String path;
if (parentDir?.isNotEmpty == true) {
path = "$parentDir/$filename";
} else {
path = filename;
}
return await MediaStore.copyFileToDownload(path, file.path);
} finally {
file.delete();
} }
} }
@override @override
cancel() async { cancel() {
if (_downloadId != null) { shouldInterrupt = true;
_log.info("[cancel] Cancel #$_downloadId"); return true;
return await android.Download.cancel(id: _downloadId!); }
Future<Directory> _openDownloadDir() async {
final tempDir = await getTemporaryDirectory();
final downloadDir = Directory("${tempDir.path}/downloads");
if (!await downloadDir.exists()) {
return downloadDir.create();
} else { } else {
return false; return downloadDir;
}
}
Future<File> _createTempFile() async {
final downloadDir = await _openDownloadDir();
while (true) {
final fileName = const Uuid().v4();
final file = File("${downloadDir.path}/$fileName");
if (await file.exists()) {
continue;
}
return file;
}
}
/// Clean up remaining cache files from previous runs
///
/// Normally the files will be deleted automatically
Future<void> _cleanUp() async {
final downloadDir = await _openDownloadDir();
await for (final f in downloadDir.list(followLinks: false)) {
_log.warning("[_cleanUp] Deleting file: ${f.path}");
await f.delete();
} }
} }
@ -125,7 +149,10 @@ class _AndroidDownload extends itf.Download {
final String filename; final String filename;
final String? parentDir; final String? parentDir;
final bool? shouldNotify; final bool? shouldNotify;
int? _downloadId;
bool shouldInterrupt = false;
static bool _isInitialDownload = true;
static final _log = Logger("mobile.download._AndroidDownload"); static final _log = Logger("mobile.download._AndroidDownload");
} }

View file

@ -8,8 +8,11 @@ abstract class Download {
/// Cancel a download /// Cancel a download
/// ///
/// Not all platforms support canceling an ongoing download /// Not all platforms support canceling an ongoing download. Return true if
Future<bool> cancel(); /// the current platform supports it, however there's no guarantee if and when
/// the download task would be canceled. After a download is canceled
/// successfully, [JobCanceledException] will be thrown in [call]
bool cancel();
} }
abstract class DownloadBuilder { abstract class DownloadBuilder {

View file

@ -43,7 +43,7 @@ class _WebDownload extends itf.Download {
} }
@override @override
cancel() => Future.value(false); cancel() => false;
final String url; final String url;
final Map<String, String>? headers; final Map<String, String>? headers;