Skip to content

Commit 933b3bd

Browse files
author
chimnayajith
committed
lightbox: Add download button to bottom app bar.
Fixes: #42
1 parent 5178420 commit 933b3bd

11 files changed

+275
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,57 @@
11
package com.zulip.flutter
22

3+
import android.media.MediaScannerConnection
4+
import android.net.Uri
35
import io.flutter.embedding.android.FlutterActivity
6+
import io.flutter.embedding.engine.FlutterEngine
7+
import io.flutter.plugin.common.MethodChannel
48

5-
class MainActivity: FlutterActivity() {
9+
// MainActivity extends FlutterActivity and sets up communication between Flutter and native Android
10+
class MainActivity : FlutterActivity() {
11+
12+
// Define a channel name for communication between Flutter and native Android
13+
private val CHANNEL = "gallery_saver"
14+
15+
// Override the configureFlutterEngine method to set up the MethodChannel and handle method calls
16+
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
17+
super.configureFlutterEngine(flutterEngine)
18+
19+
// Set up a method channel for communication between Flutter and native Android code
20+
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
21+
22+
// Handle the method call when the "scanFile" method is invoked from Flutter
23+
if (call.method == "scanFile") {
24+
// Get the file path passed from Flutter
25+
val filePath = call.argument<String>("path")
26+
27+
// Check if the file path is not null
28+
if (filePath != null) {
29+
// If the file path is valid, trigger the scanning of the file
30+
scanFile(filePath)
31+
// Return a success result back to Flutter indicating the file was scanned
32+
result.success("MediaScanner invoked for $filePath")
33+
} else {
34+
// If the file path is null, return an error to Flutter
35+
result.error("INVALID_ARGUMENT", "File path is null", null)
36+
}
37+
} else {
38+
// If the method called is not recognized, return not implemented error
39+
result.notImplemented()
40+
}
41+
}
42+
}
43+
44+
// This function triggers the Android MediaScanner to refresh the media library
45+
// so that the file is visible in the gallery or other media apps.
46+
private fun scanFile(filePath: String) {
47+
// Use the MediaScannerConnection to scan the file and add it to the device's media library
48+
MediaScannerConnection.scanFile(
49+
applicationContext,
50+
arrayOf(filePath), // File to be scanned
51+
null, // File MIME types can be specified here, or null if it's unknown
52+
// Callback function when the scanning is complete (empty in this case)
53+
) { _, _ ->
54+
// Intentionally left empty, but could be used to log success or handle errors
55+
}
56+
}
657
}

assets/l10n/app_en.arb

