378 lines
11 KiB
Dart
378 lines
11 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: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: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();
|
|
}
|
|
}
|
|
|
|
/// 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 FilePicker.platform.pickFiles(
|
|
type: FileType.image,
|
|
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,
|
|
);
|
|
}
|
|
}
|