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
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import androidx.core.content.FileProvider
import io.flutter.Log
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(),
EventChannel.StreamHandler {

View file

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

View file

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

View file

@ -2,66 +2,13 @@ import 'dart:async';
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 {
static StreamSubscription<DownloadCompleteEvent> listenDownloadComplete() =>
_completeStream.listen(null);
static StreamSubscription<DownloadCancelEvent> listenDownloadCancel() =>
_cancelStream.listen(null);
/// User canceled the download job
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(
"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 {
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");
}

View file

@ -1,12 +1,14 @@
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: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/platform/download.dart' as itf;
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 {
@override
@ -45,77 +47,99 @@ class _AndroidDownload extends itf.Download {
@override
call() async {
final String path;
if (parentDir?.isNotEmpty == true) {
path = "$parentDir/$filename";
} else {
path = filename;
if (_isInitialDownload) {
await _cleanUp();
_isInitialDownload = false;
}
final file = await _createTempFile();
try {
_log.info("[call] Start downloading '$url'");
_downloadId = await android.Download.downloadUrl(
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;
// download file to a temp dir
final fileWrite = file.openWrite();
try {
subscription = android.DownloadEvent.listenDownloadComplete()
..onData(onDownloadComplete)
..onError((e, stackTrace) {
if (e is android.AndroidDownloadError) {
if (e.downloadId != _downloadId) {
// not us, ignore
return;
}
completer.completeError(e.error, e.stackTrace);
} else {
completer.completeError(e, stackTrace);
}
});
await completer.future;
final uri = Uri.parse(url);
final req = http.Request("GET", uri)..headers.addAll(headers ?? {});
final response = await http.Client().send(req);
bool isEnd = false;
Object? error;
final subscription = response.stream.listen(
fileWrite.add,
onDone: () {
isEnd = true;
},
onError: (e, stackTrace) {
_log.severe("Failed while request", e, stackTrace);
isEnd = true;
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 {
subscription?.cancel();
fileWrite.close();
}
return uri;
} on PlatformException catch (e) {
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;
if (shouldInterrupt) {
throw JobCanceledException();
}
// 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
cancel() async {
if (_downloadId != null) {
_log.info("[cancel] Cancel #$_downloadId");
return await android.Download.cancel(id: _downloadId!);
cancel() {
shouldInterrupt = true;
return true;
}
Future<Directory> _openDownloadDir() async {
final tempDir = await getTemporaryDirectory();
final downloadDir = Directory("${tempDir.path}/downloads");
if (!await downloadDir.exists()) {
return downloadDir.create();
} 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? parentDir;
final bool? shouldNotify;
int? _downloadId;
bool shouldInterrupt = false;
static bool _isInitialDownload = true;
static final _log = Logger("mobile.download._AndroidDownload");
}

View file

@ -8,8 +8,11 @@ abstract class Download {
/// Cancel a download
///
/// Not all platforms support canceling an ongoing download
Future<bool> cancel();
/// Not all platforms support canceling an ongoing download. Return true if
/// 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 {

View file

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