Support ATTACH_DATA intent on android

This is typically used to set wallpaper or contact photos
This commit is contained in:
Ming Ming 2023-08-04 01:23:33 +08:00
parent a142436e7b
commit d5de52a789
15 changed files with 407 additions and 129 deletions

View file

@ -19,24 +19,35 @@ class ShareChannelHandler(activity: Activity) :
try {
shareItems(
call.argument("fileUris")!!,
call.argument("mimeTypes")!!,
result
call.argument("mimeTypes")!!, result
)
} catch (e: Throwable) {
result.error("systemException", e.toString(), null)
}
}
"shareText" -> {
try {
shareText(
call.argument("text")!!,
call.argument("mimeType"),
call.argument("text")!!, call.argument("mimeType"),
result
)
} catch (e: Throwable) {
result.error("systemException", e.toString(), null)
}
}
"shareAsAttachData" -> {
try {
shareAsAttachData(
call.argument("fileUri")!!, call.argument("mimeType"),
result
)
} catch (e: Throwable) {
result.error("systemException", e.toString(), null)
}
}
else -> {
result.notImplemented()
}
@ -44,8 +55,7 @@ class ShareChannelHandler(activity: Activity) :
}
private fun shareItems(
fileUris: List<String>,
mimeTypes: List<String?>,
fileUris: List<String>, mimeTypes: List<String?>,
result: MethodChannel.Result
) {
assert(fileUris.isNotEmpty())
@ -104,6 +114,26 @@ class ShareChannelHandler(activity: Activity) :
result.success(null)
}
private fun shareAsAttachData(
fileUri: String, mimeType: String?, result: MethodChannel.Result
) {
val intent = Intent().apply {
action = Intent.ACTION_ATTACH_DATA
if (mimeType == null) {
data = Uri.parse(fileUri)
} else {
setDataAndType(Uri.parse(fileUri), mimeType)
}
addCategory(Intent.CATEGORY_DEFAULT)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(
intent, _context.getString(R.string.attach_data_chooser_title)
)
_context.startActivity(chooser)
result.success(null)
}
private val _activity = activity
private val _context get() = _activity
}

View file

@ -53,8 +53,8 @@ class LocalFileMediaStoreDataSource implements LocalFileDataSource {
onFailure?.call(f, ArgumentError("File not supported"), null);
});
final share = AndroidFileShare(uriFiles.map((e) => e.uri).toList(),
uriFiles.map((e) => e.mime).toList());
final share = AndroidFileShare(
uriFiles.map((e) => AndroidFileShareFile(e.uri, e.mime)).toList());
try {
await share.share();
} catch (e, stackTrace) {

View file

@ -0,0 +1,124 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
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/map_extension.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/use_case/download_file.dart';
import 'package:nc_photos/use_case/download_preview.dart';
import 'package:nc_photos/widget/processing_dialog.dart';
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
import 'package:np_codegen/np_codegen.dart';
part 'internal_download_handler.g.dart';
/// Download file to internal dir
@npLog
class InternalDownloadHandler {
const InternalDownloadHandler(this.account);
Future<Map<File, dynamic>> downloadPreviews(
BuildContext context, List<File> files) async {
final controller = StreamController<String>();
unawaited(
showDialog(
context: context,
builder: (context) => StreamBuilder(
stream: controller.stream,
builder: (context, snapshot) => ProcessingDialog(
text: L10n.global().shareDownloadingDialogContent +
(snapshot.hasData ? " ${snapshot.data}" : ""),
),
),
),
);
try {
final results = <MapEntry<File, dynamic>>[];
for (final pair in files.withIndex()) {
final i = pair.item1, f = pair.item2;
controller.add("($i/${files.length})");
try {
final dynamic uri;
if (file_util.isSupportedImageFormat(f) &&
f.contentType != "image/gif") {
uri = await DownloadPreview()(account, f);
} else {
uri = await DownloadFile()(account, f);
}
results.add(MapEntry(f, uri));
} catch (e, stacktrace) {
_log.shout(
"[downloadPreviews] Failed while DownloadPreview", e, stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e)),
duration: k.snackBarDurationNormal,
));
}
}
return results.toMap();
} finally {
// dismiss the dialog
Navigator.of(context).pop();
}
}
Future<Map<File, dynamic>> downloadFiles(
BuildContext context, List<File> files) async {
final controller = StreamController<String>();
unawaited(
showDialog(
context: context,
builder: (context) => StreamBuilder(
stream: controller.stream,
builder: (context, snapshot) => ProcessingDialog(
text: L10n.global().shareDownloadingDialogContent +
(snapshot.hasData ? " ${snapshot.data}" : ""),
),
),
),
);
try {
final results = <MapEntry<File, dynamic>>[];
for (final pair in files.withIndex()) {
final i = pair.item1, f = pair.item2;
controller.add("($i/${files.length})");
try {
results.add(MapEntry(
f,
await DownloadFile()(
account,
f,
shouldNotify: false,
)));
} on PermissionException catch (_) {
_log.warning("[downloadFiles] Permission not granted");
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().errorNoStoragePermission),
duration: k.snackBarDurationNormal,
));
rethrow;
} catch (e, stacktrace) {
_log.shout(
"[downloadFiles] Failed while downloadFile", e, stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e)),
duration: k.snackBarDurationNormal,
));
}
}
return results.toMap();
} finally {
// dismiss the dialog
Navigator.of(context).pop();
}
}
final Account account;
}

View file

@ -0,0 +1,15 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'internal_download_handler.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$InternalDownloadHandlerNpLog on InternalDownloadHandler {
// ignore: unused_element
Logger get _log => log;
static final log =
Logger("internal_download_handler.InternalDownloadHandler");
}

View file

@ -1419,6 +1419,10 @@
"removeCollectionsFailedNotification": "Failed to remove some collections",
"accountSettingsTooltip": "Account settings",
"contributorsTooltip": "Contributors",
"setAsTooltip": "Set as",
"@setAsTooltip": {
"description": "e.g., set as wallpaper"
},
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
"@errorUnauthenticated": {

View file

@ -17,7 +17,8 @@
"createCollectionDialogNextcloudAlbumDescription",
"removeCollectionsFailedNotification",
"accountSettingsTooltip",
"contributorsTooltip"
"contributorsTooltip",
"setAsTooltip"
],
"de": [
@ -106,6 +107,7 @@
"removeCollectionsFailedNotification",
"accountSettingsTooltip",
"contributorsTooltip",
"setAsTooltip",
"errorAlbumDowngrade"
],
@ -216,7 +218,8 @@
"createCollectionDialogNextcloudAlbumDescription",
"removeCollectionsFailedNotification",
"accountSettingsTooltip",
"contributorsTooltip"
"contributorsTooltip",
"setAsTooltip"
],
"es": [
@ -226,7 +229,8 @@
"settingsSeedColorPickerSystemColorButtonLabel",
"searchLandingPeopleListEmptyText2",
"accountSettingsTooltip",
"contributorsTooltip"
"contributorsTooltip",
"setAsTooltip"
],
"fi": [
@ -236,7 +240,8 @@
"settingsSeedColorPickerSystemColorButtonLabel",
"searchLandingPeopleListEmptyText2",
"accountSettingsTooltip",
"contributorsTooltip"
"contributorsTooltip",
"setAsTooltip"
],
"fr": [
@ -366,7 +371,8 @@
"createCollectionDialogNextcloudAlbumDescription",
"removeCollectionsFailedNotification",
"accountSettingsTooltip",
"contributorsTooltip"
"contributorsTooltip",
"setAsTooltip"
],
"it": [
@ -671,6 +677,7 @@
"removeCollectionsFailedNotification",
"accountSettingsTooltip",
"contributorsTooltip",
"setAsTooltip",
"errorUnauthenticated",
"errorDisconnected",
"errorLocked",
@ -1021,6 +1028,7 @@
"removeCollectionsFailedNotification",
"accountSettingsTooltip",
"contributorsTooltip",
"setAsTooltip",
"errorUnauthenticated",
"errorDisconnected",
"errorLocked",
@ -1172,7 +1180,8 @@
"createCollectionDialogNextcloudAlbumDescription",
"removeCollectionsFailedNotification",
"accountSettingsTooltip",
"contributorsTooltip"
"contributorsTooltip",
"setAsTooltip"
],
"pt": [
@ -1193,7 +1202,8 @@
"createCollectionDialogNextcloudAlbumDescription",
"removeCollectionsFailedNotification",
"accountSettingsTooltip",
"contributorsTooltip"
"contributorsTooltip",
"setAsTooltip"
],
"ru": [
@ -1319,7 +1329,8 @@
"createCollectionDialogNextcloudAlbumDescription",
"removeCollectionsFailedNotification",
"accountSettingsTooltip",
"contributorsTooltip"
"contributorsTooltip",
"setAsTooltip"
],
"zh": [
@ -1445,7 +1456,8 @@
"createCollectionDialogNextcloudAlbumDescription",
"removeCollectionsFailedNotification",
"accountSettingsTooltip",
"contributorsTooltip"
"contributorsTooltip",
"setAsTooltip"
],
"zh_Hant": [
@ -1571,6 +1583,7 @@
"createCollectionDialogNextcloudAlbumDescription",
"removeCollectionsFailedNotification",
"accountSettingsTooltip",
"contributorsTooltip"
"contributorsTooltip",
"setAsTooltip"
]
}

