A simpler way to share photos to other apps

This commit is contained in:
Ming Ming 2021-07-10 23:06:04 +08:00
parent a2ff2a3788
commit 61c950ecec
11 changed files with 313 additions and 10 deletions

View file

@ -17,5 +17,8 @@ class MainActivity : FlutterActivity() {
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MethodChannel(flutterEngine.dartExecutor.binaryMessenger,
SelfSignedCertChannelHandler.CHANNEL).setMethodCallHandler( SelfSignedCertChannelHandler.CHANNEL).setMethodCallHandler(
SelfSignedCertChannelHandler(this)) SelfSignedCertChannelHandler(this))
MethodChannel(flutterEngine.dartExecutor.binaryMessenger,
ShareChannelHandler.CHANNEL).setMethodCallHandler(
ShareChannelHandler(this))
} }
} }

View file

@ -0,0 +1,56 @@
package com.nkming.nc_photos
import android.app.Activity
import android.content.Intent
import android.net.Uri
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class ShareChannelHandler(activity: Activity)
: MethodChannel.MethodCallHandler {
companion object {
const val CHANNEL = "com.nkming.nc_photos/share"
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "shareItems") {
try {
shareItems(call.argument<List<String>>("fileUris")!!,
call.argument<List<String?>>("mimeTypes")!!,
result)
} catch (e: Throwable) {
result.error("systemException", e.toString(), null)
}
} else {
result.notImplemented()
}
}
private fun shareItems(fileUris: List<String>,
mimeTypes: List<String?>, result: MethodChannel.Result) {
assert(fileUris.isNotEmpty())
assert(fileUris.size == mimeTypes.size)
val uris = fileUris.map { Uri.parse(it) }
val shareIntent = if (uris.size == 1) Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_STREAM, uris[0])
type = mimeTypes[0] ?: "*/*"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
} else Intent().apply {
action = Intent.ACTION_SEND_MULTIPLE
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
type = if (mimeTypes.all { it?.startsWith("image/") == true })
"image/*" else "*/*"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
val shareChooser = Intent.createChooser(shareIntent, _context.getString(
R.string.download_successful_notification_action_share_chooser))
_context.startActivity(shareChooser)
}
private val _activity = activity
private val _context get() = _activity
}

View file

@ -555,6 +555,22 @@
"@albumAddTextTooltip": { "@albumAddTextTooltip": {
"description": "Add some text that display between photos to an album" "description": "Add some text that display between photos to an album"
}, },
"shareTooltip": "Share",
"@shareTooltip": {
"description": "Share the current item to other apps"
},
"shareSelectedTooltip": "Share selected",
"@shareSelectedTooltip": {
"description": "Share selected items to other apps"
},
"shareSelectedEmptyNotification": "Select some photos to share",
"@shareSelectedEmptyNotification": {
"description": "Shown when user pressed the share button with only non-sharable items (e.g., text labels) selected"
},
"shareDownloadingDialogContent": "Downloading",
"@shareDownloadingDialogContent": {
"description": "Downloading photos to be shared"
},
"changelogTitle": "Changelog", "changelogTitle": "Changelog",
"@changelogTitle": { "@changelogTitle": {

View file

@ -0,0 +1,12 @@
import 'package:flutter/services.dart';
class Share {
static Future<void> shareItems(
List<String> fileUris, List<String> mimeTypes) =>
_channel.invokeMethod("shareItems", <String, dynamic>{
"fileUris": fileUris,
"mimeTypes": mimeTypes,
});
static const _channel = const MethodChannel("com.nkming.nc_photos/share");
}

14
lib/mobile/share.dart Normal file
View file

@ -0,0 +1,14 @@
import 'package:nc_photos/mobile/android/share.dart';
import 'package:nc_photos/platform/share.dart' as itf;
class AndroidShare extends itf.Share {
AndroidShare(this.fileUris, this.mimeTypes);
@override
Future<void> share() {
return Share.shareItems(fileUris, mimeTypes);
}
final List<String> fileUris;
final List<String> mimeTypes;
}

3
lib/platform/share.dart Normal file
View file

@ -0,0 +1,3 @@
abstract class Share {
Future<void> share();
}

60
lib/share_handler.dart Normal file
View file

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.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/k.dart' as k;
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
import 'package:nc_photos/mobile/share.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/widget/processing_dialog.dart';
import 'package:tuple/tuple.dart';
/// Handle sharing to other apps
class ShareHandler {
Future<void> shareFiles(
BuildContext context, Account account, List<File> files) async {
assert(platform_k.isAndroid);
showDialog(
context: context,
builder: (context) => ProcessingDialog(
text: AppLocalizations.of(context).shareDownloadingDialogContent),
);
final results = <Tuple2<File, dynamic>>[];
for (final f in files) {
try {
results.add(
Tuple2(f, await platform.Downloader().downloadFile(account, f)));
} on PermissionException catch (_) {
_log.warning("[shareFiles] Permission not granted");
SnackBarManager().showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)
.downloadFailureNoPermissionNotification),
duration: k.snackBarDurationNormal,
));
// dismiss the dialog
Navigator.of(context).pop();
rethrow;
} catch (e, stacktrace) {
_log.shout("[shareFiles] Failed while downloadFile", e, stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e, context)),
duration: k.snackBarDurationNormal,
));
}
}
// dismiss the dialog
Navigator.of(context).pop();
final share = AndroidShare(results.map((e) => e.item2 as String).toList(),
results.map((e) => e.item1.contentType).toList());
share.share();
}
static final _log = Logger("share_handler.ShareHandler");
}

