diff --git a/android/app/src/main/kotlin/com/nkming/nc_photos/DownloadChannelHandler.kt b/android/app/src/main/kotlin/com/nkming/nc_photos/DownloadChannelHandler.kt index 678666da..3b0fbaa8 100644 --- a/android/app/src/main/kotlin/com/nkming/nc_photos/DownloadChannelHandler.kt +++ b/android/app/src/main/kotlin/com/nkming/nc_photos/DownloadChannelHandler.kt @@ -41,6 +41,15 @@ class DownloadChannelHandler(activity: Activity) : 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() } @@ -85,6 +94,13 @@ class DownloadChannelHandler(activity: Activity) : 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 { diff --git a/android/app/src/main/kotlin/com/nkming/nc_photos/DownloadEventChannelHandler.kt b/android/app/src/main/kotlin/com/nkming/nc_photos/DownloadEventChannelHandler.kt index 98a7f1ed..9d75e6f7 100644 --- a/android/app/src/main/kotlin/com/nkming/nc_photos/DownloadEventChannelHandler.kt +++ b/android/app/src/main/kotlin/com/nkming/nc_photos/DownloadEventChannelHandler.kt @@ -94,3 +94,42 @@ class DownloadEventCompleteChannelHandler(context: Context) : _context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager } } + +class DownloadEventCancelChannelHandler(context: Context) : BroadcastReceiver(), + EventChannel.StreamHandler { + companion object { + @JvmStatic + val CHANNEL = + "com.nkming.nc_photos/download_event/action_download_cancel" + } + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != K.ACTION_DOWNLOAD_CANCEL || !intent.hasExtra( + K.EXTRA_NOTIFICATION_ID + ) + ) { + return + } + + val id = intent.getIntExtra(K.EXTRA_NOTIFICATION_ID, 0) + _eventSink?.success( + mapOf( + "notificationId" to id + ) + ) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + _context.registerReceiver( + this, IntentFilter(K.ACTION_DOWNLOAD_CANCEL) + ) + _eventSink = events + } + + override fun onCancel(arguments: Any?) { + _context.unregisterReceiver(this) + } + + private val _context = context + private var _eventSink: EventChannel.EventSink? = null +} diff --git a/android/app/src/main/kotlin/com/nkming/nc_photos/K.kt b/android/app/src/main/kotlin/com/nkming/nc_photos/K.kt index b8e47e23..deccc2cb 100644 --- a/android/app/src/main/kotlin/com/nkming/nc_photos/K.kt +++ b/android/app/src/main/kotlin/com/nkming/nc_photos/K.kt @@ -4,5 +4,9 @@ interface K { companion object { const val DOWNLOAD_NOTIFICATION_ID_MIN = 1000 const val DOWNLOAD_NOTIFICATION_ID_MAX = 2000 + + const val ACTION_DOWNLOAD_CANCEL = "com.nkming.nc_photos.ACTION_DOWNLOAD_CANCEL" + + const val EXTRA_NOTIFICATION_ID = "com.nkming.nc_photos.EXTRA_NOTIFICATION_ID" } } diff --git a/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt b/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt index 30550986..50e2d633 100644 --- a/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt +++ b/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt @@ -28,5 +28,8 @@ class MainActivity : FlutterActivity() { EventChannel(flutterEngine.dartExecutor.binaryMessenger, DownloadEventCompleteChannelHandler.CHANNEL).setStreamHandler( DownloadEventCompleteChannelHandler(this)) + EventChannel(flutterEngine.dartExecutor.binaryMessenger, + DownloadEventCancelChannelHandler.CHANNEL).setStreamHandler( + DownloadEventCancelChannelHandler(this)) } } diff --git a/android/app/src/main/kotlin/com/nkming/nc_photos/NotificationChannelHandler.kt b/android/app/src/main/kotlin/com/nkming/nc_photos/NotificationChannelHandler.kt index 707b2d1e..d5486389 100644 --- a/android/app/src/main/kotlin/com/nkming/nc_photos/NotificationChannelHandler.kt +++ b/android/app/src/main/kotlin/com/nkming/nc_photos/NotificationChannelHandler.kt @@ -30,14 +30,14 @@ class NotificationChannelHandler(activity: Activity) : companion object { const val CHANNEL = "com.nkming.nc_photos/notification" - private fun getNextNotificationId(): Int { + fun getNextNotificationId(): Int { if (++notificationId >= K.DOWNLOAD_NOTIFICATION_ID_MAX) { notificationId = K.DOWNLOAD_NOTIFICATION_ID_MIN } return notificationId } - private const val DOWNLOAD_CHANNEL_ID = "download" + const val DOWNLOAD_CHANNEL_ID = "download" private var notificationId = K.DOWNLOAD_NOTIFICATION_ID_MIN } @@ -47,11 +47,25 @@ class NotificationChannelHandler(activity: Activity) : override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "notifyItemsDownloadSuccessful" -> { + "notifyDownloadSuccessful" -> { try { - notifyItemsDownloadSuccessful( + notifyDownloadSuccessful( call.argument("fileUris")!!, call.argument("mimeTypes")!!, + call.argument("notificationId"), + result + ) + } catch (e: Throwable) { + result.error("systemException", e.toString(), null) + } + } + "notifyDownloadProgress" -> { + try { + notifyDownloadProgress( + call.argument("progress")!!, + call.argument("max")!!, + call.argument("currentItemTitle"), + call.argument("notificationId"), result ) } catch (e: Throwable) { @@ -67,15 +81,23 @@ class NotificationChannelHandler(activity: Activity) : result.error("systemException", e.toString(), null) } } + "dismiss" -> { + try { + dismiss(call.argument("notificationId")!!, result) + } catch (e: Throwable) { + result.error("systemException", e.toString(), null) + } + } else -> { result.notImplemented() } } } - private fun notifyItemsDownloadSuccessful( + private fun notifyDownloadSuccessful( fileUris: List, mimeTypes: List, + notificationId: Int?, result: MethodChannel.Result ) { assert(fileUris.isNotEmpty()) @@ -88,7 +110,7 @@ class NotificationChannelHandler(activity: Activity) : RingtoneManager.getDefaultUri( RingtoneManager.TYPE_NOTIFICATION ) - ).setOnlyAlertOnce(true).setAutoCancel(true).setLocalOnly(true) + ).setOnlyAlertOnce(false).setAutoCancel(true).setLocalOnly(true) if (uris.size == 1) { builder.setContentTitle( @@ -159,10 +181,57 @@ class NotificationChannelHandler(activity: Activity) : ), sharePendingIntent ) + val id = notificationId ?: getNextNotificationId() with(NotificationManagerCompat.from(_context)) { - notify(getNextNotificationId(), builder.build()) + notify(id, builder.build()) } - result.success(null) + result.success(id) + } + + private fun notifyDownloadProgress( + progress: Int, + max: Int, + currentItemTitle: String?, + notificationId: Int?, + result: MethodChannel.Result + ) { + val id = notificationId ?: getNextNotificationId() + val builder = NotificationCompat.Builder(_context, DOWNLOAD_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setWhen(System.currentTimeMillis()) + .setPriority(NotificationCompat.PRIORITY_HIGH).setSound( + RingtoneManager.getDefaultUri( + RingtoneManager.TYPE_NOTIFICATION + ) + ).setOnlyAlertOnce(true).setAutoCancel(false).setLocalOnly(true) + .setProgress(max, progress, false).setContentText("$progress/$max") + if (currentItemTitle == null) { + builder.setContentTitle(_context.getString(R.string.download_progress_notification_untitled_text)) + } else { + builder.setContentTitle( + _context.getString( + R.string.download_progress_notification_text, + currentItemTitle + ) + ) + } + + val cancelIntent = Intent().apply { + `package` = BuildConfig.APPLICATION_ID + action = K.ACTION_DOWNLOAD_CANCEL + putExtra(K.EXTRA_NOTIFICATION_ID, id) + } + val cancelPendingIntent = PendingIntent.getBroadcast( + _context, 0, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT + ) + builder.addAction( + 0, _context.getString(android.R.string.cancel), cancelPendingIntent + ) + + with(NotificationManagerCompat.from(_context)) { + notify(id, builder.build()) + } + result.success(id) } private fun notifyLogSaveSuccessful( @@ -205,8 +274,16 @@ class NotificationChannelHandler(activity: Activity) : // can't add the share action here because android will share the URI as // plain text instead of treating it as a text file... + val id = getNextNotificationId() with(NotificationManagerCompat.from(_context)) { - notify(getNextNotificationId(), builder.build()) + notify(id, builder.build()) + } + result.success(id) + } + + private fun dismiss(notificationId: Int, result: MethodChannel.Result) { + with(NotificationManagerCompat.from(_context)) { + cancel(notificationId) } result.success(null) } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 122dacb4..f40e1eb3 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -9,6 +9,8 @@ SHARE Share with: Downloaded %1$d items successfully + Downloading %1$s + Downloading Logs saved successfully Tap to view your saved logs diff --git a/lib/download_handler.dart b/lib/download_handler.dart index d1b78413..6a2abb40 100644 --- a/lib/download_handler.dart +++ b/lib/download_handler.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:logging/logging.dart'; @@ -6,7 +8,9 @@ import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/mobile/android/download.dart'; import 'package:nc_photos/mobile/notification.dart'; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/snack_bar_manager.dart'; @@ -18,6 +22,136 @@ class DownloadHandler { Account account, List files, { String? parentDir, + }) { + final _DownloadHandlerBase handler; + if (platform_k.isAndroid) { + handler = _DownlaodHandlerAndroid(); + } else { + handler = _DownloadHandlerWeb(); + } + return handler.downloadFiles( + account, + files, + parentDir: parentDir, + ); + } +} + +abstract class _DownloadHandlerBase { + Future downloadFiles( + Account account, + List files, { + String? parentDir, + }); +} + +class _DownlaodHandlerAndroid extends _DownloadHandlerBase { + @override + downloadFiles( + Account account, + List files, { + String? parentDir, + }) async { + _log.info("[downloadFiles] Downloading ${files.length} file"); + final notif = AndroidDownloadProgressNotification( + 0, + files.length, + currentItemTitle: files.firstOrNull?.filename, + ); + await notif.notify(); + + final successes = >[]; + StreamSubscription? subscription; + try { + bool isCancel = false; + subscription = DownloadEvent.listenDownloadCancel() + ..onData((data) { + if (data.notificationId == notif.notificationId) { + isCancel = true; + } + }); + + int count = 0; + for (final f in files) { + if (isCancel == true) { + _log.info("[downloadFiles] User canceled remaining files"); + break; + } + notif.update( + count++, + currentItemTitle: f.filename, + ); + + StreamSubscription? itemSubscription; + try { + final download = DownloadFile().build( + account, + f, + parentDir: parentDir, + shouldNotify: false, + ); + itemSubscription = DownloadEvent.listenDownloadCancel() + ..onData((data) { + if (data.notificationId == notif.notificationId) { + _log.info("[downloadFiles] Cancel requested"); + download.cancel(); + } + }); + final result = await download(); + successes.add(Tuple2(f, result)); + } on PermissionException catch (_) { + _log.warning("[downloadFiles] Permission not granted"); + SnackBarManager().showSnackBar(SnackBar( + content: + Text(L10n.global().downloadFailureNoPermissionNotification), + duration: k.snackBarDurationNormal, + )); + break; + } on JobCanceledException catch (_) { + _log.info("[downloadFiles] User canceled"); + break; + } catch (e, stackTrace) { + _log.shout( + "[downloadFiles] Failed while DownloadFile", e, stackTrace); + SnackBarManager().showSnackBar(SnackBar( + content: Text("${L10n.global().downloadFailureNotification}: " + "${exception_util.toUserString(e)}"), + duration: k.snackBarDurationNormal, + )); + } finally { + itemSubscription?.cancel(); + } + } + } finally { + subscription?.cancel(); + if (successes.isNotEmpty) { + await _onDownloadSuccessful(successes.map((e) => e.item1).toList(), + successes.map((e) => e.item2).toList(), notif.notificationId); + } else { + await notif.dismiss(); + } + } + } + + Future _onDownloadSuccessful( + List files, List results, int? notificationId) async { + final notif = AndroidDownloadSuccessfulNotification( + results.cast(), + files.map((e) => e.contentType).toList(), + notificationId: notificationId, + ); + await notif.notify(); + } + + static final _log = Logger("download_handler._DownloadHandlerAndroid"); +} + +class _DownloadHandlerWeb extends _DownloadHandlerBase { + @override + downloadFiles( + Account account, + List files, { + String? parentDir, }) async { _log.info("[downloadFiles] Downloading ${files.length} file"); var controller = SnackBarManager().showSnackBar(SnackBar( @@ -27,15 +161,15 @@ class DownloadHandler { controller?.closed.whenComplete(() { controller = null; }); - final successes = >[]; + int successCount = 0; for (final f in files) { try { - final result = await DownloadFile()( + await DownloadFile()( account, f, parentDir: parentDir, ); - successes.add(Tuple2(f, result)); + ++successCount; } on PermissionException catch (_) { _log.warning("[downloadFiles] Permission not granted"); controller?.close(); @@ -57,38 +191,14 @@ class DownloadHandler { )); } } - if (successes.isNotEmpty) { + if (successCount > 0) { controller?.close(); - await _onDownloadSuccessful(successes.map((e) => e.item1).toList(), - successes.map((e) => e.item2).toList()); + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().downloadSuccessNotification), + duration: k.snackBarDurationShort, + )); } } - Future _onDownloadSuccessful( - List files, List results) async { - dynamic notif; - if (platform_k.isAndroid) { - notif = AndroidItemDownloadSuccessfulNotification( - results.cast(), files.map((e) => e.contentType).toList()); - } - if (notif != null) { - try { - await notif.notify(); - return; - } catch (e, stacktrace) { - _log.shout( - "[_onDownloadSuccessful] Failed showing platform notification", - e, - stacktrace); - } - } - - // fallback - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().downloadSuccessNotification), - duration: k.snackBarDurationShort, - )); - } - - static final _log = Logger("download_handler.DownloadHandler"); + static final _log = Logger("download_handler._DownloadHandlerWeb"); } diff --git a/lib/mobile/android/download.dart b/lib/mobile/android/download.dart index 0cd4d783..9a5dc9dc 100644 --- a/lib/mobile/android/download.dart +++ b/lib/mobile/android/download.dart @@ -19,6 +19,14 @@ class Download { }))!; } + static Future cancel({ + required int id, + }) async { + return (await _channel.invokeMethod("cancel", { + "id": id, + }))!; + } + /// The download job has failed static const exceptionCodeDownloadError = "downloadError"; @@ -27,7 +35,10 @@ class Download { class DownloadEvent { static StreamSubscription listenDownloadComplete() => - _stream.listen(null); + _completeStream.listen(null); + + static StreamSubscription listenDownloadCancel() => + _cancelStream.listen(null); /// User canceled the download job static const exceptionCodeUserCanceled = "userCanceled"; @@ -35,7 +46,7 @@ class DownloadEvent { static const _downloadCompleteChannel = EventChannel( "com.nkming.nc_photos/download_event/action_download_complete"); - static late final _stream = _downloadCompleteChannel + static late final _completeStream = _downloadCompleteChannel .receiveBroadcastStream() .map((data) => DownloadCompleteEvent( data["downloadId"], @@ -50,6 +61,15 @@ class DownloadEvent { e.details is Map && e.details["downloadId"] is int, ); + + static const _downloadCancelChannel = EventChannel( + "com.nkming.nc_photos/download_event/action_download_cancel"); + + static late final _cancelStream = _downloadCancelChannel + .receiveBroadcastStream() + .map((data) => DownloadCancelEvent( + data["notificationId"], + )); } class DownloadCompleteEvent { @@ -66,3 +86,9 @@ class AndroidDownloadError implements Exception { final dynamic error; final StackTrace stackTrace; } + +class DownloadCancelEvent { + const DownloadCancelEvent(this.notificationId); + + final int notificationId; +} diff --git a/lib/mobile/android/notification.dart b/lib/mobile/android/notification.dart index 16f7652a..099d654a 100644 --- a/lib/mobile/android/notification.dart +++ b/lib/mobile/android/notification.dart @@ -1,17 +1,32 @@ import 'package:flutter/services.dart'; class Notification { - static Future notifyItemsDownloadSuccessful( - List fileUris, List mimeTypes) => - _channel.invokeMethod("notifyItemsDownloadSuccessful", { + static Future notifyDownloadSuccessful(List fileUris, + List mimeTypes, int? notificationId) => + _channel.invokeMethod("notifyDownloadSuccessful", { "fileUris": fileUris, "mimeTypes": mimeTypes, + "notificationId": notificationId, }); - static Future notifyLogSaveSuccessful(String fileUri) => + static Future notifyDownloadProgress(int progress, int max, + String? currentItemTitle, int? notificationId) => + _channel.invokeMethod("notifyDownloadProgress", { + "progress": progress, + "max": max, + "currentItemTitle": currentItemTitle, + "notificationId": notificationId, + }); + + static Future notifyLogSaveSuccessful(String fileUri) => _channel.invokeMethod("notifyLogSaveSuccessful", { "fileUri": fileUri, }); + static Future dismiss(int notificationId) => + _channel.invokeMethod("dismiss", { + "notificationId": notificationId, + }); + static const _channel = MethodChannel("com.nkming.nc_photos/notification"); } diff --git a/lib/mobile/file_downloader.dart b/lib/mobile/download.dart similarity index 53% rename from lib/mobile/file_downloader.dart rename to lib/mobile/download.dart index 06397674..bd7c07a8 100644 --- a/lib/mobile/file_downloader.dart +++ b/lib/mobile/download.dart @@ -3,14 +3,14 @@ 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/download.dart' as android; import 'package:nc_photos/mobile/android/media_store.dart'; -import 'package:nc_photos/platform/file_downloader.dart' as itf; +import 'package:nc_photos/platform/download.dart' as itf; import 'package:nc_photos/platform/k.dart' as platform_k; -class FileDownloader extends itf.FileDownloader { +class DownloadBuilder extends itf.DownloadBuilder { @override - downloadUrl({ + build({ required String url, Map? headers, String? mimeType, @@ -19,7 +19,7 @@ class FileDownloader extends itf.FileDownloader { bool? shouldNotify, }) { if (platform_k.isAndroid) { - return _downloadUrlAndroid( + return _AndroidDownload( url: url, headers: headers, mimeType: mimeType, @@ -31,15 +31,20 @@ class FileDownloader extends itf.FileDownloader { throw UnimplementedError(); } } +} - Future _downloadUrlAndroid({ - required String url, - Map? headers, - String? mimeType, - required String filename, - String? parentDir, - bool? shouldNotify, - }) async { +class _AndroidDownload extends itf.Download { + _AndroidDownload({ + required this.url, + this.headers, + this.mimeType, + required this.filename, + this.parentDir, + this.shouldNotify, + }); + + @override + call() async { final String path; if (parentDir?.isNotEmpty == true) { path = "$parentDir/$filename"; @@ -48,32 +53,32 @@ class FileDownloader extends itf.FileDownloader { } try { - _log.info("[_downloadUrlAndroid] Start downloading '$url'"); - final id = await Download.downloadUrl( + _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(DownloadCompleteEvent ev) { - if (ev.downloadId == id) { - _log.info( - "[_downloadUrlAndroid] Finished downloading '$url' to '${ev.uri}'"); + onDownloadComplete(android.DownloadCompleteEvent ev) { + if (ev.downloadId == _downloadId) { + _log.info("[call] Finished downloading '$url' to '${ev.uri}'"); uri = ev.uri; completer.complete(); } } - StreamSubscription? subscription; + StreamSubscription? subscription; try { - subscription = DownloadEvent.listenDownloadComplete() + subscription = android.DownloadEvent.listenDownloadComplete() ..onData(onDownloadComplete) ..onError((e, stackTrace) { - if (e is AndroidDownloadError) { - if (e.downloadId != id) { + if (e is android.AndroidDownloadError) { + if (e.downloadId != _downloadId) { // not us, ignore return; } @@ -92,10 +97,10 @@ class FileDownloader extends itf.FileDownloader { case MediaStore.exceptionCodePermissionError: throw PermissionException(); - case Download.exceptionCodeDownloadError: + case android.Download.exceptionCodeDownloadError: throw DownloadException(e.message); - case DownloadEvent.exceptionCodeUserCanceled: + case android.DownloadEvent.exceptionCodeUserCanceled: throw JobCanceledException(e.message); default: @@ -104,5 +109,23 @@ class FileDownloader extends itf.FileDownloader { } } - static final _log = Logger("mobile.file_downloader.FileDownloader"); + @override + cancel() async { + if (_downloadId != null) { + _log.info("[cancel] Cancel #$_downloadId"); + return await android.Download.cancel(id: _downloadId!); + } else { + return false; + } + } + + final String url; + final Map? headers; + final String? mimeType; + final String filename; + final String? parentDir; + final bool? shouldNotify; + int? _downloadId; + + static final _log = Logger("mobile.download._AndroidDownload"); } diff --git a/lib/mobile/notification.dart b/lib/mobile/notification.dart index 90e86ed4..8ca575b8 100644 --- a/lib/mobile/notification.dart +++ b/lib/mobile/notification.dart @@ -1,27 +1,72 @@ +import 'package:flutter/foundation.dart'; import 'package:nc_photos/mobile/android/notification.dart'; import 'package:nc_photos/platform/notification.dart' as itf; -class AndroidItemDownloadSuccessfulNotification - extends itf.ItemDownloadSuccessfulNotification { - AndroidItemDownloadSuccessfulNotification(this.fileUris, this.mimeTypes); +class AndroidDownloadSuccessfulNotification extends _AndroidNotification { + AndroidDownloadSuccessfulNotification( + this.fileUris, + this.mimeTypes, { + int? notificationId, + }) : replaceId = notificationId; @override - Future notify() { - return Notification.notifyItemsDownloadSuccessful(fileUris, mimeTypes); - } + doNotify() => + Notification.notifyDownloadSuccessful(fileUris, mimeTypes, replaceId); final List fileUris; final List mimeTypes; + final int? replaceId; } -class AndroidLogSaveSuccessfulNotification - extends itf.LogSaveSuccessfulNotification { +class AndroidDownloadProgressNotification extends _AndroidNotification { + AndroidDownloadProgressNotification( + this.progress, + this.max, { + this.currentItemTitle, + }); + + @override + doNotify() => Notification.notifyDownloadProgress( + progress, max, currentItemTitle, notificationId); + + Future update( + int progress, { + String? currentItemTitle, + }) async { + this.progress = progress; + this.currentItemTitle = currentItemTitle; + await doNotify(); + } + + int progress; + final int max; + String? currentItemTitle; +} + +class AndroidLogSaveSuccessfulNotification extends _AndroidNotification { AndroidLogSaveSuccessfulNotification(this.fileUri); @override - Future notify() { - return Notification.notifyLogSaveSuccessful(fileUri); - } + doNotify() => Notification.notifyLogSaveSuccessful(fileUri); final String fileUri; } + +abstract class _AndroidNotification extends itf.Notification { + @override + notify() async { + notificationId = await doNotify(); + } + + @override + dismiss() async { + if (notificationId != null) { + await Notification.dismiss(notificationId!); + } + } + + @protected + Future doNotify(); + + int? notificationId; +} diff --git a/lib/mobile/platform.dart b/lib/mobile/platform.dart index 5a3cf951..e6b88c28 100644 --- a/lib/mobile/platform.dart +++ b/lib/mobile/platform.dart @@ -1,5 +1,5 @@ export 'db_util.dart'; -export 'file_downloader.dart'; +export 'download.dart'; export 'file_saver.dart'; export 'map_widget.dart'; export 'universal_storage.dart'; diff --git a/lib/platform/file_downloader.dart b/lib/platform/download.dart similarity index 72% rename from lib/platform/file_downloader.dart rename to lib/platform/download.dart index 19b6eab7..a3c06ecf 100644 --- a/lib/platform/file_downloader.dart +++ b/lib/platform/download.dart @@ -1,9 +1,19 @@ -abstract class FileDownloader { +abstract class Download { /// Download a file /// /// The return data depends on the platform /// - web: null /// - android: Uri to the downloaded file + Future call(); + + /// Cancel a download + /// + /// Not all platforms support canceling an ongoing download + Future cancel(); +} + +abstract class DownloadBuilder { + /// Create a platform specific download /// /// [parentDir] is a hint that set the parent directory where the files are /// saved. Whether this is supported or not is implementation specific @@ -11,7 +21,7 @@ abstract class FileDownloader { /// [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 downloadUrl({ + Download build({ required String url, Map? headers, String? mimeType, diff --git a/lib/platform/notification.dart b/lib/platform/notification.dart index ef3b7573..df67fc9b 100644 --- a/lib/platform/notification.dart +++ b/lib/platform/notification.dart @@ -1,7 +1,5 @@ -abstract class ItemDownloadSuccessfulNotification { +abstract class Notification { Future notify(); -} -abstract class LogSaveSuccessfulNotification { - Future notify(); + Future dismiss(); } diff --git a/lib/use_case/download_file.dart b/lib/use_case/download_file.dart index c12fdade..ae41a9a5 100644 --- a/lib/use_case/download_file.dart +++ b/lib/use_case/download_file.dart @@ -3,20 +3,18 @@ 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/platform/download.dart'; class DownloadFile { - /// Download [file] - /// - /// See [FileDownloader.downloadUrl] - Future call( + /// Create a new download but don't start it yet + Download build( Account account, File file, { String? parentDir, bool? shouldNotify, }) { - final downloader = platform.FileDownloader(); final url = "${account.url}/${file.path}"; - return downloader.downloadUrl( + return platform.DownloadBuilder().build( url: url, headers: { "authorization": Api.getAuthorizationHeaderValue(account), @@ -27,4 +25,20 @@ class DownloadFile { shouldNotify: shouldNotify, ); } + + /// Download [file] + /// + /// See [DownloadBuilder] + Future call( + Account account, + File file, { + String? parentDir, + bool? shouldNotify, + }) => + build( + account, + file, + parentDir: parentDir, + shouldNotify: shouldNotify, + )(); } diff --git a/lib/web/file_downloader.dart b/lib/web/download.dart similarity index 58% rename from lib/web/file_downloader.dart rename to lib/web/download.dart index c96ddf49..6a4203ca 100644 --- a/lib/web/file_downloader.dart +++ b/lib/web/download.dart @@ -1,18 +1,35 @@ 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/platform/download.dart' as itf; import 'package:nc_photos/web/file_saver.dart'; -class FileDownloader extends itf.FileDownloader { +class DownloadBuilder extends itf.DownloadBuilder { @override - downloadUrl({ + build({ required String url, Map? headers, String? mimeType, required String filename, String? parentDir, bool? shouldNotify, - }) async { + }) { + return _WebDownload( + url: url, + headers: headers, + filename: filename, + ); + } +} + +class _WebDownload extends itf.Download { + _WebDownload({ + required this.url, + this.headers, + required this.filename, + }); + + @override + call() async { final uri = Uri.parse(url); final req = http.Request("GET", uri)..headers.addAll(headers ?? {}); final response = @@ -24,4 +41,11 @@ class FileDownloader extends itf.FileDownloader { final saver = FileSaver(); await saver.saveFile(filename, response.bodyBytes); } + + @override + cancel() => Future.value(false); + + final String url; + final Map? headers; + final String filename; } diff --git a/lib/web/platform.dart b/lib/web/platform.dart index 5a3cf951..e6b88c28 100644 --- a/lib/web/platform.dart +++ b/lib/web/platform.dart @@ -1,5 +1,5 @@ export 'db_util.dart'; -export 'file_downloader.dart'; +export 'download.dart'; export 'file_saver.dart'; export 'map_widget.dart'; export 'universal_storage.dart';