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'; 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 _AvatarData { const _AvatarData(this.data, this.id); final List data; final String id; } class AvatarService { final Logger _log = Logger('AvatarService'); Future handleAvatarUpdate(AvatarUpdatedEvent event) async { await updateAvatarForJid( event.jid, event.hash, base64Decode(_cleanBase64String(event.base64)), ); } Future updateAvatarForJid(String jid, String hash, List data) 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. if (originalConversation != null) { final avatarPath = await saveAvatarInCache( data, 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( data, hash, jid, originalRoster.avatarUrl, ); } final roster = await rs.updateRosterItem( originalRoster.id, avatarUrl: avatarPath, avatarHash: hash, ); sendEvent(RosterDiffEvent(modified: [roster])); } } Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async { final am = GetIt.I.get() .getManagerById(userAvatarManager)!; final idResult = await am.getAvatarId(jid); if (idResult.isType()) { _log.warning('Failed to get avatar id via XEP-0084 for $jid'); return null; } final id = idResult.get(); if (id == oldHash) return null; final avatarResult = await am.getUserAvatar(jid); if (avatarResult.isType()) { _log.warning('Failed to get avatar data via XEP-0084 for $jid'); return null; } final avatar = avatarResult.get(); return _AvatarData( base64Decode(_cleanBase64String(avatar.base64)), avatar.hash, ); } Future<_AvatarData?> _handleVcardAvatar(String jid, String oldHash) async { // Query the vCard final vm = GetIt.I.get() .getManagerById(vcardManager)!; final vcardResult = await vm.requestVCard(jid); if (vcardResult.isType()) return null; final binval = vcardResult.get().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 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 subscribeJid(String jid) async { return (await GetIt.I.get() .getManagerById(userAvatarManager)! .subscribe(jid)).isType(); } Future unsubscribeJid(String jid) async { return (await GetIt.I.get() .getManagerById(userAvatarManager)! .unsubscribe(jid)).isType(); } /// 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 am = GetIt.I.get() .getManagerById(userAvatarManager)!; await am.publishUserAvatar( base64, hash, public, ); await am.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 am = GetIt.I.get() .getManagerById(userAvatarManager)!; final xmpp = GetIt.I.get(); final state = await xmpp.getXmppState(); final jid = state.jid!; final idResult = await am.getAvatarId(jid); if (idResult.isType()) { _log.info('Error while getting latest avatar id for own avatar'); return; } final id = idResult.get(); if (id == state.avatarHash) return; _log.info('Mismatch between saved avatar data and server-side avatar data about ourself'); final avatarDataResult = await am.getUserAvatar(jid); if (avatarDataResult.isType()) { _log.severe('Failed to fetch our avatar'); return; } final avatarData = avatarDataResult.get(); _log.info('Received data for our own avatar'); final avatarPath = await saveAvatarInCache( base64Decode(_cleanBase64String(avatarData.base64)), avatarData.hash, jid, state.avatarUrl, ); await xmpp.modifyXmppState((state) => state.copyWith( avatarUrl: avatarPath, avatarHash: avatarData.hash, ),); sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: avatarData.hash)); } }