View file

@ -15,7 +15,9 @@ import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/session_storage.dart'; import 'package:nc_photos/session_storage.dart';
import 'package:nc_photos/share_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/resync_album.dart'; import 'package:nc_photos/use_case/resync_album.dart';
@ -222,6 +224,14 @@ class _AlbumViewerState extends State<AlbumViewer>
Widget _buildSelectionAppBar(BuildContext context) { Widget _buildSelectionAppBar(BuildContext context) {
return buildSelectionAppBar(context, [ return buildSelectionAppBar(context, [
if (platform_k.isAndroid)
IconButton(
icon: const Icon(Icons.share),
tooltip: AppLocalizations.of(context).shareSelectedTooltip,
onPressed: () {
_onSelectionAppBarSharePressed(context);
},
),
IconButton( IconButton(
icon: const Icon(Icons.remove), icon: const Icon(Icons.remove),
tooltip: AppLocalizations.of(context).removeSelectedFromAlbumTooltip, tooltip: AppLocalizations.of(context).removeSelectedFromAlbumTooltip,
@ -261,6 +271,27 @@ class _AlbumViewerState extends State<AlbumViewer>
arguments: ViewerArguments(widget.account, _backingFiles, fileIndex)); arguments: ViewerArguments(widget.account, _backingFiles, fileIndex));
} }
void _onSelectionAppBarSharePressed(BuildContext context) {
assert(platform_k.isAndroid);
final selected = selectedListItems
.whereType<_FileListItem>()
.map((e) => e.file)
.toList();
if (selected.isEmpty) {
SnackBarManager().showSnackBar(SnackBar(
content:
Text(AppLocalizations.of(context).shareSelectedEmptyNotification),
duration: k.snackBarDurationNormal,
));
return;
}
ShareHandler().shareFiles(context, widget.account, selected).then((_) {
setState(() {
clearSelectedItems();
});
});
}
void _onSelectionAppBarRemovePressed() { void _onSelectionAppBarRemovePressed() {
final selectedIndexes = final selectedIndexes =
selectedListItems.map((e) => (e as _ListItem).index).toList(); selectedListItems.map((e) => (e as _ListItem).index).toList();
@ -526,6 +557,7 @@ class _AlbumViewerState extends State<AlbumViewer>
} else if (file_util.isSupportedVideoFormat(item.file)) { } else if (file_util.isSupportedVideoFormat(item.file)) {
yield _VideoListItem( yield _VideoListItem(
index: i, index: i,
file: item.file,
account: widget.account, account: widget.account,
previewUrl: previewUrl, previewUrl: previewUrl,
onTap: () => _onItemTap(i), onTap: () => _onItemTap(i),
@ -677,10 +709,31 @@ abstract class _ListItem implements SelectableItem, DraggableItem {
final VoidCallback _onDragEndedAny; final VoidCallback _onDragEndedAny;
} }
class _ImageListItem extends _ListItem { abstract class _FileListItem extends _ListItem {
_ImageListItem({ _FileListItem({
@required int index, @required int index,
@required this.file, @required this.file,
VoidCallback onTap,
DragTargetAccept<DraggableItem> onDropBefore,
DragTargetAccept<DraggableItem> onDropAfter,
VoidCallback onDragStarted,
VoidCallback onDragEndedAny,
}) : super(
index: index,
onTap: onTap,
onDropBefore: onDropBefore,
onDropAfter: onDropAfter,
onDragStarted: onDragStarted,
onDragEndedAny: onDragEndedAny,
);
final File file;
}
class _ImageListItem extends _FileListItem {
_ImageListItem({
@required int index,
@required File file,
@required this.account, @required this.account,
@required this.previewUrl, @required this.previewUrl,
VoidCallback onTap, VoidCallback onTap,
@ -690,6 +743,7 @@ class _ImageListItem extends _ListItem {
VoidCallback onDragEndedAny, VoidCallback onDragEndedAny,
}) : super( }) : super(
index: index, index: index,
file: file,
onTap: onTap, onTap: onTap,
onDropBefore: onDropBefore, onDropBefore: onDropBefore,
onDropAfter: onDropAfter, onDropAfter: onDropAfter,
@ -706,14 +760,14 @@ class _ImageListItem extends _ListItem {
); );
} }
final File file;
final Account account; final Account account;
final String previewUrl; final String previewUrl;
} }
class _VideoListItem extends _ListItem { class _VideoListItem extends _FileListItem {
_VideoListItem({ _VideoListItem({
@required int index, @required int index,
@required File file,
@required this.account, @required this.account,
@required this.previewUrl, @required this.previewUrl,
VoidCallback onTap, VoidCallback onTap,
@ -723,6 +777,7 @@ class _VideoListItem extends _ListItem {
VoidCallback onDragEndedAny, VoidCallback onDragEndedAny,
}) : super( }) : super(
index: index, index: index,
file: file,
onTap: onTap, onTap: onTap,
onDropBefore: onDropBefore, onDropBefore: onDropBefore,
onDropAfter: onDropAfter, onDropAfter: onDropAfter,

View file

@ -17,6 +17,8 @@ import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/list_extension.dart'; import 'package:nc_photos/list_extension.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/share_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/populate_album.dart'; import 'package:nc_photos/use_case/populate_album.dart';
@ -229,6 +231,14 @@ class _DynamicAlbumViewerState extends State<DynamicAlbumViewer>
Widget _buildSelectionAppBar(BuildContext context) { Widget _buildSelectionAppBar(BuildContext context) {
return buildSelectionAppBar(context, [ return buildSelectionAppBar(context, [
if (platform_k.isAndroid)
IconButton(
icon: const Icon(Icons.share),
tooltip: AppLocalizations.of(context).shareSelectedTooltip,
onPressed: () {
_onSelectionAppBarSharePressed(context);
},
),
PopupMenuButton( PopupMenuButton(
tooltip: MaterialLocalizations.of(context).moreButtonTooltip, tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
itemBuilder: (context) => [ itemBuilder: (context) => [
@ -313,6 +323,19 @@ class _DynamicAlbumViewerState extends State<DynamicAlbumViewer>
}); });
} }
void _onSelectionAppBarSharePressed(BuildContext context) {
assert(platform_k.isAndroid);
final selected = selectedListItems
.whereType<_FileListItem>()
.map((e) => e.file)
.toList();
ShareHandler().shareFiles(context, widget.account, selected).then((_) {
setState(() {
clearSelectedItems();
});
});
}
void _onSelectionAppBarDeletePressed() async { void _onSelectionAppBarDeletePressed() async {
SnackBarManager().showSnackBar(SnackBar( SnackBarManager().showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context) content: Text(AppLocalizations.of(context)
@ -392,6 +415,7 @@ class _DynamicAlbumViewerState extends State<DynamicAlbumViewer>
); );
} else if (file_util.isSupportedVideoFormat(f)) { } else if (file_util.isSupportedVideoFormat(f)) {
yield _VideoListItem( yield _VideoListItem(
file: f,
account: widget.account, account: widget.account,
previewUrl: previewUrl, previewUrl: previewUrl,
onTap: () => _onItemTap(i), onTap: () => _onItemTap(i),
@ -433,13 +457,27 @@ abstract class _ListItem implements SelectableItem {
final VoidCallback _onTap; final VoidCallback _onTap;
} }
class _ImageListItem extends _ListItem { abstract class _FileListItem extends _ListItem {
_ImageListItem({ _FileListItem({
@required this.file, @required this.file,
VoidCallback onTap,
}) : super(
onTap: onTap,
);
final File file;
}
class _ImageListItem extends _FileListItem {
_ImageListItem({
@required File file,
@required this.account, @required this.account,
@required this.previewUrl, @required this.previewUrl,
VoidCallback onTap, VoidCallback onTap,
}) : super(onTap: onTap); }) : super(
file: file,
onTap: onTap,
);
@override @override
buildWidget(BuildContext context) { buildWidget(BuildContext context) {
@ -450,17 +488,20 @@ class _ImageListItem extends _ListItem {
); );
} }
final File file;
final Account account; final Account account;
final String previewUrl; final String previewUrl;
} }
class _VideoListItem extends _ListItem { class _VideoListItem extends _FileListItem {
_VideoListItem({ _VideoListItem({
@required File file,
@required this.account, @required this.account,
@required this.previewUrl, @required this.previewUrl,
VoidCallback onTap, VoidCallback onTap,
}) : super(onTap: onTap); }) : super(
file: file,
onTap: onTap,
);
@override @override
buildWidget(BuildContext context) { buildWidget(BuildContext context) {

View file

@ -21,8 +21,10 @@ import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/metadata_task_manager.dart'; import 'package:nc_photos/metadata_task_manager.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart'; import 'package:nc_photos/pref.dart';
import 'package:nc_photos/primitive.dart'; import 'package:nc_photos/primitive.dart';
import 'package:nc_photos/share_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/remove.dart'; import 'package:nc_photos/use_case/remove.dart';
@ -159,6 +161,14 @@ class _HomePhotosState extends State<HomePhotos>
title: Text(AppLocalizations.of(context) title: Text(AppLocalizations.of(context)
.selectionAppBarTitle(selectedListItems.length)), .selectionAppBarTitle(selectedListItems.length)),
actions: [ actions: [
if (platform_k.isAndroid)
IconButton(
icon: const Icon(Icons.share),
tooltip: AppLocalizations.of(context).shareSelectedTooltip,
onPressed: () {
_onSelectionAppBarSharePressed(context);
},
),
IconButton( IconButton(
icon: const Icon(Icons.playlist_add), icon: const Icon(Icons.playlist_add),
tooltip: AppLocalizations.of(context).addSelectedToAlbumTooltip, tooltip: AppLocalizations.of(context).addSelectedToAlbumTooltip,
@ -260,6 +270,19 @@ class _HomePhotosState extends State<HomePhotos>
arguments: ViewerArguments(widget.account, _backingFiles, index)); arguments: ViewerArguments(widget.account, _backingFiles, index));
} }
void _onSelectionAppBarSharePressed(BuildContext context) {
assert(platform_k.isAndroid);
final selected = selectedListItems
.whereType<_FileListItem>()
.map((e) => e.file)
.toList();
ShareHandler().shareFiles(context, widget.account, selected).then((_) {
setState(() {
clearSelectedItems();
});
});
}
void _onSelectionAppBarAddToAlbumPressed(BuildContext context) { void _onSelectionAppBarAddToAlbumPressed(BuildContext context) {
showDialog( showDialog(
context: context, context: context,
@ -699,4 +722,5 @@ class _VideoListItem extends _FileListItem {
enum _SelectionAppBarMenuOption { enum _SelectionAppBarMenuOption {
archive, archive,
delete,
} }

View file

@ -18,6 +18,7 @@ import 'package:nc_photos/mobile/notification.dart';
import 'package:nc_photos/mobile/platform.dart' import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/share_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/remove.dart'; import 'package:nc_photos/use_case/remove.dart';
@ -298,6 +299,18 @@ class _ViewerState extends State<Viewer> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: <Widget>[ children: <Widget>[
if (platform_k.isAndroid)
Expanded(
flex: 1,
child: IconButton(
icon: Icon(
Icons.share_outlined,
color: Colors.white.withOpacity(.87),
),
tooltip: AppLocalizations.of(context).shareTooltip,
onPressed: () => _onSharePressed(context),
),
),
Expanded( Expanded(
flex: 1, flex: 1,
child: IconButton( child: IconButton(
@ -543,6 +556,12 @@ class _ViewerState extends State<Viewer> {
} }
} }
void _onSharePressed(BuildContext context) {
assert(platform_k.isAndroid);
final file = widget.streamFiles[_pageController.page.round()];
ShareHandler().shareFiles(context, widget.account, [file]);
}
void _onDownloadPressed(BuildContext context) async { void _onDownloadPressed(BuildContext context) async {
final file = widget.streamFiles[_pageController.page.round()]; final file = widget.streamFiles[_pageController.page.round()];
_log.info("[_onDownloadPressed] Downloading file: ${file.path}"); _log.info("[_onDownloadPressed] Downloading file: ${file.path}");