+16
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,22 @@
395395
"@lightboxCopyLinkTooltip": {
396396
"description": "Tooltip in lightbox for the copy link action."
397397
},
398+
"lightboxDownloadImageTooltip": "Download image",
399+
"@lightboxDownloadImageTooltip": {
400+
"description": "Tooltip in lightbox for the download image action."
401+
},
402+
"lightboxDownloadImageSuccess": "Image downloaded successfully!",
403+
"@lightboxDownloadImageSuccess": {
404+
"description": "Message shown when the image downloads successfully."
405+
},
406+
"lightboxDownloadImageFailed": "Failed to download the image.",
407+
"@lightboxDownloadImageFailed": {
408+
"description": "Message shown when the image download fails."
409+
},
410+
"lightboxDownloadImageError": "An error occurred while downloading the image.",
411+
"@lightboxDownloadImageError": {
412+
"description": "Message shown when an unexpected error occurs during image download."
413+
},
398414
"loginPageTitle": "Log in",
399415
"@loginPageTitle": {
400416
"description": "Title for login page."

lib/generated/l10n/zulip_localizations.dart

+24
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,30 @@ abstract class ZulipLocalizations {
631631
/// **'Copy link'**
632632
String get lightboxCopyLinkTooltip;
633633

634+
/// Tooltip in lightbox for the download image action.
635+
///
636+
/// In en, this message translates to:
637+
/// **'Download image'**
638+
String get lightboxDownloadImageTooltip;
639+
640+
/// Message shown when the image downloads successfully.
641+
///
642+
/// In en, this message translates to:
643+
/// **'Image downloaded successfully!'**
644+
String get lightboxDownloadImageSuccess;
645+
646+
/// Message shown when the image download fails.
647+
///
648+
/// In en, this message translates to:
649+
/// **'Failed to download the image.'**
650+
String get lightboxDownloadImageFailed;
651+
652+
/// Message shown when an unexpected error occurs during image download.
653+
///
654+
/// In en, this message translates to:
655+
/// **'An error occurred while downloading the image.'**
656+
String get lightboxDownloadImageError;
657+
634658
/// Title for login page.
635659
///
636660
/// In en, this message translates to:

lib/generated/l10n/zulip_localizations_ar.dart

+12
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
310310
@override
311311
String get lightboxCopyLinkTooltip => 'Copy link';
312312

313+
@override
314+
String get lightboxDownloadImageTooltip => 'Download image';
315+
316+
@override
317+
String get lightboxDownloadImageSuccess => 'Image downloaded successfully!';
318+
319+
@override
320+
String get lightboxDownloadImageFailed => 'Failed to download the image.';
321+
322+
@override
323+
String get lightboxDownloadImageError => 'An error occurred while downloading the image.';
324+
313325
@override
314326
String get loginPageTitle => 'Log in';
315327

lib/generated/l10n/zulip_localizations_en.dart

+12
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,18 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
310310
@override
311311
String get lightboxCopyLinkTooltip => 'Copy link';
312312

313+
@override
314+
String get lightboxDownloadImageTooltip => 'Download image';
315+
316+
@override
317+
String get lightboxDownloadImageSuccess => 'Image downloaded successfully!';
318+
319+
@override
320+
String get lightboxDownloadImageFailed => 'Failed to download the image.';
321+
322+
@override
323+
String get lightboxDownloadImageError => 'An error occurred while downloading the image.';
324+
313325
@override
314326
String get loginPageTitle => 'Log in';
315327

lib/generated/l10n/zulip_localizations_fr.dart

+12
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,18 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
310310
@override
311311
String get lightboxCopyLinkTooltip => 'Copy link';
312312

313+
@override
314+
String get lightboxDownloadImageTooltip => 'Download image';
315+
316+
@override
317+
String get lightboxDownloadImageSuccess => 'Image downloaded successfully!';
318+
319+
@override
320+
String get lightboxDownloadImageFailed => 'Failed to download the image.';
321+
322+
@override
323+
String get lightboxDownloadImageError => 'An error occurred while downloading the image.';
324+
313325
@override
314326
String get loginPageTitle => 'Log in';
315327

lib/generated/l10n/zulip_localizations_ja.dart

+12
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,18 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
310310
@override
311311
String get lightboxCopyLinkTooltip => 'Copy link';
312312

313+
@override
314+
String get lightboxDownloadImageTooltip => 'Download image';
315+
316+
@override
317+
String get lightboxDownloadImageSuccess => 'Image downloaded successfully!';
318+
319+
@override
320+
String get lightboxDownloadImageFailed => 'Failed to download the image.';
321+
322+
@override
323+
String get lightboxDownloadImageError => 'An error occurred while downloading the image.';
324+
313325
@override
314326
String get loginPageTitle => 'Log in';
315327

lib/generated/l10n/zulip_localizations_pl.dart

+12
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,18 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
310310
@override
311311
String get lightboxCopyLinkTooltip => 'Skopiuj odnośnik';
312312

313+
@override
314+
String get lightboxDownloadImageTooltip => 'Download image';
315+
316+
@override
317+
String get lightboxDownloadImageSuccess => 'Image downloaded successfully!';
318+
319+
@override
320+
String get lightboxDownloadImageFailed => 'Failed to download the image.';
321+
322+
@override
323+
String get lightboxDownloadImageError => 'An error occurred while downloading the image.';
324+
313325
@override
314326
String get loginPageTitle => 'Zaloguj';
315327

lib/generated/l10n/zulip_localizations_ru.dart

+12
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,18 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
310310
@override
311311
String get lightboxCopyLinkTooltip => 'Copy link';
312312

313+
@override
314+
String get lightboxDownloadImageTooltip => 'Download image';
315+
316+
@override
317+
String get lightboxDownloadImageSuccess => 'Image downloaded successfully!';
318+
319+
@override
320+
String get lightboxDownloadImageFailed => 'Failed to download the image.';
321+
322+
@override
323+
String get lightboxDownloadImageError => 'An error occurred while downloading the image.';
324+
313325
@override
314326
String get loginPageTitle => 'Log in';
315327

lib/widgets/lightbox.dart

+94
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import 'package:flutter/scheduler.dart';
33
import 'package:flutter/services.dart';
44
import 'package:intl/intl.dart';
55
import 'package:video_player/video_player.dart';
6+
import 'package:http/http.dart' as http;
7+
import 'package:path_provider/path_provider.dart';
8+
import 'dart:io';
9+
import 'dart:async';
610

711
import '../api/core.dart';
812
import '../api/model/model.dart';
@@ -89,6 +93,95 @@ class _CopyLinkButton extends StatelessWidget {
8993
}
9094
}
9195

96+
class _DownloadImageButton extends StatelessWidget {
97+
const _DownloadImageButton({required this.url});
98+
99+
final Uri url;
100+
101+
static const platform = MethodChannel('gallery_saver');
102+
103+
@override
104+
Widget build(BuildContext context) {
105+
final store = PerAccountStoreWidget.of(context);
106+
final zulipLocalizations = ZulipLocalizations.of(context);
107+
return IconButton(
108+
tooltip: zulipLocalizations.lightboxDownloadImageTooltip,
109+
icon: const Icon(Icons.download),
110+
onPressed: () async {
111+
final scaffoldMessenger = ScaffoldMessenger.of(context);
112+
String message = zulipLocalizations.lightboxDownloadImageFailed;
113+
try {
114+
// Fetch the image with a timeout
115+
final response = await http.get(
116+
url,
117+
headers: {
118+
if (url.origin == store.account.realmUrl.origin) ...authHeader(
119+
email: store.account.email,
120+
apiKey: store.account.apiKey,
121+
),
122+
...userAgentHeader()
123+
}
124+
).timeout(
125+
const Duration(seconds: 30),
126+
onTimeout: () {
127+
throw TimeoutException("timed out");
128+
},
129+
);
130+
131+
if (response.statusCode == 200) {
132+
// Get the external storage directory
133+
final directory = await getExternalStorageDirectory();
134+
if (directory == null) {
135+
message = zulipLocalizations.lightboxDownloadImageError;
136+
} else {
137+
// Refactored to use MediaStore for Android 10+ (Scoped Storage)
138+
if (Platform.isAndroid) {
139+
final downloadFolder = await getDownloadDirectory();
140+
final fileName = url.pathSegments.last;
141+
final filePath = '$downloadFolder/$fileName';
142+
143+
final file = File(filePath);
144+
await file.writeAsBytes(response.bodyBytes);
145+
146+
// Trigger Media Scanner so it reflects in the gallery.
147+
await platform.invokeMethod('scanFile', {'path': filePath});
148+
149+
message = zulipLocalizations.lightboxDownloadImageSuccess;
150+
} else {
151+
message = zulipLocalizations.lightboxDownloadImageError;
152+
}
153+
}
154+
} else {
155+
message = zulipLocalizations.lightboxDownloadImageFailed;
156+
}
157+
} catch (e) {
158+
if (e is TimeoutException || e is SocketException) {
159+
message = zulipLocalizations.lightboxDownloadImageError;
160+
} else {
161+
message = zulipLocalizations.lightboxDownloadImageError;
162+
}
163+
}
164+
165+
// Show a SnackBar notification
166+
scaffoldMessenger.showSnackBar(
167+
SnackBar(behavior: SnackBarBehavior.floating, content: Text(message)),
168+
);
169+
}
170+
);
171+
}
172+
173+
// Returns the download directory for Android 10+ using scoped storage
174+
Future<String> getDownloadDirectory() async {
175+
if (Platform.isAndroid) {
176+
final directory = await getExternalStorageDirectory();
177+
final downloadFolder = '${directory?.path.split("Android")[0]}Download';
178+
return downloadFolder;
179+
}
180+
return '';
181+
}
182+
}
183+
184+
92185
class _LightboxPageLayout extends StatefulWidget {
93186
const _LightboxPageLayout({
94187
required this.routeEntranceAnimation,
@@ -258,6 +351,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
258351
elevation: elevation,
259352
child: Row(children: [
260353
_CopyLinkButton(url: widget.src),
354+
_DownloadImageButton(url: widget.src)
261355
// TODO(#43): Share image
262356
// TODO(#42): Download image
263357
]),

test/widgets/lightbox_test.dart

+17
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,23 @@ void main() {
275275
debugNetworkImageHttpClientProvider = null;
276276
});
277277

278+
testWidgets('download button triggers download', (tester) async {
279+
prepareBoringImageHttpClient();
280+
final message = eg.streamMessage(sender: eg.otherUser);
281+
await setupPage(tester, message: message, thumbnailUrl: null);
282+
283+
final downloadButton = find.byIcon(Icons.download);
284+
285+
expect(downloadButton, findsOneWidget);
286+
await tester.tap(downloadButton);
287+
await tester.pump();
288+
289+
final snackbar = find.byType(SnackBar);
290+
expect(snackbar, findsOneWidget);
291+
292+
debugNetworkImageHttpClientProvider = null;
293+
});
294+
278295
// TODO test _CopyLinkButton
279296
// TODO test thumbnail gets shown, then gets replaced when main image loads
280297
// TODO test image is scaled down to fit, but not up

0 commit comments

Comments
 (0)