Compare commits

...

3 Commits

18 changed files with 200 additions and 43 deletions

View File

@ -132,7 +132,8 @@
"addToContactsBody": "Are you sure you want to add ${jid} to your contacts?",
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
"stickerSettings": "Sticker settings"
"stickerSettings": "Sticker settings",
"newDeviceMessage": "${title} added a new encryption device"
},
"addcontact": {
"title": "Add new contact",

View File

@ -132,7 +132,8 @@
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
"stickerSettings": "Stickereinstellungen"
"stickerSettings": "Stickereinstellungen",
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt"
},
"addcontact": {
"title": "Neuen Kontakt hinzufügen",

View File

@ -4,7 +4,6 @@ import 'package:cryptography/cryptography.dart';
import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/preferences.dart';
@ -28,14 +27,22 @@ String _cleanBase64String(String original) {
class _AvatarData {
const _AvatarData(this.data, this.id);
final String data;
final List<int> data;
final String id;
}
class AvatarService {
final Logger _log = Logger('AvatarService');
Future<void> updateAvatarForJid(String jid, String hash, String base64) async {
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
await updateAvatarForJid(
event.jid,
event.hash,
base64Decode(_cleanBase64String(event.base64)),
);
}
Future<void> updateAvatarForJid(String jid, String hash, List<int> data) async {
final cs = GetIt.I.get<ConversationService>();
final rs = GetIt.I.get<RosterService>();
final originalConversation = await cs.getConversationByJid(jid);
@ -43,10 +50,9 @@ class AvatarService {
// Clean the raw data. Since this may arrive by chunks, those chunks may contain
// weird data pieces.
final base64Data = base64Decode(_cleanBase64String(base64));
if (originalConversation != null) {
final avatarPath = await saveAvatarInCache(
base64Data,
data,
hash,
jid,
originalConversation.avatarUrl,
@ -69,7 +75,7 @@ class AvatarService {
avatarPath = await getAvatarPath(jid, hash);
} else {
avatarPath = await saveAvatarInCache(
base64Data,
data,
hash,
jid,
originalRoster.avatarUrl,
@ -105,7 +111,7 @@ class AvatarService {
final avatar = avatarResult.get<UserAvatar>();
return _AvatarData(
avatar.base64,
base64Decode(_cleanBase64String(avatar.base64)),
avatar.hash,
);
}
@ -119,14 +125,15 @@ class AvatarService {
final binval = vcardResult.get<VCard>().photo?.binval;
if (binval == null) return null;
final rawHash = await Sha1().hash(base64Decode(binval));
final data = base64Decode(_cleanBase64String(binval));
final rawHash = await Sha1().hash(data);
final hash = HEX.encode(rawHash.bytes);
vm.setLastHash(jid, hash);
return _AvatarData(
binval,
data,
hash,
);
}

View File

@ -57,6 +57,8 @@ Future<void> createDatabase(Database db, int version) async {
containsNoStore INTEGER NOT NULL,
stickerPackId TEXT,
stickerHashKey TEXT,
pseudoMessageType INTEGER,
pseudoMessageData TEXT,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id),
)''',
);

View File

@ -18,6 +18,7 @@ import 'package:moxxyv2/service/database/migrations/0000_conversations3.dart';
import 'package:moxxyv2/service/database/migrations/0000_language.dart';
import 'package:moxxyv2/service/database/migrations/0000_lmc.dart';
import 'package:moxxyv2/service/database/migrations/0000_omemo_fingerprint_cache.dart';
import 'package:moxxyv2/service/database/migrations/0000_pseudo_messages.dart';
import 'package:moxxyv2/service/database/migrations/0000_reactions.dart';
import 'package:moxxyv2/service/database/migrations/0000_reactions_store_hint.dart';
import 'package:moxxyv2/service/database/migrations/0000_retraction.dart';
@ -80,7 +81,7 @@ class DatabaseService {
_db = await openDatabase(
dbPath,
password: key,
version: 23,
version: 24,
onCreate: createDatabase,
onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign
@ -181,6 +182,10 @@ class DatabaseService {
_log.finest('Running migration for database version 23');
await upgradeFromV22ToV23(db);
}
if (oldVersion < 24) {
_log.finest('Running migration for database version 24');
await upgradeFromV23ToV24(db);
}
},
);
@ -428,6 +433,8 @@ class DatabaseService {
int? mediaSize,
String? stickerPackId,
String? stickerHashKey,
int? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData,
}
) async {
var m = Message(
@ -464,6 +471,8 @@ class DatabaseService {
mediaSize: mediaSize,
stickerPackId: stickerPackId,
stickerHashKey: stickerHashKey,
pseudoMessageType: pseudoMessageType,
pseudoMessageData: pseudoMessageData,
);
if (quoteId != null) {

View File

@ -0,0 +1,12 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV23ToV24(Database db) async {
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN pseudoMessageType INTEGER;',
);
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN pseudoMessageData TEXT;',
);
}

View File

@ -66,6 +66,8 @@ class MessageService {
int? mediaSize,
String? stickerPackId,
String? stickerHashKey,
int? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData,
}
) async {
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
@ -99,6 +101,8 @@ class MessageService {
mediaSize: mediaSize,
stickerPackId: stickerPackId,
stickerHashKey: stickerHashKey,
pseudoMessageType: pseudoMessageType,
pseudoMessageData: pseudoMessageData,
);
// Only update the cache if the conversation already has been loaded. This prevents

View File

@ -6,9 +6,14 @@ import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
import 'package:moxxyv2/service/omemo/implementations.dart';
import 'package:moxxyv2/service/omemo/types.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
import 'package:omemo_dart/omemo_dart.dart';
import 'package:synchronized/synchronized.dart';
@ -90,6 +95,8 @@ class OmemoService {
if (_fingerprintCache.containsKey(event.jid)) {
_fingerprintCache[event.jid]![event.deviceId] = fingerprint;
}
await addNewDeviceMessage(event.jid, event.deviceId);
}
} else if (event is DeviceListModifiedEvent) {
await commitDeviceMap(event.list);
@ -113,6 +120,37 @@ class OmemoService {
});
}
/// Adds a pseudo message saying that [jid] added a new device with id [deviceId].
/// If, however, [jid] is our own JID, then nothing is done.
Future<void> addNewDeviceMessage(String jid, int deviceId) async {
// Add a pseudo message if it is not about our own devices
final xmppState = await GetIt.I.get<XmppService>().getXmppState();
if (jid == xmppState.jid) return;
final ms = GetIt.I.get<MessageService>();
final message = await ms.addMessageFromData(
'',
DateTime.now().millisecondsSinceEpoch,
'',
jid,
false,
'',
false,
false,
false,
pseudoMessageType: pseudoMessageTypeNewDevice,
pseudoMessageData: <String, dynamic>{
'deviceId': deviceId,
'jid': jid,
},
);
sendEvent(
MessageAddedEvent(
message: message,
),
);
}
Future<model.OmemoDevice> regenerateDevice(String jid) async {
// Prevent access to the session manager as it is (mostly) guarded ensureInitialized
await _lock.synchronized(() {

View File

@ -1405,11 +1405,7 @@ class XmppService {
}
Future<void> _onAvatarUpdated(AvatarUpdatedEvent event, { dynamic extra }) async {
await GetIt.I.get<AvatarService>().updateAvatarForJid(
event.jid,
event.hash,
event.base64,
);
await GetIt.I.get<AvatarService>().handleAvatarUpdate(event);
}
Future<void> _onStanzaAcked(StanzaAckedEvent event, { dynamic extra }) async {

View File

@ -9,18 +9,33 @@ import 'package:moxxyv2/shared/warning_types.dart';
part 'message.freezed.dart';
part 'message.g.dart';
const pseudoMessageTypeNewDevice = 1;
Map<String, String>? _optionalJsonDecode(String? data) {
if (data == null) return null;
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, String>();
}
String? _optionalJsonEncode(Map<String, String>? data) {
Map<String, dynamic> _optionalJsonDecodeWithFallback(String? data) {
if (data == null) return <String, dynamic>{};
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, dynamic>();
}
String? _optionalJsonEncode(Map<String, dynamic>? data) {
if (data == null) return null;
return jsonEncode(data);
}
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
if (data == null) return null;
if (data.isEmpty) return null;
return jsonEncode(data);
}
@freezed
class Message with _$Message {
factory Message(
@ -66,6 +81,8 @@ class Message with _$Message {
@Default([]) List<Reaction> reactions,
String? stickerPackId,
String? stickerHashKey,
int? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData,
}
) = _Message;
@ -91,6 +108,7 @@ class Message with _$Message {
'isEdited': intToBool(json['isEdited']! as int),
'containsNoStore': intToBool(json['containsNoStore']! as int),
'reactions': <Map<String, dynamic>>[],
'pseudoMessageData': _optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
}).copyWith(
quotes: quotes,
reactions: (jsonDecode(json['reactions']! as String) as List<dynamic>)
@ -104,7 +122,8 @@ class Message with _$Message {
final map = toJson()
..remove('id')
..remove('quotes')
..remove('reactions');
..remove('reactions')
..remove('pseudoMessageData');
return {
...map,
@ -128,6 +147,7 @@ class Message with _$Message {
.map((r) => r.toJson())
.toList(),
),
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
};
}
@ -143,27 +163,30 @@ class Message with _$Message {
return mimeTypeToEmoji(mediaType, addTypeName: false);
}
/// True if the message is a pseudo message.
bool get isPseudoMessage => pseudoMessageType != null && pseudoMessageData != null;
/// Returns true if the message can be quoted. False if not.
bool get isQuotable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading;
bool get isQuotable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
/// Returns true if the message can be retracted. False if not.
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
bool canRetract(bool sentBySelf) {
return originId != null && sentBySelf && !isFileUploadNotification && !isUploading && !isDownloading;
return originId != null && sentBySelf && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
}
/// Returns true if we can send a reaction for this message.
bool get isReactable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading;
bool get isReactable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
/// Returns true if the message can be edited. False if not.
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
bool canEdit(bool sentBySelf) {
return sentBySelf && !isMedia && !isFileUploadNotification && !isUploading && !isDownloading;
return sentBySelf && !isMedia && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
}
/// Returns true if the message can open the selection menu by longpressing. False if
/// not.
bool get isLongpressable => !isRetracted;
bool get isLongpressable => !isRetracted && !isPseudoMessage;
/// Returns true if the menu item to show the error should be shown in the
/// longpress menu.
@ -176,14 +199,14 @@ class Message with _$Message {
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or
/// images.
bool get isThumbnailable => isMedia && mediaType != null && (
bool get isThumbnailable => !isPseudoMessage && isMedia && mediaType != null && (
mediaType!.startsWith('image/') ||
mediaType!.startsWith('video/')
);
/// Returns true if the message can be copied to the clipboard.
bool get isCopyable => !isMedia && body.isNotEmpty;
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
/// Returns true if the message is a sticker
bool get isSticker => isMedia && stickerPackId != null && stickerHashKey != null;
bool get isSticker => isMedia && stickerPackId != null && stickerHashKey != null && !isPseudoMessage;
}

View File

@ -14,7 +14,6 @@ part 'devices_event.dart';
part 'devices_state.dart';
class DevicesBloc extends Bloc<DevicesEvent, DevicesState> {
DevicesBloc() : super(DevicesState()) {
on<DevicesRequestedEvent>(_onRequested);
on<DeviceEnabledSetEvent>(_onDeviceEnabledSet);

View File

@ -4,14 +4,12 @@ abstract class DevicesEvent {}
/// Triggered when the user requested the key page
class DevicesRequestedEvent extends DevicesEvent {
DevicesRequestedEvent(this.jid);
final String jid;
}
/// Triggered by the UI when we want to enable or disable a key
class DeviceEnabledSetEvent extends DevicesEvent {
DeviceEnabledSetEvent(this.deviceId, this.enabled);
final int deviceId;
final bool enabled;

View File

@ -19,6 +19,7 @@ import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
import 'package:moxxyv2/ui/pages/conversation/topbar.dart';
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
import 'package:moxxyv2/ui/widgets/chat/datebubble.dart';
import 'package:moxxyv2/ui/widgets/chat/media/new_device.dart';
import 'package:moxxyv2/ui/widgets/overview_menu.dart';
class ConversationPage extends StatefulWidget {
@ -136,11 +137,28 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
return const SizedBox();
}
// TODO(Unknown): Since we reverse the list: Fix start, end and between
final index = state.messages.length - 1 - (_index - 1) ~/ 2;
final item = state.messages[index];
if (item.isPseudoMessage) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: maxWidth,
),
child: NewDeviceBubble(
data: item.pseudoMessageData!,
title: state.conversation!.title,
),
),
],
);
}
final start = index - 1 < 0 ?
true :
isSent(state.messages[index - 1], jid) != isSent(item, jid);

View File

@ -46,6 +46,11 @@ class RawChatBubble extends StatelessWidget {
isInlinedWidget = message.mediaType!.startsWith('image/');
}
// Check if it is a pseudo message
if (message.isPseudoMessage) {
return true;
}
// Check if it is an embedded file
if (message.isMedia && message.mediaUrl != null && isInlinedWidget) {
return true;

View File

@ -26,7 +26,7 @@ enum MessageType {
video,
audio,
file,
sticker
sticker,
}
/// Deduce the type of message we are dealing with to pick the correct
@ -72,7 +72,9 @@ Widget buildMessageWidget(Message message, double maxWidth, BorderRadius radius,
return TextChatWidget(
message,
sent,
topWidget: message.quotes != null ? buildQuoteMessageWidget(message.quotes!, sent) : null,
topWidget: message.quotes != null ?
buildQuoteMessageWidget(message.quotes!, sent) :
null,
);
}
case MessageType.image:
@ -83,13 +85,8 @@ Widget buildMessageWidget(Message message, double maxWidth, BorderRadius radius,
return StickerChatWidget(message, radius, maxWidth, sent);
case MessageType.audio:
return AudioChatWidget(message, radius, maxWidth, sent);
case MessageType.file: {
case MessageType.file:
return FileChatWidget(message, radius, maxWidth, sent);
/*return TextChatWidget(
message,
sent,
);*/
}
}
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/devices_bloc.dart';
class NewDeviceBubble extends StatelessWidget {
const NewDeviceBubble({
required this.data,
required this.title,
super.key,
});
final Map<String, dynamic> data;
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: InkWell(
onTap: () {
context.read<DevicesBloc>().add(
DevicesRequestedEvent(data['jid']! as String),
);
},
child: ColoredBox(
color: const Color(0xffeee8d5),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
child: Text(
t.pages.conversation.newDeviceMessage(title: title),
style: const TextStyle(
color: Colors.black,
),
textAlign: TextAlign.center,
),
),
),
),
),
);
}
}

View File

@ -888,7 +888,7 @@ packages:
name: omemo_dart
url: "https://git.polynom.me/api/packages/PapaTutuWawa/pub/"
source: hosted
version: "0.4.0"
version: "0.4.1"
package_config:
dependency: transitive
description:

View File

@ -75,7 +75,7 @@ dependencies:
native_imaging: 0.1.0
omemo_dart:
hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub
version: 0.4.0
version: 0.4.1
page_transition: 2.0.9
path: 1.8.2
path_provider: 2.0.11
@ -140,7 +140,7 @@ dependency_overrides:
moxxmpp:
git:
url: https://git.polynom.me/Moxxy/moxxmpp.git
rev: 62001c1e29b644fcf7fe12618d77571853fd073e
rev: 596693c2067bc3fe73250f07cd88e7040a285537
path: packages/moxxmpp
extra_licenses: