diff --git a/lib/i18n/app_en.arb b/lib/i18n/app_en.arb index b11af59..a4eb3f6 100644 --- a/lib/i18n/app_en.arb +++ b/lib/i18n/app_en.arb @@ -38,6 +38,8 @@ "cancel": "Cancel", "report": "Report", "reply": "Reply", + "export": "Export", + "import": "Import", "settings": "Settings", "errorHappened": "An Error Occurred", "notification": "Notification", @@ -56,6 +58,11 @@ "friendAddDone": "Friend request sent, go reach your friend!", "personalize": "Personalize", "personalizeApplied": "Your account information has been updated, some fields may take a while to fully applied.", + "keypair": "Keypair", + "keypairGenerated": "A new set of keypair has been generated. Automatically set it to active.", + "keypairSecretCode": "Secret Code", + "keypairImportHint": "You can paste the exported secret code here to import all keys in it.", + "keypairExportHint": "You can copy the exported secret code to other device to import all keys into it. Do not share it with anybody else!", "reaction": "Reaction", "reactVerb": "React", "post": "Post", @@ -107,6 +114,7 @@ "chatChannelAliasLabel": "Channel Alias", "chatChannelNameLabel": "Channel Name", "chatChannelDescriptionLabel": "Channel Description", + "chatChannelEncryptedLabel": "Encrypted Channel", "chatChannelLeaveConfirm": "Are you sure you want to leave this channel? Your message will be stored, but if you rejoin this channel later, you will lose your control of your previous messages.", "chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!", "chatCall": "Call", @@ -124,9 +132,13 @@ "chatCallDisconnectConfirm": "Are you sure you want to disconnect? You can reconnect after this if you want.", "chatCallChangeSpeaker": "Change Speaker", "chatMessagePlaceholder": "Write a message...", + "chatMessageEncryptedPlaceholder": "Write a encrypted message...", "chatMessageSending": "Now delivering your messages...", "chatMessageEditNotify": "You are about editing a message.", "chatMessageReplyNotify": "You are about replying a message.", "chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!", - "chatMessageViewSource": "View source" + "chatMessageViewSource": "View source", + "chatMessageUnableDecryptWaiting": "Waiting for encryption key...", + "chatMessageUnableDecryptUnsupported": "Unable to decrypt the message, encryption algorithm unsupported", + "chatMessageUnableDecryptMissing": "Unable to decrypt the message, missing encryption key" } diff --git a/lib/i18n/app_zh.arb b/lib/i18n/app_zh.arb index f77035e..162a6f8 100644 --- a/lib/i18n/app_zh.arb +++ b/lib/i18n/app_zh.arb @@ -38,6 +38,8 @@ "exit": "离开", "report": "举报", "reply": "回复", + "export": "导出", + "import": "导入", "settings": "设置", "errorHappened": "发生了错误", "notification": "通知", @@ -56,6 +58,11 @@ "friendAddDone": "好友请求已发送,快告诉你的朋友吧!", "personalize": "个性化", "personalizeApplied": "您的账号信息已被更新,部分信息可能需要一段时间来同步。", + "keypair": "密钥对", + "keypairGenerated": "已生成一套新的密钥对,并且设为活跃的密钥。", + "keypairSecretCode": "神秘代码", + "keypairImportHint": "你可以将别的设备导出的神秘代码粘贴到这里来导入其中的所有密钥。", + "keypairExportHint": "你可以将这个导出的神秘代码到你的别的设备来导入这个设备所包含的密钥,但绝对不要发送给其他人!", "reaction": "反应", "reactVerb": "作出反应", "post": "帖子", @@ -107,6 +114,7 @@ "chatChannelAliasLabel": "频道别名", "chatChannelNameLabel": "频道名称", "chatChannelDescriptionLabel": "频道简介", + "chatChannelEncryptedLabel": "加密频道", "chatChannelLeaveConfirm": "你确定你要离开这个频道吗?你在这个频道里的消息将被存储下来,但是当你重新加入本频道后你将会失去对你之前消息的权限。", "chatChannelDeleteConfirm": "你确定你要删除这个频道吗?这个频道里的所有消息都将消失,并且不可被反转!", "chatCall": "通话", @@ -124,9 +132,13 @@ "chatCallDisconnect": "断开连接", "chatCallDisconnectConfirm": "你确定你要断开连接吗?你可以之后在任何时候重新连接。", "chatMessagePlaceholder": "发条消息……", + "chatMessageEncryptedPlaceholder": "发条加密信息……", "chatMessageSending": "正在送出你的信息……", "chatMessageEditNotify": "你正在编辑信息中……", "chatMessageReplyNotify": "你正在回复消息中……", "chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!", - "chatMessageViewSource": "查看原始信息" + "chatMessageViewSource": "查看原始信息", + "chatMessageUnableDecryptWaiting": "正在等待解密密钥……", + "chatMessageUnableDecryptUnsupported": "无法解密信息,不支持加密的算法", + "chatMessageUnableDecryptMissing": "无法解密信息,缺失解密密钥" } diff --git a/lib/main.dart b/lib/main.dart index 6ca81d5..2f14ac9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/providers/chat.dart'; import 'package:solian/providers/friend.dart'; +import 'package:solian/providers/keypair.dart'; import 'package:solian/providers/navigation.dart'; import 'package:solian/providers/notify.dart'; import 'package:solian/providers/realm.dart'; @@ -12,7 +13,6 @@ import 'package:solian/utils/timeago.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:solian/utils/video_player.dart'; import 'package:solian/widgets/chat/call/call_overlay.dart'; -import 'package:solian/widgets/notification_notifier.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -45,13 +45,14 @@ class SolianApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => NotifyProvider()), ChangeNotifierProvider(create: (_) => FriendProvider()), ChangeNotifierProvider(create: (_) => RealmProvider()), + ChangeNotifierProvider(create: (_) => KeypairProvider()), ], child: Overlay( initialEntries: [ OverlayEntry(builder: (context) { return ScaffoldMessenger( child: Scaffold( - body: NotificationNotifier(child: child ?? Container()), + body: child ?? Container(), ), ); }), diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 3b9e96d..aa6a9e2 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -12,6 +12,7 @@ class Channel { Account account; int accountId; int? realmId; + bool isEncrypted; bool isAvailable = false; @@ -26,6 +27,7 @@ class Channel { required this.type, required this.account, required this.accountId, + required this.isEncrypted, this.realmId, }); @@ -41,6 +43,7 @@ class Channel { account: Account.fromJson(json['account']), accountId: json['account_id'], realmId: json['realm_id'], + isEncrypted: json['is_encrypted'], ); Map toJson() => { @@ -55,6 +58,7 @@ class Channel { 'account': account, 'account_id': accountId, 'realm_id': realmId, + 'is_encrypted': isEncrypted, }; } diff --git a/lib/models/keypair.dart b/lib/models/keypair.dart new file mode 100644 index 0000000..53fc010 --- /dev/null +++ b/lib/models/keypair.dart @@ -0,0 +1,32 @@ +class Keypair { + final String id; + final String algorithm; + final String publicKey; + final String? privateKey; + + final bool isOwned; + + Keypair({ + required this.id, + required this.algorithm, + required this.publicKey, + required this.privateKey, + this.isOwned = false, + }); + + factory Keypair.fromJson(Map json) => Keypair( + id: json['id'], + algorithm: json['algorithm'], + publicKey: json['public_key'], + privateKey: json['private_key'], + isOwned: json['is_owned'], + ); + + Map toJson() => { + 'id': id, + 'algorithm': algorithm, + 'public_key': publicKey, + 'private_key': privateKey, + 'is_owned': isOwned, + }; +} diff --git a/lib/providers/keypair.dart b/lib/providers/keypair.dart new file mode 100644 index 0000000..4def416 --- /dev/null +++ b/lib/providers/keypair.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:encrypt/encrypt.dart' as encrypt; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:solian/models/keypair.dart'; +import 'package:solian/models/packet.dart'; +import 'package:uuid/uuid.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class KeypairProvider extends ChangeNotifier { + static const storage = FlutterSecureStorage(); + static const encryptIV = 'WT7s~> keys = {}; + List requestingKeys = List.empty(growable: true); + + KeypairProvider() { + loadKeys(); + } + + void loadKeys() async { + final result = await storage.read(key: 'keypair'); + if (result != null) { + jsonDecode(result).values.forEach((x) { + keys[x['id']] = Keypair.fromJson(x); + }); + activeKeyId = await storage.read(key: 'keypairActive'); + } + notifyListeners(); + } + + void saveKeys() async { + await storage.write(key: 'keypair', value: jsonEncode(keys)); + if (activeKeyId != null) { + await storage.write(key: 'keypairActive', value: activeKeyId); + } + } + + void receiveKeypair(Keypair kp) { + keys[kp.id] = kp; + requestingKeys.remove(kp.id); + saveKeys(); + notifyListeners(); + } + + Keypair? provideKeypair(String id) { + print(id); + print(keys[id]); + return keys[id]; + } + + void importKeys(String code) { + final result = jsonDecode(utf8.fuse(base64).decode(code)).map((x) => Keypair.fromJson(x)).toList(); + for (final item in result) { + if (item is Keypair) { + keys[item.id] = item; + } + } + saveKeys(); + notifyListeners(); + } + + void setActiveKey(String id) { + if (keys[id] == null) return; + activeKeyId = id; + saveKeys(); + notifyListeners(); + } + + void clearKeys() { + keys = {}; + storage.delete(key: 'keypairActive'); + saveKeys(); + } + + bool requestKey(String id, String algorithm, int uid) { + if (channel == null) return false; + if (requestingKeys.contains(id)) return false; + + channel!.sink.add(jsonEncode( + NetworkPackage(method: 'kex.request', payload: { + 'request_id': const Uuid().v4(), + 'keypair_id': id, + 'algorithm': algorithm, + 'owner_id': uid, + 'deadline': 3, + }).toJson(), + )); + + requestingKeys.add(id); + notifyListeners(); + return true; + } + + String? encodeViaAESKey(String keypairId, String content) { + if (keys[keypairId] == null) { + return null; + } else if (keys[keypairId]?.algorithm != 'aes') { + throw Exception('invalid algorithm'); + } + + final kp = keys[keypairId]!; + final iv = encrypt.IV.fromUtf8(encryptIV); + final key = encrypt.Key.fromBase64(kp.publicKey); + final encryptor = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.sic, padding: null)); + return encryptor.encryptBytes(utf8.encode(content), iv: iv).base64; + } + + String? decodeViaAESKey(String keypairId, String encrypted) { + if (keys[keypairId] == null) { + return null; + } else if (keys[keypairId]?.algorithm != 'aes') { + throw Exception('invalid algorithm'); + } + + final kp = keys[keypairId]!; + final iv = encrypt.IV.fromUtf8(encryptIV); + final key = encrypt.Key.fromBase64(kp.publicKey); + final encryptor = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.sic, padding: null)); + return utf8.decode(encryptor.decryptBytes(encrypt.Encrypted.fromBase64(encrypted), iv: iv)); + } + + Keypair generateAESKey() { + final random = Random.secure(); + final values = List.generate(32, (i) => random.nextInt(256)); + final key = Uint8List.fromList(values); + + final kp = Keypair( + id: const Uuid().v4(), + algorithm: 'aes', + publicKey: base64.encode(key), + privateKey: null, + isOwned: true, + ); + + keys[kp.id] = kp; + + return kp; + } +} diff --git a/lib/providers/notify.dart b/lib/providers/notify.dart index deb6536..b684e64 100644 --- a/lib/providers/notify.dart +++ b/lib/providers/notify.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:solian/models/keypair.dart'; import 'package:solian/models/packet.dart'; import 'package:solian/models/pagination.dart'; import 'package:solian/providers/auth.dart'; @@ -63,9 +64,13 @@ class NotifyProvider extends ChangeNotifier { notifyListeners(); } - Future connect(AuthProvider auth) async { + Future connect( + AuthProvider auth, { + Keypair? Function(String id)? onKexRequest, + Function(Keypair kp)? onKexProvide, + }) async { if (auth.client == null) await auth.loadClient(); - if (!await auth.isAuthorized()) return; + if (!await auth.isAuthorized()) return null; await auth.client!.refreshToken(auth.client!.currentRefreshToken!); @@ -87,19 +92,40 @@ class NotifyProvider extends ChangeNotifier { switch (result.method) { case 'notifications.new': final result = model.Notification.fromJson(jsonDecode(event)); - onRemoteMessage(result); + unreadAmount++; + notifications.add(result); + notifyListeners(); notifyMessage(result.subject, result.content); + break; + case 'kex.request': + if (onKexRequest == null || result.payload == null) break; + final resp = onKexRequest(result.payload!['keypair_id']); + if (resp == null) break; + channel.sink.add(jsonEncode( + NetworkPackage(method: 'kex.provide', payload: { + 'request_id': result.payload!['request_id'], + 'keypair_id': resp.id, + 'public_key': resp.publicKey, + 'algorithm': resp.algorithm, + }).toJson(), + )); + break; + case 'kex.provide': + if (onKexProvide == null || result.payload == null) break; + onKexProvide(Keypair( + id: result.payload!['keypair_id'], + algorithm: result.payload?['algorithm'] ?? 'aes', + publicKey: result.payload!['public_key'], + privateKey: result.payload?['private_key'], + )); + break; } }, onError: (_, __) => connect(auth), onDone: () => connect(auth), ); - } - void onRemoteMessage(model.Notification item) { - unreadAmount++; - notifications.add(item); - notifyListeners(); + return channel; } void notifyMessage(String title, String body) { @@ -136,7 +162,7 @@ class NotifyProvider extends ChangeNotifier { notifyListeners(); } - void clearRealtime() { + void clearRealtimeNotifications() { notifications = notifications.where((x) => !x.isRealtime).toList(); notifyListeners(); } diff --git a/lib/router.dart b/lib/router.dart index 21bf4a5..6aaf80e 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -6,6 +6,7 @@ import 'package:solian/models/post.dart'; import 'package:solian/models/realm.dart'; import 'package:solian/screens/account.dart'; import 'package:solian/screens/account/friend.dart'; +import 'package:solian/screens/account/keypair.dart'; import 'package:solian/screens/account/personalize.dart'; import 'package:solian/screens/auth/signup.dart'; import 'package:solian/screens/chat/call.dart'; @@ -187,7 +188,7 @@ abstract class SolianRouter { name: 'chat.channel.editor', builder: (context, state) => ChannelEditorScreen( editing: state.extra as Channel?, - realm: state.uri.queryParameters['realm'], + realm: state.uri.queryParameters['realm'] ?? 'global', ), ), GoRoute( @@ -250,6 +251,11 @@ abstract class SolianRouter { name: 'account.personalize', builder: (context, state) => const PersonalizeScreen(), ), + GoRoute( + path: '/account/keypair', + name: 'account.keypair', + builder: (context, state) => const KeypairScreen(), + ), ], ), GoRoute( diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 05be951..2ec8d3b 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/keypair.dart'; import 'package:solian/router.dart'; import 'package:solian/utils/theme.dart'; import 'package:solian/widgets/account/account_avatar.dart'; @@ -49,6 +50,13 @@ class _AccountScreenWidgetState extends State { @override Widget build(BuildContext context) { final auth = context.watch(); + final keypair = context.read(); + + final actionItems = [ + (const Icon(Icons.color_lens), AppLocalizations.of(context)!.personalize, 'account.personalize'), + (const Icon(Icons.diversity_1), AppLocalizations.of(context)!.friend, 'account.friend'), + (const Icon(Icons.key), AppLocalizations.of(context)!.keypair, 'account.keypair'), + ]; if (_isAuthorized) { return Column( @@ -57,28 +65,23 @@ class _AccountScreenWidgetState extends State { padding: EdgeInsets.only(top: 16, bottom: 8, left: 24, right: 24), child: NameCard(), ), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 34), - leading: const Icon(Icons.color_lens), - title: Text(AppLocalizations.of(context)!.personalize), - onTap: () { - widget.onSelect('account.personalize'); - }, - ), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 34), - leading: const Icon(Icons.diversity_1), - title: Text(AppLocalizations.of(context)!.friend), - onTap: () { - widget.onSelect('account.friend'); - }, - ), + ...(actionItems.map( + (x) => ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 34), + leading: x.$1, + title: Text(x.$2), + onTap: () { + widget.onSelect(x.$3); + }, + ), + )), ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 34), leading: const Icon(Icons.logout), title: Text(AppLocalizations.of(context)!.signOut), onTap: () { auth.signoff(); + keypair.clearKeys(); setState(() { _isAuthorized = false; }); diff --git a/lib/screens/account/keypair.dart b/lib/screens/account/keypair.dart new file mode 100644 index 0000000..9042e5b --- /dev/null +++ b/lib/screens/account/keypair.dart @@ -0,0 +1,191 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:provider/provider.dart'; +import 'package:solian/models/keypair.dart'; +import 'package:solian/providers/keypair.dart'; +import 'package:solian/widgets/scaffold.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class KeypairScreen extends StatelessWidget { + const KeypairScreen({super.key}); + + Widget getIcon(KeypairProvider provider, Keypair item) { + if (item.id == provider.activeKeyId) { + return const Icon(Icons.check_box); + } else if (item.isOwned) { + return const Icon(Icons.check_box_outlined); + } else { + return const Icon(Icons.key); + } + } + + void importKeys(BuildContext context) async { + final controller = TextEditingController(); + final input = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)!.import), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(AppLocalizations.of(context)!.keypairImportHint), + const SizedBox(height: 18), + TextField( + controller: controller, + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + labelText: AppLocalizations.of(context)!.keypairSecretCode, + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + ], + ), + actions: [ + TextButton( + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.8), + ), + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + child: Text(AppLocalizations.of(context)!.next), + onPressed: () { + Navigator.pop(context, controller.text); + }, + ), + ], + ); + }, + ); + + WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose()); + + if (input == null || input.isEmpty) return; + + context.read().importKeys(input); + } + + void exportKeys(BuildContext context) { + showModalBottomSheet(context: context, builder: (context) => const KeypairExportWidget()); + } + + @override + Widget build(BuildContext context) { + final keypair = context.watch(); + final keys = keypair.keys.values.toList(); + + return IndentScaffold( + title: AppLocalizations.of(context)!.keypair, + hideDrawer: true, + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.generating_tokens), + onPressed: () { + final result = keypair.generateAESKey(); + keypair.setActiveKey(result.id); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.keypairGenerated), + )); + }, + ), + appBarActions: [ + IconButton( + icon: const Icon(Icons.upload), + tooltip: AppLocalizations.of(context)!.import, + onPressed: () => importKeys(context), + ), + IconButton( + icon: const Icon(Icons.download), + tooltip: AppLocalizations.of(context)!.export, + onPressed: () => exportKeys(context), + ), + ], + body: ListView.builder( + itemCount: keys.length, + itemBuilder: (context, index) { + final element = keys[index]; + final randomId = DateTime.now().microsecondsSinceEpoch >> 10; + return Dismissible( + key: Key(randomId.toString()), + background: Container( + color: Colors.teal, + padding: const EdgeInsets.symmetric(horizontal: 20), + alignment: Alignment.centerLeft, + child: const Icon(Icons.check, color: Colors.white), + ), + direction: keypair.activeKeyId != element.id && element.isOwned + ? DismissDirection.horizontal + : DismissDirection.none, + child: ListTile( + leading: getIcon(keypair, element), + title: Text('${element.algorithm.toUpperCase()} Key'), + subtitle: Text(element.id.toUpperCase()), + ), + onDismissed: (_) { + keypair.setActiveKey(element.id); + }, + ); + }, + ), + ); + } +} + +class KeypairExportWidget extends StatelessWidget { + const KeypairExportWidget({super.key}); + + String getEncodedContent(BuildContext context) { + final keypair = context.read(); + return utf8.fuse(base64).encode(jsonEncode( + keypair.keys.values.map((x) => x.toJson()).toList(), + )); + } + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(32)), + child: SizedBox( + width: double.infinity, + height: 640, + child: ListView( + children: [ + Container( + padding: const EdgeInsets.only(left: 20, top: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.export, + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 20), + child: Text( + AppLocalizations.of(context)!.keypairExportHint, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + Markdown( + selectable: true, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + data: '```\n${getEncodedContent(context)}\n```', + padding: const EdgeInsets.all(0), + styleSheet: MarkdownStyleSheet( + codeblockPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 8), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/chat/channel/channel_editor.dart b/lib/screens/chat/channel/channel_editor.dart index 95e1b3b..c8c13bd 100644 --- a/lib/screens/chat/channel/channel_editor.dart +++ b/lib/screens/chat/channel/channel_editor.dart @@ -15,9 +15,9 @@ import 'package:uuid/uuid.dart'; class ChannelEditorScreen extends StatefulWidget { final Channel? editing; - final String? realm; + final String realm; - const ChannelEditorScreen({super.key, this.editing, this.realm}); + const ChannelEditorScreen({super.key, this.editing, this.realm = 'global'}); @override State createState() => _ChannelEditorScreenState(); @@ -28,6 +28,8 @@ class _ChannelEditorScreenState extends State { final _nameController = TextEditingController(); final _descriptionController = TextEditingController(); + bool _isEncrypted = false; + bool _isSubmitting = false; Future applyChannel(BuildContext context) async { @@ -39,9 +41,10 @@ class _ChannelEditorScreenState extends State { return; } + final scope = widget.realm.isNotEmpty ? widget.realm : 'global'; final uri = widget.editing == null - ? getRequestUri('messaging', '/api/channels/${widget.realm ?? 'global'}') - : getRequestUri('messaging', '/api/channels/${widget.realm ?? 'global'}/${widget.editing!.id}'); + ? getRequestUri('messaging', '/api/channels/$scope') + : getRequestUri('messaging', '/api/channels/$scope/${widget.editing!.id}'); final req = Request(widget.editing == null ? 'POST' : 'PUT', uri); req.headers['Content-Type'] = 'application/json'; @@ -49,6 +52,7 @@ class _ChannelEditorScreenState extends State { 'alias': _aliasController.value.text.toLowerCase(), 'name': _nameController.value.text, 'description': _descriptionController.value.text, + 'is_encrypted': _isEncrypted, }); var res = await Response.fromStream(await auth.client!.send(req)); @@ -57,7 +61,7 @@ class _ChannelEditorScreenState extends State { context.showErrorDialog(message); } else { if (SolianRouter.router.canPop()) { - SolianRouter.router.pop(true); + SolianRouter.router.pop(_aliasController.value.text.toLowerCase()); } } setState(() => _isSubmitting = false); @@ -79,6 +83,7 @@ class _ChannelEditorScreenState extends State { _aliasController.text = widget.editing!.alias; _nameController.text = widget.editing!.name; _descriptionController.text = widget.editing!.description; + _isEncrypted = widget.editing!.isEncrypted; } super.initState(); @@ -177,6 +182,15 @@ class _ChannelEditorScreenState extends State { ), ), ), + const Divider(thickness: 0.3), + CheckboxListTile( + title: Text(AppLocalizations.of(context)!.chatChannelEncryptedLabel), + value: _isEncrypted, + onChanged: (widget.editing?.isEncrypted ?? false) ? null : (newValue) { + setState(() => _isEncrypted = newValue ?? false); + }, + controlAffinity: ListTileControlAffinity.leading, + ), ], ), ); diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index 954b4fe..73ac19c 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -255,6 +255,7 @@ class _ChatWidgetState extends State { channel: widget.alias, editing: _editingItem, replying: _replyingItem, + isEncrypted: _chat.focusChannel?.isEncrypted ?? false, onReset: () => setState(() { _editingItem = null; _replyingItem = null; diff --git a/lib/screens/chat/chat_detail.dart b/lib/screens/chat/chat_detail.dart index 2c55c71..e725ec7 100644 --- a/lib/screens/chat/chat_detail.dart +++ b/lib/screens/chat/chat_detail.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/chat.dart'; import 'package:solian/router.dart'; import 'package:solian/widgets/chat/channel_deletion.dart'; import 'package:solian/widgets/scaffold.dart'; @@ -23,11 +24,12 @@ class _ChatDetailScreenState extends State { void promptLeaveChannel() async { final did = await showDialog( context: context, - builder: (context) => ChannelDeletion( - channel: widget.channel, - realm: widget.realm, - isOwned: _isOwned, - ), + builder: (context) => + ChannelDeletion( + channel: widget.channel, + realm: widget.realm, + isOwned: _isOwned, + ), ); if (did == true && SolianRouter.router.canPop()) { SolianRouter.router.pop('disposed'); @@ -50,14 +52,23 @@ class _ChatDetailScreenState extends State { @override Widget build(BuildContext context) { + final auth = context.read(); + final chat = context.read(); + final authorizedItems = [ ListTile( leading: const Icon(Icons.settings), title: Text(AppLocalizations.of(context)!.settings), onTap: () async { - SolianRouter.router.pushNamed('chat.channel.editor', extra: widget.channel).then((did) { - if (did == true) { - if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh'); + SolianRouter.router + .pushNamed( + 'chat.channel.editor', + extra: widget.channel, + queryParameters: widget.realm != 'global' ? {'realm': widget.realm} : {}, + ) + .then((resp) { + if (resp != null) { + chat.fetchChannel(context, auth, resp as String, widget.realm); } }); }, @@ -81,8 +92,14 @@ class _ChatDetailScreenState extends State { const SizedBox(width: 16), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(widget.channel.name, style: Theme.of(context).textTheme.bodyLarge), - Text(widget.channel.description, style: Theme.of(context).textTheme.bodySmall), + Text(widget.channel.name, style: Theme + .of(context) + .textTheme + .bodyLarge), + Text(widget.channel.description, style: Theme + .of(context) + .textTheme + .bodySmall), ]), ) ], diff --git a/lib/screens/chat/chat_list.dart b/lib/screens/chat/chat_list.dart index e6fbf89..9681a6e 100644 --- a/lib/screens/chat/chat_list.dart +++ b/lib/screens/chat/chat_list.dart @@ -124,11 +124,10 @@ class _ChatListWidgetState extends State { title: Text(element.name), subtitle: Text(element.description), onTap: () async { - String? result; if (['chat.channel', 'realms.chat.channel'].contains(SolianRouter.currentRoute.name)) { chat.fetchChannel(context, auth, element.alias, widget.realm!); } else { - result = await SolianRouter.router.pushNamed( + SolianRouter.router.pushNamed( widget.realm == null ? 'chat.channel' : 'realms.chat.channel', pathParameters: { 'channel': element.alias, @@ -136,10 +135,6 @@ class _ChatListWidgetState extends State { }, ); } - switch (result) { - case 'refresh': - fetchChannels(); - } }, ); }, diff --git a/lib/screens/notification.dart b/lib/screens/notification.dart index bde9628..91e82b7 100644 --- a/lib/screens/notification.dart +++ b/lib/screens/notification.dart @@ -37,7 +37,7 @@ class _NotificationScreenState extends State { markList.add(element.id); } - nty.clearRealtime(); + nty.clearRealtimeNotifications(); if(markList.isNotEmpty) { var uri = getRequestUri('passport', '/api/notifications/batch/read'); diff --git a/lib/widgets/chat/message_content.dart b/lib/widgets/chat/message_content.dart index 101bd06..8ded4d5 100644 --- a/lib/widgets/chat/message_content.dart +++ b/lib/widgets/chat/message_content.dart @@ -1,18 +1,97 @@ import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:provider/provider.dart'; import 'package:solian/models/message.dart'; +import 'package:solian/providers/keypair.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -class ChatMessageContent extends StatelessWidget { +class ChatMessageContent extends StatefulWidget { final Message item; const ChatMessageContent({super.key, required this.item}); + @override + State createState() => _ChatMessageContentState(); +} + +class _ChatMessageContentState extends State { @override Widget build(BuildContext context) { - if (item.type == 'm.text') { + final feColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.65); + + final waitingKeyHint = Row( + children: [ + Icon(Icons.key, color: feColor, size: 16), + const SizedBox(width: 4), + Expanded( + child: Text( + AppLocalizations.of(context)!.chatMessageUnableDecryptWaiting, + style: TextStyle(color: feColor), + ), + ), + ], + ); + final missingKeyHint = Row( + children: [ + Icon(Icons.key_off_outlined, color: feColor, size: 16), + const SizedBox(width: 4), + Expanded( + child: Text( + AppLocalizations.of(context)!.chatMessageUnableDecryptMissing, + style: TextStyle(color: feColor), + ), + ), + ], + ); + + if (widget.item.type == 'm.text') { + String? content; + switch (widget.item.decodedContent['algorithm']) { + case 'plain': + content = widget.item.decodedContent['value']; + case 'aes': + final keypair = context.watch(); + if (keypair.keys[widget.item.decodedContent['keypair_id']] == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (keypair.requestKey( + widget.item.decodedContent['keypair_id'], + widget.item.decodedContent['algorithm'], + widget.item.sender.account.externalId!, + )) { + } + }); + } else { + content = keypair.decodeViaAESKey( + widget.item.decodedContent['keypair_id'], + widget.item.decodedContent['value'], + )!; + break; + } + + if (keypair.requestingKeys.contains(widget.item.decodedContent['keypair_id'])) { + return waitingKeyHint.animate().swap(builder: (context, _) { + return missingKeyHint; + }, delay: 3000.ms); + } + } + + if (content == null) { + return Row( + children: [ + Icon(Icons.key_off, color: feColor, size: 16), + const SizedBox(width: 4), + Text( + AppLocalizations.of(context)!.chatMessageUnableDecryptUnsupported, + style: TextStyle(color: feColor), + ), + ], + ); + } + return Markdown( - data: item.decodedContent['value'], + data: content, shrinkWrap: true, selectable: true, physics: const NeverScrollableScrollPhysics(), diff --git a/lib/widgets/chat/message_editor.dart b/lib/widgets/chat/message_editor.dart index 3f62faf..7df27bf 100644 --- a/lib/widgets/chat/message_editor.dart +++ b/lib/widgets/chat/message_editor.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import 'package:solian/models/message.dart'; import 'package:solian/models/post.dart'; import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/keypair.dart'; import 'package:solian/utils/services_url.dart'; import 'package:solian/widgets/exts.dart'; import 'package:solian/widgets/posts/attachment_editor.dart'; @@ -19,11 +20,13 @@ class ChatMessageEditor extends StatefulWidget { final String realm; final Message? editing; final Message? replying; + final bool isEncrypted; final Function? onReset; const ChatMessageEditor({ super.key, required this.channel, + required this.isEncrypted, this.realm = 'global', this.editing, this.replying, @@ -55,8 +58,19 @@ class _ChatMessageEditorState extends State { ); } - Map buildContentBody(String content, {String algorithm = 'plain'}) { - return {'value': content, 'algorithm': algorithm}; + Map buildContentBody(String content) { + final keypair = context.read(); + if (keypair.activeKeyId == null || keypair.keys[keypair.activeKeyId] == null) { + final kp = keypair.generateAESKey(); + keypair.setActiveKey(kp.id); + return buildContentBody(content); + } + + return { + 'value': widget.isEncrypted ? keypair.encodeViaAESKey(keypair.activeKeyId!, content) : content, + 'keypair_id': widget.isEncrypted ? keypair.activeKeyId : null, + 'algorithm': widget.isEncrypted ? 'aes' : 'plain', + }; } Future sendMessage(BuildContext context) async { @@ -214,7 +228,9 @@ class _ChatMessageEditorState extends State { autocorrect: true, keyboardType: TextInputType.text, decoration: InputDecoration.collapsed( - hintText: AppLocalizations.of(context)!.chatMessagePlaceholder, + hintText: widget.isEncrypted + ? AppLocalizations.of(context)!.chatMessageEncryptedPlaceholder + : AppLocalizations.of(context)!.chatMessagePlaceholder, ), onSubmitted: (_) => sendMessage(context), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), diff --git a/lib/widgets/notification_notifier.dart b/lib/widgets/notification_notifier.dart index 6df0c13..d5af2fb 100644 --- a/lib/widgets/notification_notifier.dart +++ b/lib/widgets/notification_notifier.dart @@ -1,63 +1,12 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:solian/models/packet.dart'; import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/keypair.dart'; import 'package:solian/providers/notify.dart'; import 'package:solian/router.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:solian/models/notification.dart' as model; import 'package:badges/badges.dart' as badge; -class NotificationNotifier extends StatefulWidget { - final Widget child; - - const NotificationNotifier({super.key, required this.child}); - - @override - State createState() => _NotificationNotifierState(); -} - -class _NotificationNotifierState extends State { - void connect() async { - final notify = ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.connectingServer), - duration: const Duration(minutes: 1), - ), - ); - - final auth = context.read(); - final nty = context.read(); - - if (await auth.isAuthorized()) { - if (auth.client == null) { - await auth.loadClient(); - } - - nty.fetch(auth); - nty.connect(auth); - } - - notify.close(); - } - - @override - void initState() { - Future.delayed(Duration.zero, () { - connect(); - }); - - super.initState(); - } - - @override - Widget build(BuildContext context) { - return widget.child; - } -} - class NotificationButton extends StatefulWidget { const NotificationButton({super.key}); @@ -66,6 +15,43 @@ class NotificationButton extends StatefulWidget { } class _NotificationButtonState extends State { + void connect() async { + final auth = context.read(); + final nty = context.read(); + final keypair = context.read(); + + if (nty.isOpened) return; + + final notify = ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.connectingServer), + duration: const Duration(minutes: 1), + ), + ); + + if (await auth.isAuthorized()) { + if (auth.client == null) { + await auth.loadClient(); + } + + nty.fetch(auth); + keypair.channel = await nty.connect( + auth, + onKexRequest: keypair.provideKeypair, + onKexProvide: keypair.receiveKeypair, + ); + } + + notify.close(); + } + + @override + void initState() { + super.initState(); + + Future.delayed(Duration.zero, () => connect()); + } + @override Widget build(BuildContext context) { final nty = context.watch(); diff --git a/pubspec.lock b/pubspec.lock index ab4ea8a..3ff5a78 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: asn1lib - sha256: c9c85fedbe2188b95133cbe960e16f5f448860f7133330e272edbbca5893ddc6 + sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" async: dependency: transitive description: @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + basic_utils: + dependency: "direct main" + description: + name: basic_utils + sha256: "2064b21d3c41ed7654bc82cc476fd65542e04d60059b74d5eed490a4da08fc6c" + url: "https://pub.dev" + source: hosted + version: "5.7.0" boolean_selector: dependency: transitive description: @@ -985,10 +993,10 @@ packages: dependency: transitive description: name: pointycastle - sha256: "79fbafed02cfdbe85ef3fd06c7f4bc2cbcba0177e61b765264853d4253b21744" + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" url: "https://pub.dev" source: hosted - version: "3.9.0" + version: "3.9.1" protobuf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 52cdf7d..87ed6ab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,8 +72,9 @@ dependencies: cached_network_image: ^3.3.1 desktop_drop: ^0.4.4 easy_debounce: ^2.0.3 - encrypt: ^5.0.3 google_fonts: ^6.2.1 + basic_utils: ^5.7.0 + encrypt: ^5.0.3 dev_dependencies: flutter_test: