Handle getting the storage permission ourselves. That way we can show a toast, if we did not get the permission. Also fixes an observed situation where FilePicker would crash in its native code due to not having the storage permission. Fixes #283.
482 lines
14 KiB
Dart
482 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
import 'package:better_open_file/better_open_file.dart';
|
|
import 'package:cryptography/cryptography.dart';
|
|
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
|
import 'package:fluttertoast/fluttertoast.dart';
|
|
import 'package:get_it/get_it.dart';
|
|
import 'package:hex/hex.dart';
|
|
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
|
import 'package:moxxyv2/shared/avatar.dart';
|
|
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
|
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
|
|
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
|
|
import 'package:moxxyv2/ui/constants.dart';
|
|
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
|
import 'package:moxxyv2/ui/redirects.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:qr_flutter/qr_flutter.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
/// Shows a dialog asking the user if they are sure that they want to proceed with an
|
|
/// action. Resolves to true if the user pressed the confirm button. Returns false if
|
|
/// the cancel button was pressed.
|
|
Future<bool> showConfirmationDialog(
|
|
String title,
|
|
String body,
|
|
BuildContext context,
|
|
) async {
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(title),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
|
|
),
|
|
content: Text(body),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: Text(t.global.yes),
|
|
),
|
|
TextButton(
|
|
onPressed: Navigator.of(context).pop,
|
|
child: Text(t.global.no),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
|
|
return result != null;
|
|
}
|
|
|
|
/// Shows a dialog telling the user that the [feature] feature is not implemented.
|
|
Future<void> showNotImplementedDialog(
|
|
String feature,
|
|
BuildContext context,
|
|
) async {
|
|
return showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Not Implemented'),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
|
|
),
|
|
content: SingleChildScrollView(
|
|
child: ListBody(
|
|
children: [Text('The $feature feature is not yet implemented.')],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
child: Text(t.global.dialogAccept),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
)
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Shows a dialog giving the user a very simple information with an "Okay" button.
|
|
Future<void> showInfoDialog(
|
|
String title,
|
|
String body,
|
|
BuildContext context,
|
|
) async {
|
|
await showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(title),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
|
|
),
|
|
content: Text(body),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: Navigator.of(context).pop,
|
|
child: Text(t.global.dialogAccept),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Dismissed the softkeyboard.
|
|
void dismissSoftKeyboard(BuildContext context) {
|
|
// NOTE: Thank you, https://flutterigniter.com/dismiss-keyboard-form-lose-focus/
|
|
final current = FocusScope.of(context);
|
|
if (!current.hasPrimaryFocus) {
|
|
current.unfocus();
|
|
}
|
|
}
|
|
|
|
/// A wrapper around [FilePicker.platform.pickFiles] that first checks if we have the
|
|
/// appropriate permission. If not, tries to request the permission. If that failed,
|
|
/// show a toast to inform the user and return null.
|
|
///
|
|
/// [type] is the type of file to pick.
|
|
///
|
|
/// [allowMultiple] indicates whether the file picker should allow multiple files to be
|
|
/// selected. Defaults to true.
|
|
///
|
|
/// [withData] is equal to the withData parameter of [FilePicker.platform.pickFiles].
|
|
Future<FilePickerResult?> safePickFiles(
|
|
FileType type, {
|
|
bool allowMultiple = true,
|
|
bool withData = false,
|
|
}) async {
|
|
// If we have no storage permission, request it. If that also failed, show a toast
|
|
// telling the user that the storage permission is not available.
|
|
final status = await Permission.storage.status;
|
|
if (status.isDenied) {
|
|
final newStatus = await Permission.storage.request();
|
|
if (!newStatus.isGranted) {
|
|
await Fluttertoast.showToast(
|
|
msg: t.errors.filePicker.permissionDenied,
|
|
gravity: ToastGravity.SNACKBAR,
|
|
toastLength: Toast.LENGTH_LONG,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return FilePicker.platform.pickFiles(
|
|
type: type,
|
|
allowMultiple: allowMultiple,
|
|
withData: withData,
|
|
);
|
|
}
|
|
|
|
/// Open the file picker to pick an image and open the cropping tool.
|
|
/// The Future either resolves to null if the user cancels the action or
|
|
/// the actual image data.
|
|
Future<Uint8List?> pickAndCropImage(BuildContext context) async {
|
|
final result =
|
|
await safePickFiles(FileType.image, allowMultiple: false, withData: true);
|
|
|
|
if (result != null) {
|
|
return GetIt.I
|
|
.get<CropBloc>()
|
|
.cropImageWithData(result.files.single.bytes!);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
class PickedAvatar {
|
|
const PickedAvatar(this.path, this.hash);
|
|
final String path;
|
|
final String hash;
|
|
}
|
|
|
|
/// Open the file picker to pick an image, open the cropping tool and then save it.
|
|
/// [oldPath] is the path of the old avatar or "" if none has been set.
|
|
/// Returns the path of the new avatar path.
|
|
Future<PickedAvatar?> pickAvatar(
|
|
BuildContext context,
|
|
String jid,
|
|
String oldPath,
|
|
) async {
|
|
final data = await pickAndCropImage(context);
|
|
|
|
if (data != null) {
|
|
// TODO(Unknown): Maybe tweak these values
|
|
final compressedData = await FlutterImageCompress.compressWithList(
|
|
data,
|
|
minHeight: 200,
|
|
minWidth: 200,
|
|
quality: 60,
|
|
format: CompressFormat.png,
|
|
);
|
|
|
|
final hash = (await Sha1().hash(compressedData)).bytes;
|
|
final hashhex = HEX.encode(hash);
|
|
final avatarPath =
|
|
await saveAvatarInCache(compressedData, hashhex, jid, oldPath);
|
|
|
|
return PickedAvatar(avatarPath, hashhex);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// Turn [text] into a text that can be used with the AvatarWrapper's alt.
|
|
/// [text] must be non-empty.
|
|
String avatarAltText(String text) {
|
|
assert(text.isNotEmpty, 'Text for avatar alt must be non-empty');
|
|
|
|
if (text.length == 1) return text[0].toUpperCase();
|
|
|
|
return (text[0] + text[1]).toUpperCase();
|
|
}
|
|
|
|
/// Return the color used for tiles depending on the system brightness.
|
|
Color getTileColor(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
switch (theme.brightness) {
|
|
case Brightness.light:
|
|
return tileColorLight;
|
|
case Brightness.dark:
|
|
return tileColorDark;
|
|
}
|
|
}
|
|
|
|
/// Return the corresponding language name (in its language) for the given
|
|
/// language code [localeCode], e.g. "de", "en", ...
|
|
String localeCodeToLanguageName(String localeCode) {
|
|
switch (localeCode) {
|
|
case 'de':
|
|
return 'Deutsch';
|
|
case 'en':
|
|
return 'English';
|
|
case 'default':
|
|
return t.pages.settings.appearance.systemLanguage;
|
|
}
|
|
|
|
assert(false, 'Language code $localeCode has no name');
|
|
return '';
|
|
}
|
|
|
|
/// Scans QR Codes for an URI with a scheme of xmpp:. Returns the URI when found.
|
|
/// Returns null if not.
|
|
Future<Uri?> scanXmppUriQrCode(BuildContext context) async {
|
|
final value = await Navigator.of(context).pushNamed<String>(
|
|
qrCodeScannerRoute,
|
|
arguments: QrCodeScanningArguments(
|
|
(value) {
|
|
if (value == null) return false;
|
|
|
|
final uri = Uri.tryParse(value);
|
|
if (uri == null) return false;
|
|
|
|
if (uri.scheme == 'xmpp') {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
),
|
|
);
|
|
|
|
if (value != null) {
|
|
return Uri.parse(value);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// Shows a dialog with the given data string encoded as a QR Code.
|
|
void showQrCode(BuildContext context, String data, {bool embedLogo = true}) {
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (BuildContext context) => Center(
|
|
child: ClipRRect(
|
|
borderRadius: const BorderRadius.all(radiusLarge),
|
|
child: SizedBox(
|
|
width: 220,
|
|
height: 220,
|
|
child: QrImage(
|
|
data: data,
|
|
size: 220,
|
|
backgroundColor: Colors.white,
|
|
embeddedImage:
|
|
embedLogo ? const AssetImage('assets/images/logo.png') : null,
|
|
embeddedImageStyle: embedLogo
|
|
? QrEmbeddedImageStyle(
|
|
size: const Size(50, 50),
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Compares the scanned fingerprint (encoded by [scannedUri]) against the device list
|
|
/// [devices] for the device with id [deviceId] with the JID [deviceJid].
|
|
///
|
|
/// Returns the index of the device in [devices] on success. On failure of any kind,
|
|
/// returns -1.
|
|
int isVerificationUriValid(
|
|
List<OmemoDevice> devices,
|
|
Uri scannedUri,
|
|
String deviceJid,
|
|
int deviceId,
|
|
) {
|
|
if (scannedUri.queryParameters.isEmpty) {
|
|
// No query parameters
|
|
Fluttertoast.showToast(
|
|
msg: t.errors.omemo.verificationInvalidOmemoUrl,
|
|
gravity: ToastGravity.SNACKBAR,
|
|
toastLength: Toast.LENGTH_SHORT,
|
|
);
|
|
return -1;
|
|
}
|
|
|
|
final jid = scannedUri.path;
|
|
if (deviceJid != jid) {
|
|
// The Jid is wrong
|
|
Fluttertoast.showToast(
|
|
msg: t.errors.omemo.verificationWrongJid,
|
|
gravity: ToastGravity.SNACKBAR,
|
|
toastLength: Toast.LENGTH_SHORT,
|
|
);
|
|
return -1;
|
|
}
|
|
|
|
// TODO(PapaTutuWawa): Use an exception safe version of firstWhere
|
|
final sidParam = scannedUri.queryParameters.keys
|
|
.firstWhere((param) => param.startsWith('omemo2-sid-'));
|
|
final id = int.parse(sidParam.replaceFirst('omemo2-sid-', ''));
|
|
final fp = scannedUri.queryParameters[sidParam];
|
|
|
|
if (id != deviceId) {
|
|
// The scanned device has the wrong Id
|
|
Fluttertoast.showToast(
|
|
msg: t.errors.omemo.verificationWrongDevice,
|
|
gravity: ToastGravity.SNACKBAR,
|
|
toastLength: Toast.LENGTH_SHORT,
|
|
);
|
|
return -1;
|
|
}
|
|
|
|
final index = devices.indexWhere((device) => device.deviceId == deviceId);
|
|
if (index == -1) {
|
|
// The device is not in the list
|
|
Fluttertoast.showToast(
|
|
msg: t.errors.omemo.verificationNotInList,
|
|
gravity: ToastGravity.SNACKBAR,
|
|
toastLength: Toast.LENGTH_SHORT,
|
|
);
|
|
return -1;
|
|
}
|
|
|
|
final device = devices[index];
|
|
if (device.fingerprint != fp) {
|
|
// The fingerprint is not what we expected
|
|
Fluttertoast.showToast(
|
|
msg: t.errors.omemo.verificationWrongFingerprint,
|
|
gravity: ToastGravity.SNACKBAR,
|
|
toastLength: Toast.LENGTH_SHORT,
|
|
);
|
|
return -1;
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
/// Parse the URI [uriString] and trigger an appropriate UI action.
|
|
Future<void> handleUri(String uriString) async {
|
|
final uri = Uri.tryParse(uriString);
|
|
if (uri == null) return;
|
|
|
|
if (uri.scheme == 'xmpp') {
|
|
final psAction = uri.queryParameters['pubsub;action'];
|
|
if (psAction != null) {
|
|
final parts = psAction.split(';');
|
|
String? node;
|
|
String? item;
|
|
|
|
for (final p in parts) {
|
|
if (p.startsWith('node=')) {
|
|
node = p.substring(5);
|
|
} else if (p.startsWith('item=')) {
|
|
item = p.substring(5);
|
|
}
|
|
}
|
|
|
|
if (node == moxxmpp.stickersXmlns && item != null) {
|
|
// Retrieve a sticker pack
|
|
GetIt.I.get<StickerPackBloc>().add(
|
|
StickerPackRequested(
|
|
uri.path,
|
|
item,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
await launchUrl(
|
|
redirectUrl(uri),
|
|
mode: LaunchMode.externalNonBrowserApplication,
|
|
);
|
|
}
|
|
|
|
/// Open the file [path] using the system native means. Shows a toast if the
|
|
/// file cannot be opened.
|
|
Future<void> openFile(String path) async {
|
|
final result = await OpenFile.open(path);
|
|
|
|
if (result.type != ResultType.done) {
|
|
String message;
|
|
if (result.type == ResultType.noAppToOpen) {
|
|
message = t.errors.conversation.openFileNoAppError;
|
|
} else {
|
|
message = t.errors.conversation.openFileGenericError;
|
|
}
|
|
|
|
await Fluttertoast.showToast(
|
|
msg: message,
|
|
toastLength: Toast.LENGTH_SHORT,
|
|
gravity: ToastGravity.SNACKBAR,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Opens a modal bottom sheet with an emoji picker. Resolves to the picked emoji,
|
|
/// if one was picked. If the picker was dismissed, resolves to null.
|
|
Future<String?> pickEmoji(BuildContext context, {bool pop = true}) async {
|
|
final emoji = await showModalBottomSheet<String>(
|
|
context: context,
|
|
// TODO(PapaTutuWawa): Move this to the theme
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: radiusLarge,
|
|
topRight: radiusLarge,
|
|
),
|
|
),
|
|
builder: (context) => Padding(
|
|
padding: const EdgeInsets.only(top: 12),
|
|
child: EmojiPicker(
|
|
onEmojiSelected: (_, emoji) {
|
|
// ignore: use_build_context_synchronously
|
|
Navigator.of(context).pop(emoji.emoji);
|
|
},
|
|
//height: pickerHeight,
|
|
config: Config(
|
|
bgColor: Theme.of(context).scaffoldBackgroundColor,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (pop) {
|
|
// ignore: use_build_context_synchronously
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
return emoji;
|
|
}
|
|
|
|
/// Compute the current position of the widget with the global key [key].
|
|
Rect getWidgetPositionOnScreen(GlobalKey key) {
|
|
// (See https://stackoverflow.com/questions/50316219/how-to-get-widgets-absolute-coordinates-on-a-screen-in-flutter/58788092#58788092)
|
|
final renderObject = key.currentContext!.findRenderObject()!;
|
|
final translation = renderObject.getTransformTo(null).getTranslation();
|
|
final offset = Offset(translation.x, translation.y);
|
|
return renderObject.paintBounds.shift(offset);
|
|
}
|