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:moxlib/moxlib.dart'; import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxyv2/service/conversation.dart'; import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/roster.dart'; import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/xmpp.dart'; 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 AvatarService { AvatarService() : _log = Logger('AvatarService'); final Logger _log; UserAvatarManager _getUserAvatarManager() => GetIt.I.get().getManagerById(userAvatarManager)!; DiscoManager _getDiscoManager() => GetIt.I.get().getManagerById(discoManager)!; Future updateAvatarForJid(String jid, String hash, String base64) async { final cs = GetIt.I.get(); final rs = GetIt.I.get(); final originalConversation = await cs.getConversationByJid(jid); var saved = false; // 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, hash, jid, originalConversation.avatarUrl, ); saved = true; final conv = await cs.updateConversation( originalConversation.id, avatarUrl: avatarPath, ); sendEvent(ConversationUpdatedEvent(conversation: conv)); } else { _log.warning('Failed to get conversation'); } final originalRoster = await rs.getRosterItemByJid(jid); if (originalRoster != null) { var avatarPath = ''; if (saved) { avatarPath = await getAvatarPath(jid, hash); } else { avatarPath = await saveAvatarInCache( base64Data, hash, jid, originalRoster.avatarUrl, ); } final roster = await rs.updateRosterItem( originalRoster.id, avatarUrl: avatarPath, avatarHash: hash, ); sendEvent(RosterDiffEvent(modified: [roster])); } } Future fetchAndUpdateAvatarForJid(String jid, String oldHash) async { final response = await _getDiscoManager().discoItemsQuery(jid); final items = response.isType() ? [] : response.get>(); final itemNodes = items.map((i) => i.node); _log.finest('Disco items for $jid:'); for (final item in itemNodes) { _log.finest('- $item'); } var base64 = ''; var hash = ''; if (listContains(items, (item) => item.node == userAvatarDataXmlns)) { final avatar = _getUserAvatarManager(); final pubsubHash = await avatar.getAvatarId(jid); // Don't request if we already have the newest avatar if (pubsubHash == oldHash) return; // Query via PubSub final data = await avatar.getUserAvatar(jid); if (data == null) return; base64 = data.base64; hash = data.hash; } else { // Query the vCard final vm = GetIt.I.get().getManagerById(vcardManager)!; final vcard = await vm.requestVCard(jid); if (vcard != null) { final binval = vcard.photo?.binval; if (binval != null) { // Clean the raw data. Since this may arrive by chunks, those chunks may contain // weird data pieces. base64 = _cleanBase64String(binval); final rawHash = await Sha1().hash(base64Decode(base64)); hash = HEX.encode(rawHash.bytes); vm.setLastHash(jid, hash); } else { return; } } else { return; } } await updateAvatarForJid(jid, hash, base64); } Future subscribeJid(String jid) async { return _getUserAvatarManager().subscribe(jid); } Future unsubscribeJid(String jid) async { return _getUserAvatarManager().unsubscribe(jid); } /// 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. Future publishAvatar(String path, String hash) async { final file = File(path); final bytes = await file.readAsBytes(); final base64 = base64Encode(bytes); final prefs = await GetIt.I.get().getPreferences(); final public = prefs.isAvatarPublic; // Read the image metadata final imageSize = (await getImageSizeFromData(bytes))!; // Publish data and metadata final manager = _getUserAvatarManager(); await manager.publishUserAvatar( base64, hash, public, ); await manager.publishUserAvatarMetadata( UserAvatarMetadata( hash, bytes.length, imageSize.width.toInt(), imageSize.height.toInt(), // TODO(PapaTutuWawa): Maybe do a check here 'image/png', ), public, ); return true; } Future requestOwnAvatar() async { final avatar = _getUserAvatarManager(); final xmpp = GetIt.I.get(); final state = await xmpp.getXmppState(); final jid = state.jid!; final id = await avatar.getAvatarId(jid); if (id == state.avatarHash) return; _log.info('Mismatch between saved avatar data and server-side avatar data about ourself'); final data = await avatar.getUserAvatar(jid); if (data == null) { _log.severe('Failed to fetch our avatar'); return; } _log.info('Received data for our own avatar'); final avatarPath = await saveAvatarInCache( base64Decode(_cleanBase64String(data.base64)), data.hash, jid, state.avatarUrl, ); await xmpp.modifyXmppState((state) => state.copyWith( avatarUrl: avatarPath, avatarHash: data.hash, ),); sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: data.hash)); } }