View file

@ -8,12 +8,17 @@ class Share {
"mimeTypes": mimeTypes,
});
static Future<void> shareText(
String text, String? mimeType) =>
static Future<void> shareText(String text, String? mimeType) =>
_channel.invokeMethod("shareText", <String, dynamic>{
"text": text,
"mimeType": mimeType,
});
static Future<void> shareAsAttachData(String fileUri, String? mimeType) =>
_channel.invokeMethod("shareAsAttachData", <String, dynamic>{
"fileUri": fileUri,
"mimeType": mimeType,
});
static const _channel = MethodChannel("com.nkming.nc_photos/share");
}

View file

@ -1,16 +1,29 @@
import 'package:nc_photos/mobile/android/share.dart';
import 'package:nc_photos/platform/share.dart' as itf;
class AndroidFileShare extends itf.FileShare {
AndroidFileShare(this.fileUris, this.mimeTypes);
class AndroidFileShareFile {
const AndroidFileShareFile(this.fileUri, this.mimeType);
final String fileUri;
final String? mimeType;
}
class AndroidFileShare implements itf.FileShare {
const AndroidFileShare(this.files);
@override
share() {
return Share.shareItems(fileUris, mimeTypes);
Future<void> share() {
final uris = files.map((e) => e.fileUri).toList();
final mimes = files.map((e) => e.mimeType).toList();
return Share.shareItems(uris, mimes);
}
final List<String> fileUris;
final List<String?> mimeTypes;
Future<void> setAs() {
assert(files.length == 1);
return Share.shareAsAttachData(files.first.fileUri, files.first.mimeType);
}
final List<AndroidFileShareFile> files;
}
class AndroidTextShare extends itf.TextShare {

View file

@ -0,0 +1,86 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/internal_download_handler.dart';
import 'package:nc_photos/k.dart' as k;
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/use_case/inflate_file_descriptor.dart';
import 'package:nc_photos/widget/set_as_method_dialog.dart';
import 'package:np_codegen/np_codegen.dart';
part 'set_as_handler.g.dart';
/// A special way to share image to other apps
@npLog
class SetAsHandler {
SetAsHandler(
this._c, {
required this.context,
this.clearSelection,
});
Future<void> setAsFile(Account account, FileDescriptor fd) async {
try {
final file = (await InflateFileDescriptor(_c)(account, [fd])).first;
final method = await _askSetAsMethod(file);
switch (method) {
case SetAsMethod.preview:
return await _setAsAsPreview(account, file);
case SetAsMethod.file:
return await _setAsAsFile(account, file);
case null:
return;
}
} catch (e, stackTrace) {
_log.shout("[setAsFile] Failed while sharing files", e, stackTrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e)),
duration: k.snackBarDurationNormal,
));
} finally {
if (!isSelectionCleared) {
clearSelection?.call();
}
}
}
Future<SetAsMethod?> _askSetAsMethod(File file) {
return showDialog<SetAsMethod>(
context: context,
builder: (context) => const SetAsMethodDialog(),
);
}
Future<void> _setAsAsPreview(Account account, File file) async {
assert(platform_k.isAndroid);
final results = await InternalDownloadHandler(account)
.downloadPreviews(context, [file]);
final share = AndroidFileShare(results.entries
.map((e) => AndroidFileShareFile(e.value as String, e.key.contentType))
.toList());
return share.setAs();
}
Future<void> _setAsAsFile(Account account, File file) async {
assert(platform_k.isAndroid);
final results =
await InternalDownloadHandler(account).downloadFiles(context, [file]);
final share = AndroidFileShare(results.entries
.map((e) => AndroidFileShareFile(e.value as String, e.key.contentType))
.toList());
return share.setAs();
}
final DiContainer _c;
final BuildContext context;
final VoidCallback? clearSelection;
var isSelectionCleared = false;
}

View file

@ -0,0 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'set_as_handler.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$SetAsHandlerNpLog on SetAsHandler {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("set_as_handler.SetAsHandler");
}

View file

@ -17,7 +17,7 @@ import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/share/data_source.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/internal_download_handler.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/mobile/share.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
@ -26,17 +26,12 @@ import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/use_case/copy.dart';
import 'package:nc_photos/use_case/create_dir.dart';
import 'package:nc_photos/use_case/create_share.dart';
import 'package:nc_photos/use_case/download_file.dart';
import 'package:nc_photos/use_case/download_preview.dart';
import 'package:nc_photos/use_case/inflate_file_descriptor.dart';
import 'package:nc_photos/use_case/share_local.dart';
import 'package:nc_photos/widget/processing_dialog.dart';
import 'package:nc_photos/widget/share_link_multiple_files_dialog.dart';
import 'package:nc_photos/widget/share_method_dialog.dart';
import 'package:nc_photos/widget/simple_input_dialog.dart';
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:tuple/tuple.dart';
part 'share_handler.g.dart';
@ -118,100 +113,21 @@ class ShareHandler {
Future<void> _shareAsPreview(Account account, List<File> files) async {
assert(platform_k.isAndroid);
final controller = StreamController<String>();
unawaited(
showDialog(
context: context,
builder: (context) => StreamBuilder(
stream: controller.stream,
builder: (context, snapshot) => ProcessingDialog(
text: L10n.global().shareDownloadingDialogContent +
(snapshot.hasData ? " ${snapshot.data}" : ""),
),
),
),
);
final results = <Tuple2<File, dynamic>>[];
for (final pair in files.withIndex()) {
final i = pair.item1, f = pair.item2;
controller.add("($i/${files.length})");
try {
final dynamic uri;
if (file_util.isSupportedImageFormat(f) &&
f.contentType != "image/gif") {
uri = await DownloadPreview()(account, f);
} else {
uri = await DownloadFile()(account, f);
}
results.add(Tuple2(f, uri));
} catch (e, stacktrace) {
_log.shout(
"[_shareAsPreview] Failed while DownloadPreview", e, stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e)),
duration: k.snackBarDurationNormal,
));
}
}
// dismiss the dialog
Navigator.of(context).pop();
final share = AndroidFileShare(
results.map((e) => e.item2 as String).toList(),
results.map((e) => e.item1.contentType).toList());
final results =
await InternalDownloadHandler(account).downloadPreviews(context, files);
final share = AndroidFileShare(results.entries
.map((e) => AndroidFileShareFile(e.value as String, e.key.contentType))
.toList());
return share.share();
}
Future<void> _shareAsFile(Account account, List<File> files) async {
assert(platform_k.isAndroid);
final controller = StreamController<String>();
unawaited(
showDialog(
context: context,
builder: (context) => StreamBuilder(
stream: controller.stream,
builder: (context, snapshot) => ProcessingDialog(
text: L10n.global().shareDownloadingDialogContent +
(snapshot.hasData ? " ${snapshot.data}" : ""),
),
),
),
);
final results = <Tuple2<File, dynamic>>[];
for (final pair in files.withIndex()) {
final i = pair.item1, f = pair.item2;
controller.add("($i/${files.length})");
try {
results.add(Tuple2(
f,
await DownloadFile()(
account,
f,
shouldNotify: false,
)));
} on PermissionException catch (_) {
_log.warning("[_shareAsFile] Permission not granted");
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().errorNoStoragePermission),
duration: k.snackBarDurationNormal,
));
// dismiss the dialog
Navigator.of(context).pop();
rethrow;
} catch (e, stacktrace) {
_log.shout("[_shareAsFile] Failed while downloadFile", e, stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e)),
duration: k.snackBarDurationNormal,
));
}
}
// dismiss the dialog
Navigator.of(context).pop();
final share = AndroidFileShare(
results.map((e) => e.item2 as String).toList(),
results.map((e) => e.item1.contentType).toList());
final results =
await InternalDownloadHandler(account).downloadFiles(context, files);
final share = AndroidFileShare(results.entries
.map((e) => AndroidFileShareFile(e.value as String, e.key.contentType))
.toList());
return share.share();
}

