E2EE and Keypair

This commit is contained in:
LittleSheep 2024-05-12 20:15:12 +08:00
parent 08d0a99b10
commit 98547708af
20 changed files with 665 additions and 115 deletions

View File

@ -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"
}

View File

@ -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": "无法解密信息,缺失解密密钥"
}

View File

@ -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(),
),
);
}),

View File

@ -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<String, dynamic> toJson() => {
@ -55,6 +58,7 @@ class Channel {
'account': account,
'account_id': accountId,
'realm_id': realmId,
'is_encrypted': isEncrypted,
};
}

32
lib/models/keypair.dart Normal file
View File

@ -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<String, dynamic> json) => Keypair(
id: json['id'],
algorithm: json['algorithm'],
publicKey: json['public_key'],
privateKey: json['private_key'],
isOwned: json['is_owned'],
);
Map<String, dynamic> toJson() => {
'id': id,
'algorithm': algorithm,
'public_key': publicKey,
'private_key': privateKey,
'is_owned': isOwned,
};
}

146
lib/providers/keypair.dart Normal file
View File

@ -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~><Ae?YrJd)D';
WebSocketChannel? channel;
String? activeKeyId;
Map<String, Keypair> keys = {};
List<String> 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<int>.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;
}
}

View File

@ -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<void> connect(AuthProvider auth) async {
Future<WebSocketChannel?> 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();
}

View File

@ -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(

View File

@ -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<AccountScreenWidget> {
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
final keypair = context.read<KeypairProvider>();
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<AccountScreenWidget> {
padding: EdgeInsets.only(top: 16, bottom: 8, left: 24, right: 24),
child: NameCard(),
),
ListTile(
...(actionItems.map(
(x) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.color_lens),
title: Text(AppLocalizations.of(context)!.personalize),
leading: x.$1,
title: Text(x.$2),
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');
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;
});

View File

@ -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: <Widget>[
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<KeypairProvider>().importKeys(input);
}
void exportKeys(BuildContext context) {
showModalBottomSheet(context: context, builder: (context) => const KeypairExportWidget());
}
@override
Widget build(BuildContext context) {
final keypair = context.watch<KeypairProvider>();
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<KeypairProvider>();
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),
),
),
],
),
),
);
}
}

View File

@ -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<ChannelEditorScreen> createState() => _ChannelEditorScreenState();
@ -28,6 +28,8 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isEncrypted = false;
bool _isSubmitting = false;
Future<void> applyChannel(BuildContext context) async {
@ -39,9 +41,10 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
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<ChannelEditorScreen> {
'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<ChannelEditorScreen> {
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<ChannelEditorScreen> {
_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<ChannelEditorScreen> {
),
),
),
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,
),
],
),
);

View File

@ -255,6 +255,7 @@ class _ChatWidgetState extends State<ChatWidget> {
channel: widget.alias,
editing: _editingItem,
replying: _replyingItem,
isEncrypted: _chat.focusChannel?.isEncrypted ?? false,
onReset: () => setState(() {
_editingItem = null;
_replyingItem = null;

View File

@ -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,7 +24,8 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
void promptLeaveChannel() async {
final did = await showDialog(
context: context,
builder: (context) => ChannelDeletion(
builder: (context) =>
ChannelDeletion(
channel: widget.channel,
realm: widget.realm,
isOwned: _isOwned,
@ -50,14 +52,23 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final chat = context.read<ChatProvider>();
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<ChatDetailScreen> {
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),
]),
)
],

View File

@ -124,11 +124,10 @@ class _ChatListWidgetState extends State<ChatListWidget> {
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<ChatListWidget> {
},
);
}
switch (result) {
case 'refresh':
fetchChannels();
}
},
);
},

View File

@ -37,7 +37,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
markList.add(element.id);
}
nty.clearRealtime();
nty.clearRealtimeNotifications();
if(markList.isNotEmpty) {
var uri = getRequestUri('passport', '/api/notifications/batch/read');

View File

@ -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<ChatMessageContent> createState() => _ChatMessageContentState();
}
class _ChatMessageContentState extends State<ChatMessageContent> {
@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<KeypairProvider>();
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(),

View File

@ -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<ChatMessageEditor> {
);
}
Map<String, dynamic> buildContentBody(String content, {String algorithm = 'plain'}) {
return {'value': content, 'algorithm': algorithm};
Map<String, dynamic> buildContentBody(String content) {
final keypair = context.read<KeypairProvider>();
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<void> sendMessage(BuildContext context) async {
@ -214,7 +228,9 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
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(),

View File

@ -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<NotificationNotifier> createState() => _NotificationNotifierState();
}
class _NotificationNotifierState extends State<NotificationNotifier> {
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<AuthProvider>();
final nty = context.read<NotifyProvider>();
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<NotificationButton> {
void connect() async {
final auth = context.read<AuthProvider>();
final nty = context.read<NotifyProvider>();
final keypair = context.read<KeypairProvider>();
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<NotifyProvider>();

View File

@ -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:

View File

@ -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: