Migrate to DownloadManage on Android

Fix OOME when downloading large files
This commit is contained in:
Ming Ming 2021-09-19 18:59:45 +08:00
parent ff87cd3099
commit 734807733c
16 changed files with 457 additions and 28 deletions

View file

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION"/>
<application
android:label="@string/app_name"

View file

@ -0,0 +1,90 @@
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) {
if (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)
}
} 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 val _activity = activity
private val _context get() = _activity
private val _downloadManager by lazy {
_context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
}
}

View file

@ -0,0 +1,92 @@
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",
null
)
}
} else {
Log.i(
"DownloadEventCompleteChannelHandler.onReceive",
"ID #$downloadId not found, user canceled the job?"
)
_eventSink?.error(
"userCanceled", "Download #$downloadId was canceled", null
)
}
}
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
}
}

View file

@ -3,11 +3,15 @@ package com.nkming.nc_photos
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
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))
@ -20,5 +24,9 @@ class MainActivity : FlutterActivity() {
MethodChannel(flutterEngine.dartExecutor.binaryMessenger,
ShareChannelHandler.CHANNEL).setMethodCallHandler(
ShareChannelHandler(this))
EventChannel(flutterEngine.dartExecutor.binaryMessenger,
DownloadEventCompleteChannelHandler.CHANNEL).setStreamHandler(
DownloadEventCompleteChannelHandler(this))
}
}

View file

@ -19,8 +19,6 @@ import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
private const val PERMISSION_REQUEST_CODE = 11011
/*
* Save downloaded item on device
*
@ -85,12 +83,7 @@ class MediaStoreChannelHandler(activity: Activity)
private fun saveFileToDownload0(fileName: String, content: ByteArray,
result: MethodChannel.Result) {
if (ContextCompat.checkSelfPermission(_activity,
Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(_activity,
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
PERMISSION_REQUEST_CODE)
if (!PermissionHandler.ensureWriteExternalStorage(_activity)) {
result.error("permissionError", "Permission not granted", null)
return
}

View file

@ -0,0 +1,29 @@
package com.nkming.nc_photos
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
private const val PERMISSION_REQUEST_CODE = 11011
class PermissionHandler {
companion object {
fun ensureWriteExternalStorage(activity: Activity): Boolean {
return if (ContextCompat.checkSelfPermission(
activity, Manifest.permission.WRITE_EXTERNAL_STORAGE
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
PERMISSION_REQUEST_CODE
)
false
} else {
true
}
}
}
}

View file

@ -65,3 +65,27 @@ class InvalidBaseUrlException implements Exception {
final dynamic message;
}
/// A download job has failed
class DownloadException implements Exception {
DownloadException([this.message]);
@override
toString() {
return "DownloadException: $message";
}
final dynamic message;
}
/// A running job has been canceled
class JobCanceledException implements Exception {
JobCanceledException([this.message]);
@override
toString() {
return "JobCanceledException: $message";
}
final dynamic message;
}

View file

@ -0,0 +1,51 @@
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,
}))!;
}
/// The download job has failed
static const exceptionCodeDownloadError = "downloadError";
static const _channel = MethodChannel("com.nkming.nc_photos/download");
}
class DownloadEvent {
static StreamSubscription<DownloadCompleteEvent> listenDownloadComplete() =>
_stream.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 _stream = _downloadCompleteChannel
.receiveBroadcastStream()
.map((data) => DownloadCompleteEvent(
data["downloadId"],
data["uri"],
));
}
class DownloadCompleteEvent {
const DownloadCompleteEvent(this.downloadId, this.uri);
final int downloadId;
final String uri;
}

View file

@ -0,0 +1,85 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/mobile/android/download.dart';
import 'package:nc_photos/mobile/android/media_store.dart';
import 'package:nc_photos/platform/file_downloader.dart' as itf;
import 'package:nc_photos/platform/k.dart' as platform_k;
class FileDownloader extends itf.FileDownloader {
@override
downloadUrl({
required String url,
Map<String, String>? headers,
String? mimeType,
required String filename,
bool? shouldNotify,
}) {
if (platform_k.isAndroid) {
return _downloadUrlAndroid(
url: url,
headers: headers,
mimeType: mimeType,
filename: filename,
shouldNotify: shouldNotify,
);
} else {
throw UnimplementedError();
}
}
Future<String> _downloadUrlAndroid({
required String url,
Map<String, String>? headers,
String? mimeType,
required String filename,
bool? shouldNotify,
}) async {
try {
_log.info("[_downloadUrlAndroid] Start downloading '$url'");
final id = await Download.downloadUrl(
url: url,
headers: headers,
mimeType: mimeType,
filename: filename,
shouldNotify: shouldNotify,
);
late final String uri;
final completer = Completer();
onDownloadComplete(DownloadCompleteEvent ev) {
if (ev.downloadId == id) {
_log.info(
"[_downloadUrlAndroid] Finished downloading '$url' to '${ev.uri}'");
uri = ev.uri;
completer.complete();
}
}
DownloadEvent.listenDownloadComplete()
..onData(onDownloadComplete)
..onError((e, stackTrace) {
completer.completeError(e, stackTrace);
});
await completer.future;
return uri;
} on PlatformException catch (e) {
switch (e.code) {
case MediaStore.exceptionCodePermissionError:
throw PermissionException();
case Download.exceptionCodeDownloadError:
throw DownloadException(e.message);
case DownloadEvent.exceptionCodeUserCanceled:
throw JobCanceledException(e.message);
default:
rethrow;
}
}
}
static final _log = Logger("mobile.file_downloader.FileDownloader");
}

View file

@ -1,4 +1,5 @@
export 'db_util.dart';
export 'file_downloader.dart';
export 'file_saver.dart';
export 'map_widget.dart';
export 'universal_storage.dart';

View file

@ -0,0 +1,18 @@
abstract class FileDownloader {
/// Download a file
///
/// The return data depends on the platform
/// - web: null
/// - android: Uri to the downloaded file
///
/// [shouldNotify] is a hint that suggest whether to notify user about the
/// progress. The actual decision is made by the underlying platform code and
/// is not guaranteed to respect this flag
Future<dynamic> downloadUrl({
required String url,
Map<String, String>? headers,
String? mimeType,
required String filename,
bool? shouldNotify,
});
}

View file

@ -4,7 +4,6 @@ import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
@ -25,11 +24,16 @@ class ShareHandler {
builder: (context) =>
ProcessingDialog(text: L10n.global().shareDownloadingDialogContent),
);
final fileRepo = FileRepo(FileCachedDataSource());
final results = <Tuple2<File, dynamic>>[];
for (final f in files) {
try {
results.add(Tuple2(f, await DownloadFile(fileRepo)(account, f)));
results.add(Tuple2(
f,
await DownloadFile()(
account,
f,
shouldNotify: false,
)));
} on PermissionException catch (_) {
_log.warning("[shareFiles] Permission not granted");
SnackBarManager().showSnackBar(SnackBar(

View file

@ -1,23 +1,31 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
import 'package:nc_photos/use_case/get_file_binary.dart';
import 'package:path/path.dart' as path;
class DownloadFile {
DownloadFile(this._fileRepo);
DownloadFile();
/// Download [file]
///
/// The return data depends on the platform
/// - web: null
/// - android: Uri to the downloaded file
Future<dynamic> call(Account account, File file) async {
final content = await GetFileBinary(_fileRepo)(account, file);
final saver = platform.FileSaver();
return saver.saveFile(path.basename(file.path), content);
/// See [FileDownloader.downloadUrl]
Future<dynamic> call(
Account account,
File file, {
bool? shouldNotify,
}) {
final downloader = platform.FileDownloader();
final url = "${account.url}/${file.path}";
return downloader.downloadUrl(
url: url,
headers: {
"authorization": Api.getAuthorizationHeaderValue(account),
},
mimeType: file.contentType,
filename: path.basename(file.path),
shouldNotify: shouldNotify,
);
}
final FileRepo _fileRepo;
}

View file

@ -0,0 +1,26 @@
import 'package:http/http.dart' as http;
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/platform/file_downloader.dart' as itf;
import 'package:nc_photos/web/file_saver.dart';
class FileDownloader extends itf.FileDownloader {
@override
downloadUrl({
required String url,
Map<String, String>? headers,
String? mimeType,
required String filename,
bool? shouldNotify,
}) async {
final uri = Uri.parse(url);
final req = http.Request("GET", uri)..headers.addAll(headers ?? {});
final response =
await http.Response.fromStream(await http.Client().send(req));
if (response.statusCode ~/ 100 != 2) {
throw DownloadException(
"Failed downloading $filename (HTTP ${response.statusCode})");
}
final saver = FileSaver();
await saver.saveFile(filename, response.bodyBytes);
}
}

View file

@ -1,4 +1,5 @@
export 'db_util.dart';
export 'file_downloader.dart';
export 'file_saver.dart';
export 'map_widget.dart';
export 'universal_storage.dart';

View file

@ -452,9 +452,9 @@ class _ViewerState extends State<Viewer>
});
dynamic result;
try {
final fileRepo = FileRepo(FileCachedDataSource());
result = await DownloadFile(fileRepo)(widget.account, file);
result = await DownloadFile()(widget.account, file);
controller?.close();
_onDownloadSuccessful(file, result);
} on PermissionException catch (_) {
_log.warning("[_onDownloadPressed] Permission not granted");
controller?.close();
@ -462,7 +462,8 @@ class _ViewerState extends State<Viewer>
content: Text(L10n.global().downloadFailureNoPermissionNotification),
duration: k.snackBarDurationNormal,
));
return;
} on JobCanceledException catch (_) {
_log.info("[_onDownloadPressed] Canceled");
} catch (e, stacktrace) {
_log.shout(
"[_onDownloadPressed] Failed while downloadFile", e, stacktrace);
@ -472,10 +473,7 @@ class _ViewerState extends State<Viewer>
"${exception_util.toUserString(e)}"),
duration: k.snackBarDurationNormal,
));
return;
}
_onDownloadSuccessful(file, result);
}
void _onDownloadSuccessful(File file, dynamic result) {