View file

@ -94,10 +94,9 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
}
}
if (results.isNotEmpty) {
final share = AndroidFileShare(
results.map((e) => e.item2 as String).toList(),
results.map((e) => e.item1.fdMime).toList(),
);
final share = AndroidFileShare(results
.map((e) => AndroidFileShareFile(e.item2 as String, e.item1.fdMime))
.toList());
unawaited(share.share());
}
emit(state.copyWith(result: true));
@ -129,10 +128,9 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger {
}
}
if (results.isNotEmpty) {
final share = AndroidFileShare(
results.map((e) => e.item2 as String).toList(),
results.map((e) => e.item1.fdMime).toList(),
);
final share = AndroidFileShare(results
.map((e) => AndroidFileShareFile(e.item2 as String, e.item1.fdMime))
.toList());
unawaited(share.share());
}
emit(state.copyWith(result: true));

View file

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:nc_photos/app_localizations.dart';
enum SetAsMethod {
file,
preview,
}
class SetAsMethodDialog extends StatelessWidget {
const SetAsMethodDialog({
super.key,
this.isSupportPerview = true,
});
@override
Widget build(BuildContext context) {
return SimpleDialog(
title: Text(L10n.global().setAsTooltip),
children: [
if (isSupportPerview)
SimpleDialogOption(
child: ListTile(
title: Text(L10n.global().shareMethodPreviewTitle),
subtitle: Text(L10n.global().shareMethodPreviewDescription),
),
onPressed: () {
Navigator.of(context).pop(SetAsMethod.preview);
},
),
SimpleDialogOption(
child: ListTile(
title: Text(L10n.global().shareMethodOriginalFileTitle),
subtitle: Text(L10n.global().shareMethodOriginalFileDescription),
),
onPressed: () {
Navigator.of(context).pop(SetAsMethod.file);
},
),
],
);
}
final bool isSupportPerview;
}

View file

@ -18,12 +18,14 @@ import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/exif_extension.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/location_util.dart' as location_util;
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/platform/features.dart' as features;
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/set_as_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/inflate_file_descriptor.dart';
@ -169,6 +171,13 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
label: L10n.global().addItemToCollectionTooltip,
onPressed: () => _onAddToAlbumPressed(context),
),
if (platform_k.isAndroid &&
file_util.isSupportedImageFormat(_file!))
_DetailPaneButton(
icon: Icons.launch,
label: L10n.global().setAsTooltip,
onPressed: () => _onSetAsPressed(context),
),
if (widget.fd.fdIsArchived == true)
_DetailPaneButton(
icon: Icons.unarchive_outlined,
@ -417,6 +426,12 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
);
}
void _onSetAsPressed(BuildContext context) {
assert(_file != null);
final c = KiwiContainer().resolve<DiContainer>();
SetAsHandler(c, context: context).setAsFile(widget.account, _file!);
}
void _onMapTap() {
if (platform_k.isAndroid) {
final intent = AndroidIntent(

View file

@ -11,4 +11,5 @@
<string name="download_progress_notification_untitled_text">Downloading</string>
<string name="log_save_successful_notification_title">Logs saved successfully</string>
<string name="log_save_successful_notification_text">Tap to view your saved logs</string>
<string name="attach_data_chooser_title">Set as</string>
</resources>