Merge pull request 'Improve avatar and presence handling' (#285) from feat/better-avatar-handling into master

Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/285
This commit is contained in:
PapaTutuWawa 2023-06-10 22:20:23 +00:00
commit 20489fbb25
42 changed files with 940 additions and 654 deletions

View File

@ -208,7 +208,7 @@ files:
attributes:
conversationJid: String
title: String
avatarUrl: String
avatarPath: String
- name: StickerPackImportSuccessEvent
extends: BackgroundEvent
implements:
@ -274,6 +274,13 @@ files:
reactions:
type: List<ReactionGroup>
deserialise: true
# Triggered when the stream negotiations have been completed
- name: StreamNegotiationsCompletedEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
resumed: bool
generate_builder: true
builder_name: "Event"
builder_baseclass: "BackgroundEvent"
@ -576,6 +583,20 @@ files:
- JsonImplementation
attributes:
messageId: int
- name: RequestAvatarForJidCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
jid: String
hash: String?
ownAvatar: bool
- name: DebugCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
id: int
generate_builder: true
# get${builder_Name}FromJson
builder_name: "Command"

View File

@ -63,6 +63,7 @@ import 'package:moxxyv2/ui/pages/share_selection.dart';
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
import 'package:moxxyv2/ui/service/avatars.dart';
import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/service/progress.dart';
import 'package:moxxyv2/ui/service/sharing.dart';
@ -83,6 +84,7 @@ void setupLogging() {
Future<void> setupUIServices() async {
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
GetIt.I.registerSingleton<UIDataService>(UIDataService());
GetIt.I.registerSingleton<UIAvatarsService>(UIAvatarsService());
GetIt.I.registerSingleton<UISharingService>(UISharingService());
}

View File

@ -1,8 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:cryptography/cryptography.dart';
import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart';
@ -14,60 +12,100 @@ import 'package:moxxyv2/shared/avatar.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
/// Removes line breaks and spaces from [original]. This might happen when we request the
/// avatar data. Returns the cleaned version.
String _cleanBase64String(String original) {
var ret = original;
for (final char in ['\n', ' ']) {
ret = ret.replaceAll(char, '');
}
return ret;
}
class _AvatarData {
const _AvatarData(this.data, this.id);
final List<int> data;
final String id;
}
class AvatarService {
final Logger _log = Logger('AvatarService');
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
await updateAvatarForJid(
event.jid,
event.hash,
base64Decode(_cleanBase64String(event.base64)),
);
/// List of JIDs for which we have already requested the avatar in the current stream.
final List<JID> _requestedInStream = [];
void resetCache() {
_requestedInStream.clear();
}
Future<void> updateAvatarForJid(
String jid,
Future<bool> _fetchAvatarForJid(JID jid, String hash) async {
final conn = GetIt.I.get<XmppConnection>();
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
final rawAvatar = await am.getUserAvatar(jid);
if (rawAvatar.isType<AvatarError>()) {
_log.warning('Failed to request avatar for $jid');
return false;
}
final avatar = rawAvatar.get<UserAvatarData>();
await _updateAvatarForJid(
jid,
avatar.hash,
avatar.data,
);
return true;
}
/// Requests the avatar for [jid]. [oldHash], if given, is the last SHA-1 hash of the known avatar.
/// If the avatar for [jid] has already been requested in this stream session, does nothing. Otherwise,
/// requests the XEP-0084 metadata and queries the new avatar only if the queried SHA-1 != [oldHash].
///
/// Returns true, if everything went okay. Returns false if an error occurred.
Future<bool> requestAvatar(JID jid, String? oldHash) async {
if (_requestedInStream.contains(jid)) {
return true;
}
_requestedInStream.add(jid);
final conn = GetIt.I.get<XmppConnection>();
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
final rawId = await am.getAvatarId(jid);
if (rawId.isType<AvatarError>()) {
_log.finest(
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
);
return false;
}
final id = rawId.get<String>();
if (id == oldHash) {
_log.finest('Not fetching avatar for $jid since the hashes are equal');
return true;
}
return _fetchAvatarForJid(jid, id);
}
Future<void> handleAvatarUpdate(UserAvatarUpdatedEvent event) async {
if (event.metadata.isEmpty) return;
// TODO(Unknown): Maybe make a better decision?
await _fetchAvatarForJid(event.jid, event.metadata.first.id);
}
/// Updates the avatar path and hash for the conversation and/or roster item with jid [JID].
/// [hash] is the new hash of the avatar. [data] is the raw avatar data.
Future<void> _updateAvatarForJid(
JID 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);
final originalRoster = await rs.getRosterItemByJid(jid);
final originalConversation = await cs.getConversationByJid(jid.toString());
final originalRoster = await rs.getRosterItemByJid(jid.toString());
if (originalConversation == null && originalRoster == null) return;
final avatarPath = await saveAvatarInCache(
data,
hash,
jid,
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
jid.toString(),
(originalConversation?.avatarPath ?? originalRoster?.avatarPath)!,
);
if (originalConversation != null) {
final conversation = await cs.createOrUpdateConversation(
jid,
jid.toString(),
update: (c) async {
return cs.updateConversation(
jid,
avatarUrl: avatarPath,
jid.toString(),
avatarPath: avatarPath,
avatarHash: hash,
);
},
);
@ -81,7 +119,7 @@ class AvatarService {
if (originalRoster != null) {
final roster = await rs.updateRosterItem(
originalRoster.id,
avatarUrl: avatarPath,
avatarPath: avatarPath,
avatarHash: hash,
);
@ -89,80 +127,6 @@ class AvatarService {
}
}
Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async {
final am = GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!;
final idResult = await am.getAvatarId(JID.fromString(jid));
if (idResult.isType<AvatarError>()) {
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
return null;
}
final id = idResult.get<String>();
if (id == oldHash) return null;
final avatarResult = await am.getUserAvatar(jid);
if (avatarResult.isType<AvatarError>()) {
_log.warning('Failed to get avatar data via XEP-0084 for $jid');
return null;
}
final avatar = avatarResult.get<UserAvatar>();
return _AvatarData(
base64Decode(_cleanBase64String(avatar.base64)),
avatar.hash,
);
}
Future<_AvatarData?> _handleVcardAvatar(String jid, String oldHash) async {
// Query the vCard
final vm = GetIt.I
.get<XmppConnection>()
.getManagerById<VCardManager>(vcardManager)!;
final vcardResult = await vm.requestVCard(jid);
if (vcardResult.isType<VCardError>()) return null;
final binval = vcardResult.get<VCard>().photo?.binval;
if (binval == null) return null;
final data = base64Decode(_cleanBase64String(binval));
final rawHash = await Sha1().hash(data);
final hash = HEX.encode(rawHash.bytes);
vm.setLastHash(jid, hash);
return _AvatarData(
data,
hash,
);
}
Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
_AvatarData? data;
data ??= await _handleUserAvatar(jid, oldHash);
data ??= await _handleVcardAvatar(jid, oldHash);
if (data != null) {
await updateAvatarForJid(jid, data.id, data.data);
}
}
Future<bool> subscribeJid(String jid) async {
return (await GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!
.subscribe(jid))
.isType<bool>();
}
Future<bool> unsubscribeJid(String jid) async {
return (await GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!
.unsubscribe(jid))
.isType<bool>();
}
/// Publishes the data at [path] as an avatar with PubSub ID
/// [hash]. [hash] must be the hex-encoded version of the SHA-1 hash
/// of the avatar data.
@ -201,6 +165,7 @@ class AvatarService {
imageSize.height.toInt(),
// TODO(PapaTutuWawa): Maybe do a check here
'image/png',
null,
),
public,
);
@ -213,38 +178,44 @@ class AvatarService {
return true;
}
/// Like [requestAvatar], but fetches and processes the avatar for our own account.
Future<void> requestOwnAvatar() async {
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.getXmppState();
final jid = JID.fromString(state.jid!);
if (_requestedInStream.contains(jid)) {
return;
}
_requestedInStream.add(jid);
final am = GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!;
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.getXmppState();
final jid = state.jid!;
final idResult = await am.getAvatarId(JID.fromString(jid));
if (idResult.isType<AvatarError>()) {
_log.info('Error while getting latest avatar id for own avatar');
return;
}
final id = idResult.get<String>();
if (id == state.avatarHash) return;
_log.info(
'Mismatch between saved avatar data and server-side avatar data about ourself',
final rawId = await am.getAvatarId(jid);
if (rawId.isType<AvatarError>()) {
_log.finest(
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
);
final avatarDataResult = await am.getUserAvatar(jid);
if (avatarDataResult.isType<AvatarError>()) {
_log.severe('Failed to fetch our avatar');
return;
}
final avatarData = avatarDataResult.get<UserAvatar>();
final id = rawId.get<String>();
_log.info('Received data for our own avatar');
if (id == state.avatarHash) {
_log.finest('Not fetching avatar for $jid since the hashes are equal');
return;
}
final rawAvatar = await am.getUserAvatar(jid);
if (rawAvatar.isType<AvatarError>()) {
_log.warning('Failed to request avatar for $jid');
return;
}
final avatarData = rawAvatar.get<UserAvatarData>();
final avatarPath = await saveAvatarInCache(
base64Decode(_cleanBase64String(avatarData.base64)),
avatarData.data,
avatarData.hash,
jid,
jid.toString(),
state.avatarUrl,
);
await xss.modifyXmppState(

View File

@ -87,8 +87,7 @@ class ConversationService {
tmp.add(
Conversation.fromDatabaseJson(
c,
rosterItem != null && !rosterItem.pseudoRosterItem,
rosterItem?.subscription ?? 'none',
rosterItem?.showAddToRosterButton ?? true,
lastMessage,
),
);
@ -136,7 +135,8 @@ class ConversationService {
Message? lastMessage,
bool? open,
int? unreadCounter,
String? avatarUrl,
String? avatarPath,
Object? avatarHash = notSpecified,
ChatState? chatState,
bool? muted,
bool? encrypted,
@ -160,8 +160,11 @@ class ConversationService {
if (unreadCounter != null) {
c['unreadCounter'] = unreadCounter;
}
if (avatarUrl != null) {
c['avatarUrl'] = avatarUrl;
if (avatarPath != null) {
c['avatarPath'] = avatarPath;
}
if (avatarHash != notSpecified) {
c['avatarHash'] = avatarHash as String?;
}
if (muted != null) {
c['muted'] = boolToInt(muted);
@ -191,8 +194,7 @@ class ConversationService {
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
var newConversation = Conversation.fromDatabaseJson(
result,
rosterItem != null,
rosterItem?.subscription ?? 'none',
rosterItem?.showAddToRosterButton ?? true,
lastMessage,
);
@ -215,7 +217,7 @@ class ConversationService {
String title,
Message? lastMessage,
ConversationType type,
String avatarUrl,
String avatarPath,
String jid,
int unreadCounter,
int lastChangeTimestamp,
@ -231,14 +233,14 @@ class ConversationService {
final newConversation = Conversation(
title,
lastMessage,
avatarUrl,
avatarPath,
null,
jid,
unreadCounter,
type,
lastChangeTimestamp,
open,
rosterItem != null && !rosterItem.pseudoRosterItem,
rosterItem?.subscription ?? 'none',
rosterItem?.showAddToRosterButton ?? true,
muted,
encrypted,
ChatState.gone,

View File

@ -41,6 +41,8 @@ import 'package:moxxyv2/service/database/migrations/0002_reactions.dart';
import 'package:moxxyv2/service/database/migrations/0002_reactions_2.dart';
import 'package:moxxyv2/service/database/migrations/0002_shared_media.dart';
import 'package:moxxyv2/service/database/migrations/0002_sticker_metadata.dart';
import 'package:moxxyv2/service/database/migrations/0003_avatar_hashes.dart';
import 'package:moxxyv2/service/database/migrations/0003_remove_subscriptions.dart';
import 'package:path/path.dart' as path;
import 'package:random_string/random_string.dart';
// ignore: implementation_imports
@ -144,6 +146,8 @@ const List<DatabaseMigration<Database>> migrations = [
DatabaseMigration(35, upgradeFromV34ToV35),
DatabaseMigration(36, upgradeFromV35ToV36),
DatabaseMigration(37, upgradeFromV36ToV37),
DatabaseMigration(38, upgradeFromV37ToV38),
DatabaseMigration(39, upgradeFromV38ToV39),
];
class DatabaseService {
@ -179,10 +183,23 @@ class DatabaseService {
_log.finest('Key generation done...');
}
// Just some sanity checks
final version = migrations.last.version;
assert(
migrations.every((migration) => migration.version <= version),
"Every migration's version must be smaller or equal to the last version",
);
assert(
migrations
.sublist(0, migrations.length - 1)
.every((migration) => migration.version < version),
'The last migration must have the largest version',
);
database = await openDatabase(
dbPath,
password: key,
version: 37,
version: version,
onCreate: createDatabase,
onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign

View File

@ -0,0 +1,13 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV37ToV38(Database db) async {
await db
.execute('ALTER TABLE $conversationsTable ADD COLUMN avatarHash TEXT');
await db.execute(
'ALTER TABLE $conversationsTable RENAME COLUMN avatarUrl TO avatarPath',
);
await db.execute(
'ALTER TABLE $rosterTable RENAME COLUMN avatarUrl TO avatarPath',
);
}

View File

@ -0,0 +1,6 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV38ToV39(Database db) async {
await db.execute('DROP TABLE $subscriptionsTable');
}

View File

@ -25,10 +25,10 @@ import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/service/subscription.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/debug.dart' as debug;
import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
@ -100,6 +100,8 @@ void setupBackgroundEventHandler() {
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia),
EventTypeMatcher<GetReactionsForMessageCommand>(performGetReactions),
EventTypeMatcher<RequestAvatarForJidCommand>(performRequestAvatarForJid),
EventTypeMatcher<DebugCommand>(performDebugCommand),
]);
GetIt.I.registerSingleton<EventHandler>(handler);
@ -318,7 +320,7 @@ Future<void> performSendMessage(
command.editSid!,
command.recipients.first,
command.chatState.isNotEmpty
? chatStateFromString(command.chatState)
? ChatState.fromName(command.chatState)
: null,
);
return;
@ -328,7 +330,7 @@ Future<void> performSendMessage(
body: command.body,
recipients: command.recipients,
chatState: command.chatState.isNotEmpty
? chatStateFromString(command.chatState)
? ChatState.fromName(command.chatState)
: null,
quotedMessage: command.quotedMessage,
currentConversationJid: command.currentConversationJid,
@ -505,30 +507,39 @@ Future<void> performAddContact(
},
);
// Manage subscription requests
final srs = GetIt.I.get<SubscriptionRequestService>();
final hasSubscriptionRequest = await srs.hasPendingSubscriptionRequest(jid);
if (hasSubscriptionRequest) {
await srs.acceptSubscriptionRequest(jid);
}
// Add to roster, if needed
final item = await roster.getRosterItemByJid(jid);
if (item != null) {
if (item.subscription != 'from' && item.subscription != 'both') {
GetIt.I.get<Logger>().finest(
'Roster item already exists with no presence subscription from them. Sending subscription request',
'Roster item for $jid has subscription "${item.subscription}" with ask "${item.ask}"',
);
srs.sendSubscriptionRequest(jid);
// Nothing more to do
if (item.subscription == 'both') {
return;
}
final pm = GetIt.I
.get<XmppConnection>()
.getManagerById<PresenceManager>(presenceManager)!;
switch (item.subscription) {
case 'both':
return;
case 'none':
case 'from':
if (item.ask != 'subscribe') {
// Try to move from "from"/"none" to "both", by going over "From + Pending Out"
await pm.requestSubscription(JID.fromString(item.jid));
}
break;
case 'to':
// Move from "to" to "both"
await pm.acceptSubscriptionRequest(JID.fromString(item.jid));
break;
}
} else {
await roster.addToRosterWrapper('', '', jid, jid.split('@')[0]);
}
// Try to figure out an avatar
// TODO(Unknown): Don't do that here. Do it more intelligently.
await GetIt.I.get<AvatarService>().subscribeJid(jid);
await GetIt.I.get<AvatarService>().fetchAndUpdateAvatarForJid(jid, '');
}
Future<void> performRemoveContact(
@ -547,7 +558,7 @@ Future<void> performRemoveContact(
sendEvent(
ConversationUpdatedEvent(
conversation: conversation.copyWith(
inRoster: false,
showAddToRoster: true,
),
),
);
@ -615,23 +626,32 @@ Future<void> performSetShareOnlineStatus(
dynamic extra,
}) async {
final rs = GetIt.I.get<RosterService>();
final srs = GetIt.I.get<SubscriptionRequestService>();
final item = await rs.getRosterItemByJid(command.jid);
// TODO(Unknown): Maybe log
if (item == null) return;
final jid = JID.fromString(command.jid);
final pm = GetIt.I
.get<XmppConnection>()
.getManagerById<PresenceManager>(presenceManager)!;
if (command.share) {
if (item.ask == 'subscribe') {
await srs.acceptSubscriptionRequest(command.jid);
} else {
srs.sendSubscriptionRequest(command.jid);
switch (item.subscription) {
case 'to':
await pm.acceptSubscriptionRequest(jid);
break;
case 'none':
case 'from':
await pm.requestSubscription(jid);
break;
}
} else {
if (item.ask == 'subscribe') {
await srs.rejectSubscriptionRequest(command.jid);
} else {
srs.sendUnsubscriptionRequest(command.jid);
switch (item.subscription) {
case 'both':
case 'from':
case 'to':
await pm.unsubscribe(jid);
break;
}
}
}
@ -673,9 +693,9 @@ Future<void> performSendChatState(
final conn = GetIt.I.get<XmppConnection>();
if (command.jid != '') {
conn
await conn
.getManagerById<ChatStateManager>(chatStateManager)!
.sendChatState(chatStateFromString(command.state), command.jid);
.sendChatState(ChatState.fromName(command.state), command.jid);
}
}
@ -864,17 +884,14 @@ Future<void> performMessageRetraction(
true,
);
if (command.conversationJid != '') {
(GetIt.I
final manager = GetIt.I
.get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!)
.sendMessage(
MessageDetails(
to: command.conversationJid,
messageRetraction: MessageRetractionData(
command.originId,
t.messages.retractedFallback,
),
),
.getManagerById<MessageManager>(messageManager)!;
await manager.sendMessage(
JID.fromString(command.conversationJid),
TypedMap<StanzaHandlerExtension>.fromList([
MessageRetractionData(command.originId, t.messages.retractedFallback),
]),
);
}
}
@ -956,23 +973,24 @@ Future<void> performAddMessageReaction(
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
// Send the reaction
GetIt.I
final manager = GetIt.I
.get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!
.sendMessage(
MessageDetails(
to: command.conversationJid,
messageReactions: MessageReactions(
.getManagerById<MessageManager>(messageManager)!;
await manager.sendMessage(
JID.fromString(command.conversationJid),
TypedMap<StanzaHandlerExtension>.fromList([
MessageReactionsData(
msg.originId ?? msg.sid,
await rs.getReactionsForMessageByJid(
command.messageId,
jid,
),
),
requestChatMarkers: false,
messageProcessingHints:
!msg.containsNoStore ? [MessageProcessingHint.store] : null,
),
const MarkableData(false),
MessageProcessingHintData([
if (!msg.containsNoStore) MessageProcessingHint.store,
]),
]),
);
}
}
@ -995,23 +1013,24 @@ Future<void> performRemoveMessageReaction(
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
// Send the reaction
GetIt.I
final manager = GetIt.I
.get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!
.sendMessage(
MessageDetails(
to: command.conversationJid,
messageReactions: MessageReactions(
.getManagerById<MessageManager>(messageManager)!;
await manager.sendMessage(
JID.fromString(command.conversationJid),
TypedMap<StanzaHandlerExtension>.fromList([
MessageReactionsData(
msg.originId ?? msg.sid,
await rs.getReactionsForMessageByJid(
command.messageId,
jid,
),
),
requestChatMarkers: false,
messageProcessingHints:
!msg.containsNoStore ? [MessageProcessingHint.store] : null,
),
const MarkableData(false),
MessageProcessingHintData([
if (!msg.containsNoStore) MessageProcessingHint.store,
]),
]),
);
}
}
@ -1248,3 +1267,46 @@ Future<void> performGetReactions(
id: id,
);
}
Future<void> performRequestAvatarForJid(
RequestAvatarForJidCommand command, {
dynamic extra,
}) async {
final as = GetIt.I.get<AvatarService>();
Future<void> future;
if (command.ownAvatar) {
future = as.requestOwnAvatar();
} else {
future = as.requestAvatar(
JID.fromString(command.jid),
command.hash,
);
}
unawaited(future);
}
Future<void> performDebugCommand(
DebugCommand command, {
dynamic extra,
}) async {
final conn = GetIt.I.get<XmppConnection>();
if (command.id == debug.DebugCommand.clearStreamResumption.id) {
// Disconnect
await conn.disconnect();
// Reset stream management
await conn.getManagerById<StreamManagementManager>(smManager)!.resetState();
// Reconnect
await conn.connect(
shouldReconnect: true,
waitForConnection: true,
);
} else if (command.id == debug.DebugCommand.requestRoster.id) {
await conn
.getManagerById<RosterManager>(rosterManager)!
.requestRoster(useRosterVersion: false);
}
}

View File

@ -124,11 +124,7 @@ String getUnrecoverableErrorString(NonRecoverableErrorEvent event) {
/// This information is complemented either the srcUrl or if unavailable
/// by the body of the quoted message. For non-media messages, we always use
/// the body as fallback.
String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
if (quotedMessage == null) {
return null;
}
String createFallbackBodyForQuotedMessage(Message quotedMessage) {
if (quotedMessage.isMedia) {
// Create formatted size string, if size is stored
String quoteMessageSize;

View File

@ -338,14 +338,13 @@ class HttpFileTransferService {
sendEvent(MessageUpdatedEvent(message: msg));
// Send the message to the recipient
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: recipient,
body: slot.getUrl,
requestDeliveryReceipt: true,
id: msg.sid,
originId: msg.originId,
sfs: StatelessFileSharingData(
await conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
JID.fromString(recipient),
TypedMap<StanzaHandlerExtension>.fromList([
MessageBodyData(slot.getUrl),
const MessageDeliveryReceiptData(true),
StableIdData(msg.originId, null),
StatelessFileSharingData(
FileMetadataData(
mediaType: job.mime,
size: stat.size,
@ -353,11 +352,12 @@ class HttpFileTransferService {
thumbnails: job.thumbnails,
hashes: plaintextHashes,
),
<StatelessFileSharingSource>[source],
),
shouldEncrypt: job.encryptMap[recipient]!,
funReplacement: oldSid,
[source],
includeOOBFallback: true,
),
FileUploadNotificationReplacementData(oldSid),
MessageIdData(msg.sid),
]),
);
_log.finest(
'Sent message with file upload for ${job.path} to $recipient',

View File

@ -1,12 +1,32 @@
import 'dart:async';
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/roster.dart';
/// Update the "showAddToRoster" state of the conversation with jid [jid] to
/// [showAddToRoster], if the conversation exists.
Future<void> updateConversation(String jid, bool showAddToRoster) async {
final cs = GetIt.I.get<ConversationService>();
final newConversation = await cs.createOrUpdateConversation(
jid,
update: (conversation) async {
final c = conversation.copyWith(
showAddToRoster: showAddToRoster,
);
cs.setConversation(c);
return c;
},
);
if (newConversation != null) {
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
}
}
class MoxxyRosterStateManager extends BaseRosterStateManager {
@override
Future<RosterCacheLoadResult> loadRosterCache() async {
@ -45,6 +65,7 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
// Remove stale items
for (final jid in removed) {
await rs.removeRosterItemByJid(jid);
await updateConversation(jid, true);
}
// Create new roster items
@ -54,8 +75,7 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
// Skip adding items twice
if (exists) continue;
rosterAdded.add(
await rs.addRosterItemFromData(
final newRosterItem = await rs.addRosterItemFromData(
'',
'',
item.jid,
@ -67,8 +87,11 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
null,
null,
groups: item.groups,
),
);
rosterAdded.add(newRosterItem);
// Update the cached conversation item
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
}
// Update modified items
@ -80,15 +103,17 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
continue;
}
rosterModified.add(
await rs.updateRosterItem(
final newRosterItem = await rs.updateRosterItem(
ritem.id,
title: item.name,
subscription: item.subscription,
ask: item.ask,
groups: item.groups,
),
);
rosterModified.add(newRosterItem);
// Update the cached conversation item
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
}
// Tell the UI

View File

@ -36,7 +36,7 @@ class NotificationsService {
MessageNotificationTappedEvent(
conversationJid: action.payload!['conversationJid']!,
title: action.payload!['title']!,
avatarUrl: action.payload!['avatarUrl']!,
avatarPath: action.payload!['avatarPath']!,
),
);
} else if (action.buttonKeyPressed == _notificationActionKeyRead) {
@ -110,8 +110,8 @@ class NotificationsService {
final title =
contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title;
final avatarPath = contactIntegrationEnabled
? c.contactAvatarPath ?? c.avatarUrl
: c.avatarUrl;
? c.contactAvatarPath ?? c.avatarPath
: c.avatarPath;
await AwesomeNotifications().createNotification(
content: NotificationContent(
@ -131,7 +131,7 @@ class NotificationsService {
'conversationJid': c.jid,
'sid': m.sid,
'title': title,
'avatarUrl': avatarPath,
'avatarPath': avatarPath,
},
),
actionButtons: [

View File

@ -8,7 +8,6 @@ import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/subscription.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/roster.dart';
@ -32,7 +31,7 @@ class RosterService {
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
Future<RosterItem> addRosterItemFromData(
String avatarUrl,
String avatarPath,
String avatarHash,
String jid,
String title,
@ -47,7 +46,7 @@ class RosterService {
// TODO(PapaTutuWawa): Handle groups
final i = RosterItem(
-1,
avatarUrl,
avatarPath,
avatarHash,
jid,
title,
@ -76,7 +75,7 @@ class RosterService {
/// Wrapper around [DatabaseService]'s updateRosterItem that updates the cache.
Future<RosterItem> updateRosterItem(
int id, {
String? avatarUrl,
String? avatarPath,
String? avatarHash,
String? title,
String? subscription,
@ -89,8 +88,8 @@ class RosterService {
}) async {
final i = <String, dynamic>{};
if (avatarUrl != null) {
i['avatarUrl'] = avatarUrl;
if (avatarPath != null) {
i['avatarPath'] = avatarPath;
}
if (avatarHash != null) {
i['avatarHash'] = avatarHash;
@ -197,7 +196,7 @@ class RosterService {
/// and, if it was successful, create the database entry. Returns the
/// [RosterItem] model object.
Future<RosterItem> addToRosterWrapper(
String avatarUrl,
String avatarPath,
String avatarHash,
String jid,
String title,
@ -205,7 +204,7 @@ class RosterService {
final css = GetIt.I.get<ContactsService>();
final contactId = await css.getContactIdForJid(jid);
final item = await addRosterItemFromData(
avatarUrl,
avatarPath,
avatarHash,
jid,
title,
@ -217,14 +216,19 @@ class RosterService {
await css.getContactDisplayName(contactId),
);
final result = await GetIt.I
.get<XmppConnection>()
.getRosterManager()!
.addToRoster(jid, title);
final conn = GetIt.I.get<XmppConnection>();
final result = await conn.getRosterManager()!.addToRoster(jid, title);
if (!result) {
// TODO(Unknown): Signal error?
}
final to = JID.fromString(jid);
final preApproval =
await conn.getPresenceManager()!.preApproveSubscription(to);
if (!preApproval) {
await conn.getPresenceManager()!.requestSubscription(to);
}
sendEvent(RosterDiffEvent(added: [item]));
return item;
}
@ -236,14 +240,14 @@ class RosterService {
String jid, {
bool unsubscribe = true,
}) async {
final roster = GetIt.I.get<XmppConnection>().getRosterManager()!;
final conn = GetIt.I.get<XmppConnection>();
final roster = conn.getRosterManager()!;
final pm = conn.getManagerById<PresenceManager>(presenceManager)!;
final result = await roster.removeFromRoster(jid);
if (result == RosterRemovalResult.okay ||
result == RosterRemovalResult.itemNotFound) {
if (unsubscribe) {
GetIt.I
.get<SubscriptionRequestService>()
.sendUnsubscriptionRequest(jid);
await pm.unsubscribe(JID.fromString(jid));
}
_log.finest('Removing from roster maybe worked. Removing from database');

View File

@ -33,7 +33,6 @@ import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/service/subscription.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/commands.dart';
@ -175,9 +174,6 @@ Future<void> entrypoint() async {
GetIt.I.registerSingleton<ContactsService>(ContactsService());
GetIt.I.registerSingleton<StickersService>(StickersService());
GetIt.I.registerSingleton<XmppStateService>(XmppStateService());
GetIt.I.registerSingleton<SubscriptionRequestService>(
SubscriptionRequestService(),
);
GetIt.I.registerSingleton<FilesService>(FilesService());
GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
final xmpp = XmppService();
@ -211,6 +207,7 @@ Future<void> entrypoint() async {
StreamManagementNegotiator(),
CSINegotiator(),
RosterFeatureNegotiator(),
PresenceNegotiator(),
SaslScramNegotiator(10, '', '', ScramHashType.sha512),
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
@ -230,7 +227,6 @@ Future<void> entrypoint() async {
CSIManager(),
CarbonsManager(),
PubSubManager(),
VCardManager(),
UserAvatarManager(),
StableIdManager(),
MessageDeliveryReceiptManager(),
@ -249,6 +245,7 @@ Future<void> entrypoint() async {
LastMessageCorrectionManager(),
MessageReactionsManager(),
StickersManager(),
MessageProcessingHintManager(),
]);
GetIt.I.registerSingleton<XmppConnection>(connection);

View File

@ -1,95 +0,0 @@
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
import 'package:synchronized/synchronized.dart';
class SubscriptionRequestService {
List<String>? _subscriptionRequests;
final Lock _lock = Lock();
/// Only load data from the database into
/// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet
/// been loaded.
Future<void> _loadSubscriptionRequestsIfNeeded() async {
await _lock.synchronized(() async {
_subscriptionRequests ??= List<String>.from(
(await GetIt.I
.get<DatabaseService>()
.database
.query(subscriptionsTable))
.map((m) => m['jid']! as String)
.toList(),
);
});
}
Future<List<String>> getSubscriptionRequests() async {
await _loadSubscriptionRequestsIfNeeded();
return _subscriptionRequests!;
}
Future<void> addSubscriptionRequest(String jid) async {
await _loadSubscriptionRequestsIfNeeded();
await _lock.synchronized(() async {
if (!_subscriptionRequests!.contains(jid)) {
_subscriptionRequests!.add(jid);
await GetIt.I.get<DatabaseService>().database.insert(
subscriptionsTable,
{
'jid': jid,
},
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
});
}
Future<void> removeSubscriptionRequest(String jid) async {
await _loadSubscriptionRequestsIfNeeded();
await _lock.synchronized(() async {
if (_subscriptionRequests!.contains(jid)) {
_subscriptionRequests!.remove(jid);
await GetIt.I.get<DatabaseService>().database.delete(
subscriptionsTable,
where: 'jid = ?',
whereArgs: [jid],
);
}
});
}
Future<bool> hasPendingSubscriptionRequest(String jid) async {
return (await getSubscriptionRequests()).contains(jid);
}
PresenceManager get _presence =>
GetIt.I.get<XmppConnection>().getPresenceManager()!;
/// Accept a subscription request from [jid].
Future<void> acceptSubscriptionRequest(String jid) async {
_presence.sendSubscriptionRequestApproval(jid);
await removeSubscriptionRequest(jid);
}
/// Reject a subscription request from [jid].
Future<void> rejectSubscriptionRequest(String jid) async {
_presence.sendSubscriptionRequestRejection(jid);
await removeSubscriptionRequest(jid);
}
/// Send a subscription request to [jid].
void sendSubscriptionRequest(String jid) {
_presence.sendSubscriptionRequest(jid);
}
/// Remove a presence subscription with [jid].
void sendUnsubscriptionRequest(String jid) {
_presence.sendUnsubscriptionRequest(jid);
}
}

View File

@ -29,7 +29,6 @@ import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/subscription.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/eventhandler.dart';
@ -56,7 +55,7 @@ class XmppService {
_onDeliveryReceiptReceived,
),
EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated),
EventTypeMatcher<UserAvatarUpdatedEvent>(_onAvatarUpdated),
EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked),
EventTypeMatcher<MessageEvent>(_onMessage),
EventTypeMatcher<BlocklistBlockPushEvent>(_onBlocklistBlockPush),
@ -184,13 +183,14 @@ class XmppService {
if (conversation?.type != ConversationType.note) {
// Send the correction
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: recipient,
body: newBody,
lastMessageCorrectionId: oldId,
chatState: chatState,
),
final manager = conn.getManagerById<MessageManager>(messageManager)!;
await manager.sendMessage(
JID.fromString(recipient),
TypedMap<StanzaHandlerExtension>.fromList([
MessageBodyData(newBody),
LastMessageCorrectionData(oldId),
if (chatState != null) chatState,
]),
);
}
}
@ -259,27 +259,38 @@ class XmppService {
if (conversation?.type == ConversationType.chat) {
final moxxmppSticker = sticker?.toMoxxmpp();
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: recipient,
body: body,
requestDeliveryReceipt: true,
id: sid,
originId: originId,
quoteBody: createFallbackBodyForQuotedMessage(quotedMessage),
quoteFrom: quotedMessage?.sender,
quoteId: quotedMessage?.sid,
chatState: chatState,
shouldEncrypt: conversation!.encrypted,
stickerPackId: sticker?.stickerPackId,
sfs: moxxmppSticker != null
? StatelessFileSharingData(
final manager = conn.getManagerById<MessageManager>(messageManager)!;
await manager.sendMessage(
JID.fromString(recipient),
TypedMap<StanzaHandlerExtension>.fromList([
MessageBodyData(body),
const MarkableData(true),
MessageIdData(sid),
StableIdData(originId, null),
if (sticker != null && moxxmppSticker != null)
StickersData(
sticker.stickerPackId,
StatelessFileSharingData(
moxxmppSticker.metadata,
moxxmppSticker.sources,
)
: null,
setOOBFallbackBody: sticker != null ? false : true,
),
),
// Optional chat state
if (chatState != null) chatState,
// Prepare the appropriate quote
if (quotedMessage != null)
ReplyData.fromQuoteData(
quotedMessage.sid,
QuoteData.fromBodies(
createFallbackBodyForQuotedMessage(quotedMessage),
body,
),
),
]),
);
}
@ -290,7 +301,9 @@ class XmppService {
}
MediaFileLocation? _getEmbeddedFile(MessageEvent event) {
if (event.sfs?.sources.isNotEmpty ?? false) {
final sfs = event.extensions.get<StatelessFileSharingData>();
final oob = event.extensions.get<OOBData>();
if (sfs?.sources.isNotEmpty ?? false) {
// final source = firstWhereOrNull(
// event.sfs!.sources,
// (StatelessFileSharingSource source) {
@ -300,14 +313,14 @@ class XmppService {
// );
final hasUrlSource = firstWhereOrNull(
event.sfs!.sources,
sfs!.sources,
(src) => src is StatelessFileSharingUrlSource,
) !=
null;
final name = event.sfs!.metadata.name;
final name = sfs.metadata.name;
if (hasUrlSource) {
final sources = event.sfs!.sources
final sources = sfs.sources
.whereType<StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList();
@ -317,13 +330,13 @@ class XmppService {
null,
null,
null,
event.sfs!.metadata.hashes,
sfs.metadata.hashes,
null,
event.sfs!.metadata.size,
sfs.metadata.size,
);
} else {
final encryptedSource = firstWhereOrNull(
event.sfs!.sources,
sfs.sources,
(src) => src is StatelessFileSharingEncryptedSource,
)! as StatelessFileSharingEncryptedSource;
@ -335,15 +348,15 @@ class XmppService {
encryptedSource.encryption.toNamespace(),
encryptedSource.key,
encryptedSource.iv,
event.sfs?.metadata.hashes,
sfs.metadata.hashes,
encryptedSource.hashes,
event.sfs!.metadata.size,
sfs.metadata.size,
);
}
} else if (event.oob != null) {
} else if (oob != null) {
return MediaFileLocation(
[event.oob!.url!],
filenameFromUrl(event.oob!.url!),
[oob.url!],
filenameFromUrl(oob.url!),
null,
null,
null,
@ -360,45 +373,36 @@ class XmppService {
final result = await GetIt.I
.get<XmppConnection>()
.getDiscoManager()!
.discoInfoQuery(event.fromJid);
.discoInfoQuery(event.from);
if (result.isType<DiscoError>()) return;
final info = result.get<DiscoInfo>();
if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
unawaited(
GetIt.I.get<XmppConnection>().sendStanza(
StanzaDetails(
Stanza.message(
to: event.fromJid.toBare().toString(),
type: event.type,
children: [
makeChatMarker(
'received',
event.originId ?? event.sid,
final isMarkable =
event.extensions.get<MarkableData>()?.isMarkable ?? false;
final deliveryReceiptRequested =
event.extensions.get<MessageDeliveryReceiptData>()?.receiptRequested ??
false;
final originId = event.extensions.get<StableIdData>()?.originId;
final manager = GetIt.I
.get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!;
if (isMarkable && info.features.contains(chatMarkersXmlns)) {
await manager.sendMessage(
event.from.toBare(),
TypedMap<StanzaHandlerExtension>.fromList([
ChatMarkerData(
ChatMarker.received,
originId ?? event.id,
)
],
),
awaitable: false,
),
),
]),
);
} else if (event.deliveryReceiptRequested &&
} else if (deliveryReceiptRequested &&
info.features.contains(deliveryXmlns)) {
unawaited(
GetIt.I.get<XmppConnection>().sendStanza(
StanzaDetails(
Stanza.message(
to: event.fromJid.toBare().toString(),
type: event.type,
children: [
makeMessageDeliveryResponse(
event.originId ?? event.sid,
)
],
),
awaitable: false,
),
),
await manager.sendMessage(
event.from.toBare(),
TypedMap<StanzaHandlerExtension>.fromList([
MessageDeliveryReceivedData(originId ?? event.id),
]),
);
}
}
@ -410,19 +414,14 @@ class XmppService {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
if (!prefs.sendChatMarkers) return;
unawaited(
GetIt.I.get<XmppConnection>().sendStanza(
StanzaDetails(
Stanza.message(
to: to,
type: 'chat',
children: [
makeChatMarker('displayed', sid),
],
),
awaitable: false,
),
),
final manager = GetIt.I
.get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!;
await manager.sendMessage(
JID.fromString(to),
TypedMap<StanzaHandlerExtension>.fromList([
ChatMarkerData(ChatMarker.displayed, sid),
]),
);
}
@ -601,7 +600,7 @@ class XmppService {
rosterItem?.title ?? recipient.split('@').first,
lastMessages[recipient],
ConversationType.chat,
rosterItem?.avatarUrl ?? '',
rosterItem?.avatarPath ?? '',
recipient,
0,
DateTime.now().millisecondsSinceEpoch,
@ -643,6 +642,7 @@ class XmppService {
// Requesting Upload slots and uploading
final hfts = GetIt.I.get<HttpFileTransferService>();
final manager = conn.getManagerById<MessageManager>(messageManager)!;
for (final path in paths) {
final pathMime = lookupMimeType(path);
@ -661,19 +661,20 @@ class XmppService {
}
}
if (recipient != '') {
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: recipient,
id: messages[path]![recipient]!.sid,
fun: FileMetadataData(
await manager.sendMessage(
JID.fromString(recipient),
TypedMap<StanzaHandlerExtension>.fromList([
MessageIdData(messages[path]![recipient]!.sid),
FileUploadNotificationData(
FileMetadataData(
// TODO(Unknown): Maybe add media type specific metadata
mediaType: lookupMimeType(path),
name: pathlib.basename(path),
size: File(path).statSync().size,
thumbnails: thumbnails[path] ?? [],
),
shouldEncrypt: encrypt[recipient]!,
),
]),
);
}
}
@ -761,19 +762,25 @@ class XmppService {
unawaited(_initializeOmemoService(settings.jid.toString()));
if (!event.resumed) {
// Reset the avatar service's cache
GetIt.I.get<AvatarService>().resetCache();
// Reset the blocking service's cache
GetIt.I.get<BlocklistService>().onNewConnection();
// Reset the OMEMO cache
GetIt.I.get<OmemoService>().onNewConnection();
// Enable carbons
final carbonsResult = await connection
.getManagerById<CarbonsManager>(carbonsManager)!
.enableCarbons();
// Enable carbons, if they're not already enabled (e.g. by using SASL2)
final cm = connection.getManagerById<CarbonsManager>(carbonsManager)!;
if (!cm.isEnabled) {
final carbonsResult = await cm.enableCarbons();
if (!carbonsResult) {
_log.warning('Failed to enable carbons');
}
} else {
_log.info('Not enabling carbons as they are already enabled');
}
// In section 5 of XEP-0198 it says that a client should not request the roster
// in case of a stream resumption.
@ -781,22 +788,9 @@ class XmppService {
.getManagerById<RosterManager>(rosterManager)!
.requestRoster();
// TODO(Unknown): Once groupchats come into the equation, this gets trickier
final roster = await GetIt.I.get<RosterService>().getRoster();
for (final item in roster) {
await GetIt.I
.get<AvatarService>()
.fetchAndUpdateAvatarForJid(item.jid, item.avatarHash);
}
await GetIt.I.get<BlocklistService>().getBlocklist();
}
// Make sure we display our own avatar correctly.
// Note that this only requests the avatar if its hash differs from the locally cached avatar's.
// TODO(Unknown): Maybe don't do this on mobile Internet
unawaited(GetIt.I.get<AvatarService>().requestOwnAvatar());
if (_loginTriggeredFromUI) {
// TODO(Unknown): Trigger another event so the UI can see this aswell
await GetIt.I.get<XmppStateService>().modifyXmppState(
@ -808,6 +802,10 @@ class XmppService {
),
);
}
sendEvent(
StreamNegotiationsCompletedEvent(resumed: event.resumed),
);
}
Future<void> _onConnectionStateChanged(
@ -837,20 +835,24 @@ class XmppService {
SubscriptionRequestReceivedEvent event, {
dynamic extra,
}) async {
final jid = event.from.toBare().toString();
final jid = event.from.toBare();
// Auto-accept if the JID is in the roster
final rs = GetIt.I.get<RosterService>();
final srs = GetIt.I.get<SubscriptionRequestService>();
final rosterItem = await rs.getRosterItemByJid(jid);
final rosterItem = await rs.getRosterItemByJid(jid.toString());
if (rosterItem != null) {
await srs.acceptSubscriptionRequest(jid);
return;
final pm = GetIt.I
.get<XmppConnection>()
.getManagerById<PresenceManager>(presenceManager)!;
switch (rosterItem.subscription) {
case 'from':
await pm.acceptSubscriptionRequest(jid);
break;
}
await GetIt.I.get<SubscriptionRequestService>().addSubscriptionRequest(
event.from.toBare().toString(),
);
return;
}
}
Future<void> _onDeliveryReceiptReceived(
@ -900,12 +902,12 @@ class XmppService {
final msg = await ms.updateMessage(
dbMsg.id,
received: dbMsg.received ||
event.type == 'received' ||
event.type == 'displayed' ||
event.type == 'acknowledged',
event.type == ChatMarker.received ||
event.type == ChatMarker.displayed ||
event.type == ChatMarker.acknowledged,
displayed: dbMsg.displayed ||
event.type == 'displayed' ||
event.type == 'acknowledged',
event.type == ChatMarker.displayed ||
event.type == ChatMarker.acknowledged,
);
sendEvent(MessageUpdatedEvent(message: msg));
@ -935,14 +937,21 @@ class XmppService {
/// Return true if [event] describes a message that we want to display.
bool _isMessageEventMessage(MessageEvent event) {
return event.body.isNotEmpty || event.sfs != null || event.fun != null;
final body = event.extensions.get<MessageBodyData>()?.body;
final sfs = event.extensions.get<StatelessFileSharingData>();
final fun = event.extensions.get<FileUploadNotificationData>();
return (body?.isNotEmpty ?? false) || sfs != null || fun != null;
}
/// Extract the thumbnail data from a message, if existent.
String? _getThumbnailData(MessageEvent event) {
final sfs = event.extensions.get<StatelessFileSharingData>();
final fun = event.extensions.get<FileUploadNotificationData>();
final thumbnails = firstNotNull([
event.sfs?.metadata.thumbnails,
event.fun?.thumbnails,
sfs?.metadata.thumbnails,
fun?.metadata.thumbnails,
]) ??
[];
for (final i in thumbnails) {
@ -956,27 +965,33 @@ class XmppService {
/// Extract the mime guess from a message, if existent.
String? _getMimeGuess(MessageEvent event) {
final sfs = event.extensions.get<StatelessFileSharingData>();
final fun = event.extensions.get<FileUploadNotificationData>();
return firstNotNull([
event.sfs?.metadata.mediaType,
event.fun?.mediaType,
sfs?.metadata.mediaType,
fun?.metadata.mediaType,
]);
}
/// Extract the embedded dimensions, if existent.
Size? _getDimensions(MessageEvent event) {
if (event.sfs != null &&
event.sfs?.metadata.width != null &&
event.sfs?.metadata.height != null) {
final sfs = event.extensions.get<StatelessFileSharingData>();
final fun = event.extensions.get<FileUploadNotificationData>();
if (sfs != null &&
sfs.metadata.width != null &&
sfs.metadata.height != null) {
return Size(
event.sfs!.metadata.width!.toDouble(),
event.sfs!.metadata.height!.toDouble(),
sfs.metadata.width!.toDouble(),
sfs.metadata.height!.toDouble(),
);
} else if (event.fun != null &&
event.fun?.width != null &&
event.fun?.height != null) {
} else if (fun != null &&
fun.metadata.width != null &&
fun.metadata.height != null) {
return Size(
event.fun!.width!.toDouble(),
event.fun!.height!.toDouble(),
fun.metadata.width!.toDouble(),
fun.metadata.height!.toDouble(),
);
}
@ -987,11 +1002,14 @@ class XmppService {
/// [embeddedFile] is the possible source of the file. If no file is present, then
/// [embeddedFile] is null.
bool _isFileEmbedded(MessageEvent event, MediaFileLocation? embeddedFile) {
final body = event.extensions.get<MessageBodyData>()?.body;
final oob = event.extensions.get<OOBData>();
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and
// that the message body and the OOB url are the same if the OOB url is not null.
return embeddedFile != null &&
Uri.parse(embeddedFile.urls.first).scheme == 'https' &&
implies(event.oob != null, event.body == event.oob?.url);
implies(oob != null, body == oob?.url);
}
/// Handle a message retraction given the MessageEvent [event].
@ -1001,8 +1019,8 @@ class XmppService {
) async {
await GetIt.I.get<MessageService>().retractMessage(
conversationJid,
event.messageRetraction!.id,
event.fromJid.toBare().toString(),
event.extensions.get<MessageRetractionData>()!.id,
event.from.toBare().toString(),
false,
);
}
@ -1020,19 +1038,19 @@ class XmppService {
Future<void> _handleErrorMessage(MessageEvent event) async {
if (event.error == null) {
_log.warning(
'Received error for message ${event.sid} without an error element',
'Received error for message ${event.id} without an error element',
);
return;
}
final ms = GetIt.I.get<MessageService>();
final msg = await ms.getMessageByStanzaId(
event.fromJid.toBare().toString(),
event.sid,
event.from.toBare().toString(),
event.id,
);
if (msg == null) {
_log.warning('Received error for message ${event.sid} we cannot find');
_log.warning('Received error for message ${event.id} we cannot find');
return;
}
@ -1068,23 +1086,23 @@ class XmppService {
) async {
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final correctionId = event.extensions.get<LastMessageCorrectionData>()!.id;
final msg = await ms.getMessageByStanzaId(
conversationJid,
event.messageCorrectionId!,
correctionId,
);
if (msg == null) {
_log.warning(
'Received message correction for message ${event.messageCorrectionId} we cannot find.',
'Received message correction for message $correctionId we cannot find.',
);
return;
}
// Check if the Jid is allowed to do correct the message
// TODO(Unknown): Maybe use the JID parser?
final bareSender = event.fromJid.toBare().toString();
if (msg.sender.split('/').first != bareSender) {
if (msg.senderJid.toBare() != event.from.toBare()) {
_log.warning(
'Received a message correction from $bareSender for message that is not sent by $bareSender',
'Received a message correction from ${event.from} for a message that is sent by ${msg.sender}',
);
return;
}
@ -1097,9 +1115,10 @@ class XmppService {
return;
}
// TODO(Unknown): Should we null-check here?
final newMsg = await ms.updateMessage(
msg.id,
body: event.body,
body: event.extensions.get<MessageBodyData>()!.body,
isEdited: true,
);
sendEvent(MessageUpdatedEvent(message: newMsg));
@ -1120,30 +1139,32 @@ class XmppService {
) async {
final ms = GetIt.I.get<MessageService>();
// TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
final reactions = event.extensions.get<MessageReactionsData>()!;
final msg = await ms.getMessageByXmppId(
event.messageReactions!.messageId,
reactions.messageId,
conversationJid,
queryReactionPreview: false,
);
if (msg == null) {
_log.warning(
'Received reactions for ${event.messageReactions!.messageId} from ${event.fromJid} for $conversationJid, but could not find message.',
'Received reactions for ${reactions.messageId} from ${event.from} for $conversationJid, but could not find message.',
);
return;
}
await GetIt.I.get<ReactionsService>().processNewReactions(
msg,
event.fromJid.toBare().toString(),
event.messageReactions!.emojis,
event.from.toBare().toString(),
reactions.emojis,
);
}
Future<void> _onMessage(MessageEvent event, {dynamic extra}) async {
// The jid this message event is meant for
final conversationJid = event.isCarbon
? event.toJid.toBare().toString()
: event.fromJid.toBare().toString();
final isCarbon = event.extensions.get<CarbonsData>()?.isCarbon ?? false;
final conversationJid = isCarbon
? event.to.toBare().toString()
: event.from.toBare().toString();
if (event.type == 'error') {
await _handleErrorMessage(event);
@ -1152,40 +1173,41 @@ class XmppService {
}
// Process the chat state update. Can also be attached to other messages
if (event.chatState != null) {
await _onChatState(event.chatState!, conversationJid);
final chatState = event.extensions.get<ChatState>();
if (chatState != null) {
await _onChatState(chatState, conversationJid);
}
// Process message corrections separately
if (event.messageCorrectionId != null) {
if (event.extensions.get<LastMessageCorrectionData>() != null) {
await _handleMessageCorrection(event, conversationJid);
return;
}
// Process File Upload Notifications replacements separately
if (event.funReplacement != null) {
if (event.extensions.get<FileUploadNotificationReplacementData>() != null) {
await _handleFileUploadNotificationReplacement(event, conversationJid);
return;
}
if (event.messageRetraction != null) {
if (event.extensions.get<MessageRetractionData>() != null) {
await _handleMessageRetraction(event, conversationJid);
return;
}
// Handle message reactions
if (event.messageReactions != null) {
if (event.extensions.get<MessageReactionsData>() != null) {
await _handleMessageReactions(event, conversationJid);
return;
}
// Stop the processing here if the event does not describe a displayable message
if (!_isMessageEventMessage(event) &&
event.other['encryption_error'] == null) return;
if (event.other['encryption_error'] is InvalidKeyExchangeException) return;
if (!_isMessageEventMessage(event) && event.encryptionError == null) return;
if (event.encryptionError is InvalidKeyExchangeException) return;
// Ignore File Upload Notifications where we don't have a filename.
if (event.fun != null && event.fun!.name == null) {
final fun = event.extensions.get<FileUploadNotificationData>();
if (fun != null && fun.metadata.name == null) {
_log.finest(
'Ignoring File Upload Notification as it does not specify a filename',
);
@ -1200,25 +1222,30 @@ class XmppService {
// Is the conversation partner in our roster
final isInRoster = rosterItem != null;
// True if the message was sent by us (via a Carbon)
final sent =
event.isCarbon && event.fromJid.toBare().toString() == state.jid;
final sent = isCarbon && event.from.toBare().toString() == state.jid;
// The timestamp at which we received the message
final messageTimestamp = DateTime.now().millisecondsSinceEpoch;
// Acknowledge the message if enabled
if (event.deliveryReceiptRequested && isInRoster && prefs.sendChatMarkers) {
final receiptRequested =
event.extensions.get<MessageDeliveryReceiptData>()?.receiptRequested ??
false;
if (receiptRequested && isInRoster && prefs.sendChatMarkers) {
// NOTE: We do not await it to prevent us being blocked if the IQ response id delayed
await _acknowledgeMessage(event);
}
// Pre-process the message in case it is a reply to another message
// TODO(Unknown): Fix the notion that no body means body == ''
final body = event.extensions.get<MessageBodyData>()?.body ?? '';
final reply = event.extensions.get<ReplyData>();
String? replyId;
var messageBody = event.body;
if (event.reply != null) {
replyId = event.reply!.id;
var messageBody = body;
if (reply != null) {
replyId = reply.id;
// Strip the compatibility fallback, if specified
messageBody = event.reply!.removeFallback(messageBody);
messageBody = reply.withoutFallback ?? body;
_log.finest('Removed message reply compatibility fallback from message');
}
@ -1261,18 +1288,21 @@ class XmppService {
var message = await ms.addMessageFromData(
messageBody,
messageTimestamp,
event.fromJid.toString(),
event.from.toString(),
conversationJid,
event.sid,
event.fun != null,
event.id,
fun != null,
event.encrypted,
event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ??
event.extensions
.get<MessageProcessingHintData>()
?.hints
.contains(MessageProcessingHint.noStore) ??
false,
fileMetadata: fileMetadata?.fileMetadata,
quoteId: replyId,
originId: event.originId,
errorType: errorTypeFromException(event.other['encryption_error']),
stickerPackId: event.stickerPackId,
originId: event.extensions.get<StableIdData>()?.originId,
errorType: errorTypeFromException(event.encryptionError),
stickerPackId: event.extensions.get<StickersData>()?.stickerPackId,
);
// Attempt to auto-download the embedded file, if
@ -1336,7 +1366,7 @@ class XmppService {
rosterItem?.title ?? conversationJid.split('@')[0],
message,
ConversationType.chat,
rosterItem?.avatarUrl ?? '',
rosterItem?.avatarPath ?? '',
conversationJid,
sent ? 0 : 1,
messageTimestamp,
@ -1392,7 +1422,7 @@ class XmppService {
}
// Mark the file as downlading when it includes a File Upload Notification
if (event.fun != null) {
if (fun != null) {
message = await ms.updateMessage(
message.id,
isDownloading: true,
@ -1408,8 +1438,10 @@ class XmppService {
String conversationJid,
) async {
final ms = GetIt.I.get<MessageService>();
var message =
await ms.getMessageByStanzaId(conversationJid, event.funReplacement!);
final replacementId =
event.extensions.get<FileUploadNotificationReplacementData>()!.id;
var message = await ms.getMessageByStanzaId(conversationJid, replacementId);
if (message == null) {
_log.warning(
'Received a FileUploadNotification replacement for unknown message',
@ -1426,11 +1458,9 @@ class XmppService {
}
// Check if the Jid is allowed to do so
// TODO(Unknown): Maybe use the JID parser?
final bareSender = event.fromJid.toBare().toString();
if (message.sender.split('/').first != bareSender) {
if (message.senderJid != event.from.toBare()) {
_log.warning(
'Received a FileUploadNotification replacement by $bareSender for message that is not sent by $bareSender',
'Received a FileUploadNotification replacement by ${event.from} for a message that is sent by ${message.sender}',
);
return;
}
@ -1454,8 +1484,8 @@ class XmppService {
fileMetadata: fileMetadata ?? notSpecified,
isFileUploadNotification: false,
isDownloading: shouldDownload,
sid: event.sid,
originId: event.originId,
sid: event.id,
originId: event.extensions.get<StableIdData>()?.originId,
);
// Remove the old entry
@ -1493,7 +1523,7 @@ class XmppService {
}
Future<void> _onAvatarUpdated(
AvatarUpdatedEvent event, {
UserAvatarUpdatedEvent event, {
dynamic extra,
}) async {
await GetIt.I.get<AvatarService>().handleAvatarUpdate(event);

10
lib/shared/debug.dart Normal file
View File

@ -0,0 +1,10 @@
enum DebugCommand {
/// Clear the stream resumption state so that the next connection is fresh.
clearStreamResumption(0),
requestRoster(1);
const DebugCommand(this.id);
/// The id of the command
final int id;
}

View File

@ -14,11 +14,11 @@ class ConversationChatStateConverter
@override
ChatState fromJson(Map<String, dynamic> json) =>
chatStateFromString(json['chatState'] as String);
ChatState.fromName(json['chatState'] as String);
@override
Map<String, dynamic> toJson(ChatState state) => <String, String>{
'chatState': chatStateToString(state),
'chatState': state.toName(),
};
}
@ -49,30 +49,52 @@ enum ConversationType {
@freezed
class Conversation with _$Conversation {
factory Conversation(
/// The title of the chat.
String title,
// The newest message in the chat.
@ConversationMessageConverter() Message? lastMessage,
String avatarUrl,
// The path to the avatar.
String avatarPath,
// The hash of the avatar.
String? avatarHash,
// The JID of the entity we're having a chat with...
String jid,
// The number of unread messages.
int unreadCounter,
// The kind of chat this conversation is representing.
ConversationType type,
// The timestamp the conversation was last changed.
// NOTE: In milliseconds since Epoch or -1 if none has ever happened
int lastChangeTimestamp,
// Indicates if the conversation should be shown on the homescreen
// Indicates if the conversation should be shown on the homescreen.
bool open,
// Indicates, if [jid] is a regular user, if the user is in the roster.
bool inRoster,
// The subscription state of the roster item
String subscription,
/// Flag indicating whether the "add to roster" button should be shown.
bool showAddToRoster,
// Whether the chat is muted (true = muted, false = not muted)
bool muted,
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
bool encrypted,
// The current chat state
@ConversationChatStateConverter() ChatState chatState, {
// The id of the contact in the device's phonebook if it exists
String? contactId,
// The path to the contact avatar, if available
String? contactAvatarPath,
// The contact's display name, if it exists
String? contactDisplayName,
}) = _Conversation;
@ -85,16 +107,14 @@ class Conversation with _$Conversation {
factory Conversation.fromDatabaseJson(
Map<String, dynamic> json,
bool inRoster,
String subscription,
bool showAddToRoster,
Message? lastMessage,
) {
return Conversation.fromJson({
...json,
'muted': intToBool(json['muted']! as int),
'open': intToBool(json['open']! as int),
'inRoster': inRoster,
'subscription': subscription,
'showAddToRoster': showAddToRoster,
'encrypted': intToBool(json['encrypted']! as int),
'chatState':
const ConversationChatStateConverter().toJson(ChatState.gone),
@ -107,8 +127,7 @@ class Conversation with _$Conversation {
final map = toJson()
..remove('id')
..remove('chatState')
..remove('inRoster')
..remove('subscription')
..remove('showAddToRoster')
..remove('lastMessage');
return {
@ -128,10 +147,10 @@ class Conversation with _$Conversation {
/// XMPP avatar's path.
String? get avatarPathWithOptionalContact {
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
return contactAvatarPath ?? avatarUrl;
return contactAvatarPath ?? avatarPath;
}
return avatarUrl;
return avatarPath;
}
/// The title of the chat. This returns, if enabled, first the contact's display

View File

@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/helpers.dart';
@ -201,9 +202,12 @@ class Message with _$Message {
/// Returns true if the message can be copied to the clipboard.
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
/// Returns true if the message is a sticker
/// Returns true if the message is a sticker.
bool get isSticker => isMedia && stickerPackId != null && !isPseudoMessage;
/// True if the message is a media message
/// True if the message is a media message.
bool get isMedia => fileMetadata != null;
/// The JID of the sender in moxxmpp's format.
JID get senderJid => JID.fromString(sender);
}

View File

@ -8,7 +8,7 @@ part 'roster.g.dart';
class RosterItem with _$RosterItem {
factory RosterItem(
int id,
String avatarUrl,
String avatarPath,
String avatarHash,
String jid,
String title,
@ -53,4 +53,24 @@ class RosterItem with _$RosterItem {
'pseudoRosterItem': boolToInt(pseudoRosterItem),
};
}
/// Whether a conversation with this roster item should display the "Add to roster" button.
bool get showAddToRosterButton {
// Those chats are not dealt with on the roster
if (pseudoRosterItem) {
return false;
}
// A full presence subscription is already achieved. Nothing to do
if (subscription == 'both') {
return false;
}
// We are not yet waiting for a response to the presence request
if (ask == 'subscribe' && ['none', 'from', 'to'].contains(subscription)) {
return false;
}
return true;
}
}

View File

@ -65,7 +65,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
RequestedConversationEvent(
result.conversation!.jid,
result.conversation!.title,
result.conversation!.avatarUrl,
result.conversation!.avatarPath,
removeUntilConversations: true,
),
);

View File

@ -102,7 +102,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
emit(
state.copyWith(
conversation: state.conversation!.copyWith(
inRoster: true,
showAddToRoster: false,
),
),
);

View File

@ -53,7 +53,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
state.copyWith(
displayName: event.displayName,
jid: event.jid,
avatarUrl: event.avatarUrl ?? '',
avatarPath: event.avatarUrl ?? '',
conversations: event.conversations..sort(compareConversation),
),
);
@ -118,7 +118,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
) async {
return emit(
state.copyWith(
avatarUrl: event.path,
avatarPath: event.path,
),
);
}

View File

@ -5,7 +5,7 @@ class ConversationsState with _$ConversationsState {
factory ConversationsState({
@Default(<Conversation>[]) List<Conversation> conversations,
@Default('') String displayName,
@Default('') String avatarUrl,
@Default('') String avatarPath,
@Default('') String jid,
}) = _ConversationsState;
}

View File

@ -90,16 +90,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
SetSubscriptionStateEvent event,
Emitter<ProfileState> emit,
) async {
emit(
state.copyWith(
conversation: state.conversation!.copyWith(
// NOTE: This is wrong, but we just keep it like this until the real result comes
// in.
subscription: event.shareStatus ? 'to' : 'from',
),
),
);
await MoxplatformPlugin.handler.getDataSender().sendData(
SetShareOnlineStatusCommand(jid: event.jid, share: event.shareStatus),
awaitable: false,

View File

@ -28,6 +28,7 @@ enum ShareSelectionType {
class ShareListItem {
const ShareListItem(
this.avatarPath,
this.avatarHash,
this.jid,
this.title,
this.isConversation,
@ -38,6 +39,7 @@ class ShareListItem {
this.contactDisplayName,
);
final String avatarPath;
final String? avatarHash;
final String jid;
final String title;
final bool isConversation;
@ -79,7 +81,8 @@ class ShareSelectionBloc
final items = List<ShareListItem>.from(
conversations.map((c) {
return ShareListItem(
c.avatarUrl,
c.avatarPath,
c.avatarHash,
c.jid,
c.title,
true,
@ -100,7 +103,8 @@ class ShareSelectionBloc
if (index == -1) {
items.add(
ShareListItem(
rosterItem.avatarUrl,
rosterItem.avatarPath,
rosterItem.avatarHash,
rosterItem.jid,
rosterItem.title,
false,
@ -113,7 +117,8 @@ class ShareSelectionBloc
);
} else {
items[index] = ShareListItem(
rosterItem.avatarUrl,
rosterItem.avatarPath,
rosterItem.avatarHash,
rosterItem.jid,
rosterItem.title,
false,
@ -187,7 +192,7 @@ class ShareSelectionBloc
SendMessageCommand(
recipients: _getRecipients(),
body: state.text!,
chatState: chatStateToString(ChatState.gone),
chatState: ChatState.gone.toName(),
),
);

View File

@ -324,7 +324,7 @@ class BidirectionalConversationController
recipients: [conversationJid],
body: text,
quotedMessage: _quotedMessage,
chatState: chatStateToString(ChatState.active),
chatState: ChatState.active.toName(),
editId: _messageEditingState?.id,
editSid: _messageEditingState?.sid,
currentConversationJid: conversationJid,

View File

@ -17,6 +17,7 @@ import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart' as stickers;
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
import 'package:moxxyv2/ui/prestart.dart';
import 'package:moxxyv2/ui/service/avatars.dart';
import 'package:moxxyv2/ui/service/progress.dart';
void setupEventHandler() {
@ -34,6 +35,9 @@ void setupEventHandler() {
EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
EventTypeMatcher<StickerPackAddedEvent>(onStickerPackAdded),
EventTypeMatcher<StreamNegotiationsCompletedEvent>(
onStreamNegotiationsDone,
),
]);
GetIt.I.registerSingleton<EventHandler>(handler);
@ -173,7 +177,7 @@ Future<void> onNotificationTappend(
conversation.RequestedConversationEvent(
event.conversationJid,
event.title,
event.avatarUrl,
event.avatarPath,
),
);
}
@ -186,3 +190,12 @@ Future<void> onStickerPackAdded(
stickers.StickerPackAddedEvent(event.stickerPack),
);
}
Future<void> onStreamNegotiationsDone(
StreamNegotiationsCompletedEvent event, {
dynamic extra,
}) async {
if (!event.resumed) {
GetIt.I.get<UIAvatarsService>().resetCache();
}
}

View File

@ -448,9 +448,12 @@ class ConversationPageState extends State<ConversationPage>
children: [
BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) =>
prev.conversation?.inRoster != next.conversation?.inRoster,
prev.conversation?.showAddToRoster !=
next.conversation?.showAddToRoster,
builder: (context, state) {
if ((state.conversation?.inRoster ?? false) ||
final showAddToRoster =
state.conversation?.showAddToRoster ?? false;
if (!showAddToRoster ||
state.conversation?.type == ConversationType.note) {
return const SizedBox();
}

View File

@ -49,7 +49,7 @@ class ConversationTopbar extends StatelessWidget
bool _shouldRebuild(ConversationState prev, ConversationState next) {
return prev.conversation?.title != next.conversation?.title ||
prev.conversation?.avatarUrl != next.conversation?.avatarUrl ||
prev.conversation?.avatarPath != next.conversation?.avatarPath ||
prev.conversation?.chatState != next.conversation?.chatState ||
prev.conversation?.jid != next.conversation?.jid ||
prev.conversation?.encrypted != next.conversation?.encrypted;
@ -110,14 +110,17 @@ class ConversationTopbar extends StatelessWidget
child: Material(
color: Colors.transparent,
child: RebuildOnContactIntegrationChange(
builder: () => AvatarWrapper(
radius: 25,
avatarUrl: state.conversation
?.avatarPathWithOptionalContact ??
'',
builder: () => CachingXMPPAvatar(
jid: state.conversation?.jid ?? '',
altText: state
.conversation?.titleWithOptionalContact ??
'A',
radius: 25,
hasContactId:
state.conversation?.contactId != null,
hash: state.conversation?.avatarHash,
path: state.conversation?.avatarPath,
shouldRequest: state.conversation != null,
),
),
),

View File

@ -158,7 +158,7 @@ class ConversationsPageState extends State<ConversationsPage>
RequestedConversationEvent(
item.jid,
item.title,
item.avatarUrl,
item.avatarPath,
),
),
key: key,
@ -251,7 +251,7 @@ class ConversationsPageState extends State<ConversationsPage>
profile.ProfilePageRequestedEvent(
true,
jid: state.jid,
avatarUrl: state.avatarUrl,
avatarUrl: state.avatarPath,
displayName: state.displayName,
),
);
@ -265,10 +265,17 @@ class ConversationsPageState extends State<ConversationsPage>
tag: 'self_profile_picture',
child: Material(
color: const Color.fromRGBO(0, 0, 0, 0),
child: AvatarWrapper(
// NOTE: We do not care about the avatar hash because
// we just read it from the XMPP state in the
// avatar service.
child: CachingXMPPAvatar(
radius: 20,
avatarUrl: state.avatarUrl,
path: state.avatarPath,
altText: state.jid[0],
altIcon: Icons.person,
hasContactId: false,
jid: state.jid,
ownAvatar: true,
),
),
),

View File

@ -108,14 +108,14 @@ class NewConversationPage extends StatelessWidget {
false,
false,
),
item.avatarUrl,
item.avatarPath,
item.avatarHash,
item.jid,
0,
ConversationType.chat,
0,
true,
true,
'',
false,
false,
ChatState.gone,
@ -130,7 +130,7 @@ class NewConversationPage extends StatelessWidget {
NewConversationAddedEvent(
item.jid,
item.title,
item.avatarUrl,
item.avatarPath,
ConversationType.chat,
),
),

View File

@ -1,7 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/debug.dart' as debug;
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
@ -148,6 +151,28 @@ class DebuggingPage extends StatelessWidget {
...kDebugMode
? [
const SectionTitle('Testing'),
SettingsRow(
title: 'Reset stream management state',
onTap: () {
MoxplatformPlugin.handler.getDataSender().sendData(
DebugCommand(
id: debug.DebugCommand.clearStreamResumption.id,
),
awaitable: false,
);
},
),
SettingsRow(
title: 'Request roster',
onTap: () {
MoxplatformPlugin.handler.getDataSender().sendData(
DebugCommand(
id: debug.DebugCommand.requestRoster.id,
),
awaitable: false,
);
},
),
SettingsRow(
title: 'Reset showDebugMenu state',
onTap: () {

View File

@ -76,13 +76,13 @@ class ShareSelectionPage extends StatelessWidget {
item.title,
null,
item.avatarPath,
item.avatarHash,
item.jid,
0,
ConversationType.chat,
0,
true,
true,
'',
false,
false,
ChatState.gone,

View File

@ -0,0 +1,30 @@
import 'package:logging/logging.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart';
class UIAvatarsService {
/// Logger
final Logger _log = Logger('UIAvatarsService');
/// Mapping between a JID and whether we have requested an avatar for the
/// JID already in the session (from login until stream resumption failure).
final Map<String, bool> _avatarRequested = {};
void requestAvatarIfRequired(String jid, String? hash, bool ownAvatar) {
if (_avatarRequested[jid] ?? false) return;
_log.finest('Requesting avatar for $jid');
_avatarRequested[jid] = true;
MoxplatformPlugin.handler.getDataSender().sendData(
RequestAvatarForJidCommand(
jid: jid,
hash: hash,
ownAvatar: ownAvatar,
),
);
}
void resetCache() {
_avatarRequested.clear();
}
}

View File

@ -1,6 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/service/avatars.dart';
import 'package:moxxyv2/ui/theme.dart';
class AvatarWrapper extends StatelessWidget {
@ -99,3 +102,90 @@ class AvatarWrapper extends StatelessWidget {
);
}
}
class CachingXMPPAvatar extends StatefulWidget {
const CachingXMPPAvatar({
required this.jid,
required this.altText,
required this.radius,
required this.hasContactId,
this.altIcon,
this.shouldRequest = true,
this.ownAvatar = false,
this.hash,
this.path,
super.key,
});
/// The JID of the entity.
final String jid;
/// The hash of the JID's avatar or null, if we don't know of an avatar.
final String? hash;
/// The alt-text, if [path] is null.
final String altText;
/// The (potentially null) path to the avatar image.
final String? path;
/// The radius of the avatar widget.
final double radius;
/// Flag indicating whether the conversation has a contactId != null.
final bool hasContactId;
/// Flag indicating whether a request for the avatar should happen or not.
final bool shouldRequest;
/// Alt-icon, if [path] is null.
final IconData? altIcon;
/// Flag indicating whether this avatar is our own avatar.
final bool ownAvatar;
@override
CachingXMPPAvatarState createState() => CachingXMPPAvatarState();
}
class CachingXMPPAvatarState extends State<CachingXMPPAvatar> {
void _performRequest() {
// Only request the avatar if we don't have a contact integration avatar already.
if (!GetIt.I.get<PreferencesBloc>().state.enableContactIntegration ||
!widget.hasContactId) {
GetIt.I.get<UIAvatarsService>().requestAvatarIfRequired(
widget.jid,
widget.hash,
widget.ownAvatar,
);
}
}
@override
void initState() {
super.initState();
if (!widget.shouldRequest) return;
_performRequest();
}
@override
void didUpdateWidget(CachingXMPPAvatar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.shouldRequest && !oldWidget.shouldRequest) {
_performRequest();
}
}
@override
Widget build(BuildContext context) {
return AvatarWrapper(
avatarUrl: widget.path,
altText: widget.altText,
altIcon: widget.altIcon,
radius: widget.radius,
);
}
}

View File

@ -81,12 +81,12 @@ class ReactionList extends StatelessWidget {
return ReactionsRow(
avatar: ownReaction
? AvatarWrapper(
avatarUrl: bloc.state.avatarUrl,
avatarUrl: bloc.state.avatarPath,
radius: 35,
altIcon: Icons.person,
)
: AvatarWrapper(
avatarUrl: conversation?.avatarUrl,
avatarUrl: conversation?.avatarPath,
radius: 35,
altIcon: Icons.person,
),

View File

@ -180,10 +180,14 @@ class ConversationsListRowState extends State<ConversationsListRow> {
Widget _buildAvatar() {
return RebuildOnContactIntegrationChange(
builder: () {
final avatar = AvatarWrapper(
final avatar = CachingXMPPAvatar(
radius: 35,
avatarUrl: widget.conversation.avatarPathWithOptionalContact,
jid: widget.conversation.jid,
hash: widget.conversation.avatarHash,
altText: widget.conversation.titleWithOptionalContact,
path: widget.conversation.avatarPathWithOptionalContact,
hasContactId: widget.conversation.contactId != null,
shouldRequest: widget.conversation.type != ConversationType.note,
);
if (widget.enableAvatarOnTap &&

View File

@ -34,13 +34,6 @@
<xmpp:version>2.5rc3</xmpp:version>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0054.html" />
<xmpp:status>partial</xmpp:status>
<xmpp:version>1.2</xmpp:version>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0060.html" />

View File

@ -954,10 +954,10 @@ packages:
description:
path: "packages/moxxmpp"
ref: HEAD
resolved-ref: "1475cb542f7d68824f8f4b11efd751cfb3eea428"
resolved-ref: f2d8c6a009d3f08806e1565bf9b92407e659f178
url: "https://codeberg.org/moxxy/moxxmpp.git"
source: git
version: "0.3.2"
version: "0.4.0"
moxxmpp_socket_tcp:
dependency: "direct main"
description:

View File

@ -71,7 +71,7 @@ dependencies:
version: 0.1.15
moxxmpp:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.3.1
version: 0.4.0
moxxmpp_socket_tcp:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.3.1
@ -139,7 +139,7 @@ dependency_overrides:
moxxmpp:
git:
url: https://codeberg.org/moxxy/moxxmpp.git
rev: 1475cb542f7d68824f8f4b11efd751cfb3eea428
rev: f2d8c6a009d3f08806e1565bf9b92407e659f178
path: packages/moxxmpp
extra_licenses:

19
scripts/check-pr.sh Normal file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# Run before merging a PR. Checks if the formatting is correct.
# Check if there are any formatting issues
echo "Checking formatting..."
dart format --output none --set-exit-if-changed . &> /dev/null
if [[ ! "$?" = "0" ]]; then
echo "Error: dart format indicates that format the code is not formatted properly"
exit 1
fi
# Check if the linter has any issues
echo "Checking linter..."
flutter analyze &> /dev/null
if [[ ! "$?" = "0" ]]; then
echo "Error: flutter analyze indicates that there are lint issues"
exit 1
fi
echo "PR looks good!"