diff --git a/android/app/src/main/kotlin/com/zulip/flutter/DownloadManager.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/DownloadManager.g.kt new file mode 100644 index 0000000000..c6350dc5ec --- /dev/null +++ b/android/app/src/main/kotlin/com/zulip/flutter/DownloadManager.g.kt @@ -0,0 +1,99 @@ +// Autogenerated from Pigeon (v22.7.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package com.zulip.flutter + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +private fun wrapResult(result: Any?): List { + return listOf(result) +} + +private fun wrapError(exception: Throwable): List { + return if (exception is DownloadManagerError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class DownloadManagerError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() +private open class DownloadManagerPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface DownloadManagerHostApi { + /** + * Downloads a file using the given URL and saves it with the specified file name in the Downloads directory. + * Returns a success message or an error message. + */ + fun downloadFile(fileUrl: String, fileName: String, header: String, callback: (Result) -> Unit) + + companion object { + /** The codec used by DownloadManagerHostApi. */ + val codec: MessageCodec by lazy { + DownloadManagerPigeonCodec() + } + /** Sets up an instance of `DownloadManagerHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: DownloadManagerHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.DownloadManagerHostApi.downloadFile$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val fileUrlArg = args[0] as String + val fileNameArg = args[1] as String + val headerArg = args[2] as String + api.downloadFile(fileUrlArg, fileNameArg, headerArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt index eb332d786f..85584704fa 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt @@ -1,11 +1,13 @@ package com.zulip.flutter import android.annotation.SuppressLint +import android.app.DownloadManager import android.content.ContentUris import android.content.ContentValues import android.content.Context import android.content.Intent import android.media.AudioAttributes +import android.media.MediaScannerConnection import android.net.Uri import android.os.Build import android.os.Bundle @@ -19,7 +21,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.graphics.drawable.IconCompat import io.flutter.embedding.engine.plugins.FlutterPlugin - +import org.json.JSONObject private const val TAG = "ZulipPlugin" fun toAndroidPerson(person: Person): androidx.core.app.Person { @@ -283,17 +285,50 @@ private class AndroidNotificationHost(val context: Context) } } +/** Host class for handling downloads via DownloadManager */ +class DownloadHost(private val context: Context) : DownloadManagerHostApi { + + /** Downloads a file from the given URL and saves it to the specified filename in the Downloads folder. */ + override fun downloadFile(url: String, fileName: String, header: String, callback: (Result) -> Unit) { + val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val headersJsonObject = JSONObject(header) + val headersMap = mutableMapOf() + for (key in headersJsonObject.keys()) { + headersMap[key] = headersJsonObject.getString(key) + } + val uri = Uri.parse(url) + + val request = DownloadManager.Request(uri).apply { + setTitle("Downloading $fileName") + setDescription("File is being downloaded...") + setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName) + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + + for ((key, value) in headersMap) { + addRequestHeader(key, value) + } + } + // Queue the download + downloadManager.enqueue(request) + callback(Result.success("Download started successfully for: $fileName")) + } +} + /** A Flutter plugin for the Zulip app's ad-hoc needs. */ // @Keep is needed because this class is used only // from ZulipShimPlugin, via reflection. @Keep class ZulipPlugin : FlutterPlugin { // TODO ActivityAware too? private var notificationHost: AndroidNotificationHost? = null + private var downloadHost: DownloadHost? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { Log.d(TAG, "Attaching to Flutter engine.") notificationHost = AndroidNotificationHost(binding.applicationContext) + downloadHost = DownloadHost(binding.applicationContext) + AndroidNotificationHostApi.setUp(binding.binaryMessenger, notificationHost) + DownloadManagerHostApi.setUp(binding.binaryMessenger, downloadHost) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -302,6 +337,9 @@ class ZulipPlugin : FlutterPlugin { // TODO ActivityAware too? return } AndroidNotificationHostApi.setUp(binding.binaryMessenger, null) + DownloadManagerHostApi.setUp(binding.binaryMessenger, null) + notificationHost = null + downloadHost = null } -} +} \ No newline at end of file diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ee7e96c35f..8710c727bd 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -436,6 +436,22 @@ "@lightboxVideoDuration": { "description": "The total duration of the video playing in the lightbox." }, + "lightboxDownloadImageTooltip": "Download image", + "@lightboxDownloadImageTooltip": { + "description": "Tooltip in lightbox for the download image action." + }, + "lightboxDownloadImageSuccess": "Image downloaded successfully!", + "@lightboxDownloadImageSuccess": { + "description": "Message shown when the image downloads successfully." + }, + "lightboxDownloadImageFailed": "Failed to download the image.", + "@lightboxDownloadImageFailed": { + "description": "Message shown when the image download fails." + }, + "lightboxDownloadImageError": "An error occurred while downloading the image.", + "@lightboxDownloadImageError": { + "description": "Message shown when an unexpected error occurs during image download." + }, "loginPageTitle": "Log in", "@loginPageTitle": { "description": "Title for login page." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index b6fbb70769..fcab899672 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -681,6 +681,30 @@ abstract class ZulipLocalizations { /// **'Video duration'** String get lightboxVideoDuration; + /// Tooltip in lightbox for the download image action. + /// + /// In en, this message translates to: + /// **'Download image'** + String get lightboxDownloadImageTooltip; + + /// Message shown when the image downloads successfully. + /// + /// In en, this message translates to: + /// **'Image downloaded successfully!'** + String get lightboxDownloadImageSuccess; + + /// Message shown when the image download fails. + /// + /// In en, this message translates to: + /// **'Failed to download the image.'** + String get lightboxDownloadImageFailed; + + /// Message shown when an unexpected error occurs during image download. + /// + /// In en, this message translates to: + /// **'An error occurred while downloading the image.'** + String get lightboxDownloadImageError; + /// Title for login page. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 025b4b1444..6ce4ab993e 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -340,6 +340,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get lightboxVideoDuration => 'Video duration'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Log in'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 9467d33428..bcfe049b9b 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -340,6 +340,18 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get lightboxVideoDuration => 'Video duration'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Log in'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index f363ee0043..2428dfa8d8 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -340,6 +340,18 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get lightboxVideoDuration => 'Video duration'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Log in'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 35b3e86fe5..96ab518aea 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -340,6 +340,18 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get lightboxVideoDuration => 'Video duration'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Log in'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0594722d31..f32e09fdd2 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -340,6 +340,18 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get lightboxVideoDuration => 'Video duration'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Zaloguj'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 879559fed4..19602f0cbf 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -340,6 +340,18 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get lightboxVideoDuration => 'Video duration'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Вход в систему'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index af87dfd949..a64ed69fc2 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -340,6 +340,18 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get lightboxVideoDuration => 'Video duration'; + @override + String get lightboxDownloadImageTooltip => 'Download image'; + + @override + String get lightboxDownloadImageSuccess => 'Image downloaded successfully!'; + + @override + String get lightboxDownloadImageFailed => 'Failed to download the image.'; + + @override + String get lightboxDownloadImageError => 'An error occurred while downloading the image.'; + @override String get loginPageTitle => 'Prihlásiť sa'; diff --git a/lib/host/android_download_manager.dart b/lib/host/android_download_manager.dart new file mode 100644 index 0000000000..acb12432ad --- /dev/null +++ b/lib/host/android_download_manager.dart @@ -0,0 +1,11 @@ +import 'android_download_manager.g.dart'; + +// Wrapper class for Download functionality +class AndroidDownloader { + final DownloadManagerHostApi _api = DownloadManagerHostApi(); + + /// Downloads a file from the given URL and saves it with the specified file name. + Future downloadFile(String url, String fileName, String header) async { + await _api.downloadFile(url, fileName, header); + } +} diff --git a/lib/host/android_download_manager.g.dart b/lib/host/android_download_manager.g.dart new file mode 100644 index 0000000000..279c92091a --- /dev/null +++ b/lib/host/android_download_manager.g.dart @@ -0,0 +1,81 @@ +// Autogenerated from Pigeon (v22.7.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class DownloadManagerHostApi { + /// Constructor for [DownloadManagerHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + DownloadManagerHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Downloads a file using the given URL and saves it with the specified file name in the Downloads directory. + /// Returns a success message or an error message. + Future downloadFile(String fileUrl, String fileName, String header) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.DownloadManagerHostApi.downloadFile$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([fileUrl, fileName, header]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as String?)!; + } + } +} diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 50477ec01a..f2dcc82fbb 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -10,6 +10,7 @@ import 'package:package_info_plus/package_info_plus.dart' as package_info_plus; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:wakelock_plus/wakelock_plus.dart' as wakelock_plus; +import '../host/android_download_manager.g.dart'; import '../host/android_notifications.dart'; import '../log.dart'; import '../widgets/store.dart'; @@ -168,6 +169,9 @@ abstract class ZulipBinding { /// Wraps the [AndroidNotificationHostApi] constructor. AndroidNotificationHostApi get androidNotificationHost; + /// Wraps the [DownloadManagerHostApi] constructor. + DownloadManagerHostApi get androidDownloadHost; + /// Pick files from the media library, via package:file_picker. /// /// This wraps [file_picker.pickFiles]. @@ -442,6 +446,9 @@ class LiveZulipBinding extends ZulipBinding { @override AndroidNotificationHostApi get androidNotificationHost => AndroidNotificationHostApi(); + @override + DownloadManagerHostApi get androidDownloadHost => DownloadManagerHostApi(); + @override Future pickFiles({ bool allowMultiple = false, diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 1406b5fdbd..c52d407985 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -1,8 +1,14 @@ +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:video_player/video_player.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'dart:io'; +import 'dart:async'; import '../api/core.dart'; import '../api/model/model.dart'; @@ -89,6 +95,94 @@ class _CopyLinkButton extends StatelessWidget { } } +class _DownloadImageButton extends StatelessWidget { + _DownloadImageButton({required this.url}); + + final downloader = ZulipBinding.instance.androidDownloadHost; + final Uri url; + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + return IconButton( + tooltip: zulipLocalizations.lightboxDownloadImageTooltip, + icon: const Icon(Icons.download), + onPressed: () async { + final scaffoldMessenger = ScaffoldMessenger.of(context); + String message = zulipLocalizations.lightboxDownloadImageFailed; + try { + // fetching image a without to prevent errors. + final response = await http.get( + url, + headers: { + if (url.origin == store.account.realmUrl.origin) ...authHeader( + email: store.account.email, + apiKey: store.account.apiKey, + ), + ...userAgentHeader() + } + ).timeout( + const Duration(seconds: 30), + onTimeout: () { + throw TimeoutException("timed out"); + }, + ); + + // Creating headers for the download manager for private images. + Map headers = {}; + if (url.origin == store.account.realmUrl.origin) { + headers.addAll(authHeader( + email: store.account.email, + apiKey: store.account.apiKey, + )); + } + headers.addAll(userAgentHeader()); + String headersJson = json.encode(headers); + + await requestStoragePermission(); + if (response.statusCode == 200) { + if (Platform.isAndroid) { + final fileName = url.pathSegments.last.trim(); + await downloader.downloadFile(url.toString(), fileName, headersJson); + message = zulipLocalizations.lightboxDownloadImageSuccess; + } else { + message = zulipLocalizations.lightboxDownloadImageError; + } + + } else { + message = zulipLocalizations.lightboxDownloadImageFailed; + } + } catch (e) { + if (e is TimeoutException || e is SocketException) { + message = zulipLocalizations.lightboxDownloadImageError; + } else { + message = zulipLocalizations.lightboxDownloadImageError; + } + } + + // show snackbar notification for the status. + scaffoldMessenger.showSnackBar( + SnackBar(behavior: SnackBarBehavior.floating, content: Text(message)), + ); + } + ); + } + + Future requestStoragePermission() async { + if (Platform.isAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; + + // Check Android version (SDK version) and request permission for Android 10 and below + if (androidInfo.version.sdkInt <= 29) { + if (await Permission.storage.isDenied) { + await Permission.storage.request(); + } + } + } + } +} + + class _LightboxPageLayout extends StatefulWidget { const _LightboxPageLayout({ required this.routeEntranceAnimation, @@ -261,8 +355,8 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> { elevation: elevation, child: Row(children: [ _CopyLinkButton(url: widget.src), + _DownloadImageButton(url: widget.src) // TODO(#43): Share image - // TODO(#42): Download image ]), ); } diff --git a/pigeon/download_manager.dart b/pigeon/download_manager.dart new file mode 100644 index 0000000000..d750a15e23 --- /dev/null +++ b/pigeon/download_manager.dart @@ -0,0 +1,18 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/host/android_download_manager.g.dart', + kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/DownloadManager.g.kt', + kotlinOptions: KotlinOptions( + package: 'com.zulip.flutter', + errorClassName: 'DownloadManagerError', + ), +)) + +@HostApi() +abstract class DownloadManagerHostApi { + /// Downloads a file using the given URL and saves it with the specified file name in the Downloads directory. + /// Returns a success message or an error message. + @async + String downloadFile(String fileUrl, String fileName, String header); +} diff --git a/pubspec.lock b/pubspec.lock index 4ee3317f8d..b96c125f9e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -827,6 +827,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + url: "https://pub.dev" + source: hosted + version: "11.3.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1" + url: "https://pub.dev" + source: hosted + version: "12.0.13" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + url: "https://pub.dev" + source: hosted + version: "9.4.5" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 27b7519334..ffc98f58ea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: package_info_plus: ^8.0.0 path: ^1.8.3 path_provider: ^2.0.13 + permission_handler: ^11.3.1 share_plus: ^10.1.3 share_plus_platform_interface: ^5.0.2 sqlite3: ^2.4.0 diff --git a/test/model/binding.dart b/test/model/binding.dart index 039d6c3787..0c8927b2fa 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; +import 'package:zulip/host/android_download_manager.g.dart'; import 'package:zulip/host/android_notifications.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/store.dart'; @@ -76,6 +77,7 @@ class TestZulipBinding extends ZulipBinding { _resetPickFiles(); _resetPickImage(); _resetWakelock(); + _resetDownloads(); } /// The current global store offered to a [GlobalStoreWidget]. @@ -287,6 +289,17 @@ class TestZulipBinding extends ZulipBinding { return (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); } + void _resetDownloads() { + _androidDownloadHostApi = null; + } + + FakeDownloadManagerHostApi? _androidDownloadHostApi; + + @override + FakeDownloadManagerHostApi get androidDownloadHost { + return (_androidDownloadHostApi ??= FakeDownloadManagerHostApi()); + } + /// The value that `ZulipBinding.instance.pickFiles()` should return. /// /// See also [takePickFilesCalls]. @@ -745,3 +758,40 @@ typedef CopySoundResourceToMediaStoreCall = ({ String targetFileDisplayName, String sourceResourceName, }); + +class FakeDownloadManagerHostApi implements DownloadManagerHostApi { + // TODO(?): Find a better way to handle this. This member is exported from + // the Pigeon generated class but are not used for this fake class, + // so return the default value. + @override + // ignore: non_constant_identifier_names + final BinaryMessenger? pigeonVar_binaryMessenger = null; + + // TODO(?): Find a better way to handle this. This member is exported from + // the Pigeon generated class but are not used for this fake class, + // so return the default value. + @override + // ignore: non_constant_identifier_names + final String pigeonVar_messageChannelSuffix = ''; + + final List _downloads = []; + + @override + Future downloadFile(String fileUrl, String fileName, String header) async { + _downloads.add(fileName); + return "Download started for: $fileName"; + } + + + bool isDownloaded(String fileName) { + return _downloads.contains(fileName); + } + + void resetDownloads() { + _downloads.clear(); + } + + List getDownloadedFiles() { + return List.unmodifiable(_downloads); + } +} \ No newline at end of file diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index 31a4132a7c..3c2c067525 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -286,6 +286,31 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + testWidgets('download button triggers download', (tester) async { + prepareBoringImageHttpClient(); + final message = eg.streamMessage(sender: eg.otherUser); + await setupPage(tester, message: message, thumbnailUrl: null); + + final downloadButton = find.byIcon(Icons.download); + + expect(downloadButton, findsOneWidget); + await tester.tap(downloadButton); + await tester.pump(); + + final snackbar = find.byType(SnackBar); + expect(snackbar, findsOneWidget); + + final downloadHost = TestZulipBinding.instance.androidDownloadHost; + final fileName = "lightbox-image.png"; + final downloadResult = await downloadHost.downloadFile( + 'https://chat.example/lightbox-image.png', + fileName, + ''); + expect(downloadResult, contains("Download started for: $fileName")); + expect(downloadHost.getDownloadedFiles(), contains(fileName)); + debugNetworkImageHttpClientProvider = null; + }); + // TODO test _CopyLinkButton // TODO test thumbnail gets shown, then gets replaced when main image loads // TODO test image is scaled down to fit, but not up diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0d4b4d65c2..4a2d73bf64 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4a4d9be3e7..30c3ca4786 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows firebase_core + permission_handler_windows share_plus sqlite3_flutter_libs url_launcher_windows