Files
moxxy/lib/service/avatars.dart

565 lines
17 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:cryptography/cryptography.dart';
import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/cache.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/groupchat.dart';
import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/preferences.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/helpers.dart';
import 'package:path/path.dart' as p;
class AvatarService {
final Logger _log = Logger('AvatarService');
/// List of JIDs for which we have already requested the avatar in the current stream.
final List<JID> _requestedInStream = [];
/// Cached version of the path to the avatar cache. Used to prevent constant calls
/// to the native side.
late final String _avatarCacheDir;
/// Computes the path to use for cached avatars.
static Future<String> getCachePath() async =>
computeCacheDirectoryPath('avatars');
@visibleForTesting
void initializeForTesting(String cacheDir) {
_avatarCacheDir = cacheDir;
}
Future<void> initialize() async {
_avatarCacheDir = await getCachePath();
}
void resetCache() {
_requestedInStream.clear();
}
String _computeAvatarPath(String hash) => p.join(_avatarCacheDir, hash);
/// Returns whether we can remove the avatar file at [path] by checking if the
/// avatar is referenced by any other conversation. If [ignoreSelf] is true, then
/// our own avatar is also taken into consideration.
@visibleForTesting
Future<bool> canRemoveAvatar(String path, bool ignoreSelf) async {
final db = GetIt.I.get<DatabaseService>().database;
final result = await db.rawQuery(
'''
SELECT
COUNT(c.avatarPath) as usage_conversation,
COUNT(p.avatarPath) as usage_members
FROM
$conversationsTable as c,
$groupchatMembersTable as p
WHERE
c.avatarPath = ? OR p.avatarPath = ?
''',
[path, path],
);
final ownModifier =
(await GetIt.I.get<XmppStateService>().state).avatarUrl == path &&
!ignoreSelf
? 1
: 0;
final usageConversation = result.first['usage_conversation']! as int;
final usageGroupchat = result.first['usage_members']! as int;
_log.finest(
'Avatar usage for $path: $usageConversation (conversations) + $usageGroupchat (groupchat)',
);
return usageConversation + usageGroupchat + ownModifier == 0;
}
/// Remove the avatar file at [path], if [path] is non-null and [canRemoveAvatar] approves.
/// [ignoreSelf] is passed to [canRemoveAvatar]'s ignoreSelf parameter.
Future<void> safeRemoveAvatar(String? path, bool ignoreSelf) async {
if (path == null) {
return;
}
if (await canRemoveAvatar(path, ignoreSelf) && File(path).existsSync()) {
await File(path).delete();
}
}
/// Checks if the avatar with the specified hash already exists on disk.
bool _hasAvatar(String hash) => File(_computeAvatarPath(hash)).existsSync();
/// Save the avatar, described by the raw bytes [bytes] and its hash [hash], into
/// the avatar cache directory.
Future<void> _saveAvatarInCache(List<int> bytes, String hash) async {
final dir = Directory(_avatarCacheDir);
if (!dir.existsSync()) {
await dir.create(recursive: true);
}
// Write the avatar
await File(_computeAvatarPath(hash)).writeAsBytes(bytes);
}
/// Fetches the avatar with id [id] for [jid], if we don't already have it locally.
Future<String?> _maybeFetchAvatarForJid(
JID jid,
String id,
bool useVCard,
) async {
// Check if we even have to request it.
if (_hasAvatar(id)) {
return _computeAvatarPath(id);
}
List<int> data;
String hexHash;
if (useVCard) {
// Use XEP-0153/XEP-0054.
// Get the VCard.
final vm = GetIt.I
.get<XmppConnection>()
.getManagerById<VCardManager>(vcardManager)!;
final vcardResult = await vm.requestVCard(jid);
if (vcardResult.isType<VCardError>()) {
_log.warning('Failed to request vcard of $jid');
return null;
}
final vcard = vcardResult.get<VCard>();
// Check if we have a photo
if (vcard.photo?.binval == null) {
_log.warning('VCard of $jid does not contain a photo.');
return null;
}
data = base64Decode(vcard.photo!.binval!);
hexHash = id;
} else {
// Use XEP-0084.
// Request the avatar data and write it to disk.
final rawAvatar = await GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!
.getUserAvatarData(jid, id);
if (rawAvatar.isType<AvatarError>()) {
_log.warning('Failed to request avatar ($jid, $id)');
return null;
}
// Verify the hash
data = rawAvatar.get<UserAvatarData>().data;
hexHash = rawAvatar.get<UserAvatarData>().hash;
final actualHexHash = HEX.encode(
(await Sha1().hash(data)).bytes,
);
if (actualHexHash != hexHash) {
_log.warning(
'Avatar hash of $jid ($hexHash) is not equal to the computed hash ($actualHexHash)',
);
return null;
}
}
await _saveAvatarInCache(
data,
hexHash,
);
return _computeAvatarPath(id);
}
Future<void> _applyNewAvatarToJid(JID jid, String hash) async {
assert(_hasAvatar(hash), 'The avatar must exist');
final cs = GetIt.I.get<ConversationService>();
final rs = GetIt.I.get<RosterService>();
final accountJid = (await GetIt.I.get<XmppStateService>().getAccountJid())!;
final conversation =
await cs.getConversationByJid(jid.toString(), accountJid);
final rosterItem = await rs.getRosterItemByJid(jid.toString(), accountJid);
// Do nothing if we do not know of the JID.
if (conversation == null && rosterItem == null) {
_log.info('Found no conversation or roster item with jid $jid');
return;
}
// Update the conversation
final avatarPath = _computeAvatarPath(hash);
if (conversation != null) {
final newConversation = await cs.createOrUpdateConversation(
jid.toString(),
accountJid,
update: (c) async {
return cs.updateConversation(
jid.toString(),
accountJid,
avatarPath: avatarPath,
avatarHash: hash,
);
},
);
sendEvent(
ConversationUpdatedEvent(conversation: newConversation!),
);
// Try to delete the old avatar
await safeRemoveAvatar(conversation.avatarPath, false);
}
// Update the roster item
if (rosterItem != null) {
final newRosterItem = await rs.updateRosterItem(
jid.toString(),
accountJid,
avatarPath: avatarPath,
avatarHash: hash,
);
sendEvent(
RosterDiffEvent(modified: [newRosterItem]),
);
// Try to delete the old avatar
await safeRemoveAvatar(rosterItem.avatarPath, false);
}
// Update the UI.
sendEvent(
AvatarUpdatedEvent(jid: jid.toString(), path: avatarPath),
);
}
Future<void> handleAvatarUpdate(UserAvatarUpdatedEvent event) async {
if (event.metadata.isEmpty) {
return;
}
// Add the JID to the pending requests list.
_requestedInStream.add(event.jid);
// Fetch the new avatar.
final metadata = event.metadata
.firstWhereOrNull((element) => element.type == 'image/png');
if (metadata == null) {
_log.warning(
'Avatar metadata from ${event.jid} does not advertise an image/png avatar, which violates XEP-0084',
);
return;
}
final newAvatarPath = await _maybeFetchAvatarForJid(
event.jid,
metadata.id,
false,
);
if (newAvatarPath == null) {
_log.warning('Failed to fetch avatar ${metadata.id} for ${event.jid}');
_requestedInStream.remove(event.jid);
return;
}
// Update the conversation.
await _applyNewAvatarToJid(event.jid, metadata.id);
// Remove the JID from the pending requests list.
_requestedInStream.remove(event.jid);
}
Future<String?> requestGroupchatAvatar(JID roomJid, String? oldHash) async {
// Prevent multiple requests in a row.
if (_requestedInStream.contains(roomJid)) {
return null;
}
_requestedInStream.add(roomJid);
// Perform a disco request.
final id =
await GetIt.I.get<GroupchatService>().getGroupchatAvatarHash(roomJid);
if (id == null) {
return null;
}
// Check if the id changed.
var bypassIdCheck = false;
if (oldHash != null && !File(_computeAvatarPath(oldHash)).existsSync()) {
bypassIdCheck = true;
_log.finest('Avatar hash $oldHash does not exist. Bypass id check');
bypassIdCheck = true;
}
if (id == oldHash && !bypassIdCheck) {
_log.finest(
'Remote id ($id) is equal to local id ($oldHash) for $roomJid. Not fetching avatar.',
);
_requestedInStream.remove(roomJid);
return _computeAvatarPath(id);
}
// Fetch the avatar.
final newAvatarPath = await _maybeFetchAvatarForJid(
roomJid,
id,
true,
);
if (newAvatarPath == null) {
_log.finest('Failed to request MUC avatar for $roomJid');
_requestedInStream.remove(roomJid);
return null;
}
// Update conversation.
await _applyNewAvatarToJid(roomJid, id);
// Remove the JID from the pending requests list.
_requestedInStream.remove(roomJid);
return _computeAvatarPath(id);
}
/// Request the avatar for [jid], given its optional previous avatar hash [oldHash].
Future<String?> requestAvatar(JID jid, String? oldHash) async {
// Prevent multiple requests in a row.
if (_requestedInStream.contains(jid)) {
return null;
}
_requestedInStream.add(jid);
// Request the latest metadata.
final rawMetadata = await GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!
.getLatestMetadata(jid);
if (rawMetadata.isType<AvatarError>()) {
_log.warning('Failed to get metadata for $jid');
_requestedInStream.remove(jid);
return null;
}
// Find the first metadata item that advertises a PNG avatar.
final metadata = rawMetadata.get<List<UserAvatarMetadata>>();
var id =
metadata.firstWhereOrNull((element) => element.type == 'image/png')?.id;
if (id == null) {
_log.warning(
'$jid does not advertise an avatar of type image/png, which violates XEP-0084',
);
if (metadata.isEmpty) {
_log.warning(
'$jid does not advertise any metadata.',
);
_requestedInStream.remove(jid);
return null;
}
// If other avatar types are present, sort the list to make the selection stable
// and then just pick the first one.
final typeSortedMetadata = List.of(metadata)
..sort((a, b) => a.type.compareTo(b.type));
final firstMetadata = typeSortedMetadata.first;
_log.warning(
'Falling back to ${firstMetadata.id} (${firstMetadata.type})',
);
id = firstMetadata.id;
}
// Check if the id changed.
var bypassIdCheck = false;
if (oldHash != null && !File(_computeAvatarPath(oldHash)).existsSync()) {
bypassIdCheck = true;
_log.finest('Avatar hash $oldHash does not exist. Bypass id check');
bypassIdCheck = true;
}
if (id == oldHash && !bypassIdCheck) {
_log.finest(
'Remote id ($id) is equal to local id ($oldHash) for $jid. Not fetching avatar.',
);
_requestedInStream.remove(jid);
return _computeAvatarPath(id);
}
// Request the new avatar.
final newAvatarPath = await _maybeFetchAvatarForJid(
jid,
id,
false,
);
if (newAvatarPath == null) {
_log.warning('Failed to request avatar for $jid');
_requestedInStream.remove(jid);
return null;
}
// Update conversations.
await _applyNewAvatarToJid(jid, id);
// Remove the JID from the pending requests list.
_requestedInStream.remove(jid);
return _computeAvatarPath(id);
}
/// Request the avatar for our own avatar.
Future<bool> requestOwnAvatar() async {
final xss = GetIt.I.get<XmppStateService>();
final jid = JID.fromString((await xss.getAccountJid())!);
// Prevent multiple requests in a row.
if (_requestedInStream.contains(jid)) {
return true;
}
_requestedInStream.add(jid);
// Get the current id.
final state = await xss.state;
final rawMetadata = await GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!
.getLatestMetadata(jid);
if (rawMetadata.isType<AvatarError>()) {
_log.warning('rawMetadata is an AvatarError');
return false;
}
// Find the first metadata item that advertises a PNG avatar.
final id = rawMetadata
.get<List<UserAvatarMetadata>>()
.firstWhereOrNull((element) => element.type == 'image/png')
?.id;
if (id == null) {
_log.warning(
'We ($jid) do not advertise an avatar of type image/png, which violates XEP-0084',
);
return false;
}
// Check if the avatar even changed.
var bypassIdCheck = false;
if (state.avatarUrl != null && !File(state.avatarUrl!).existsSync()) {
bypassIdCheck = true;
_log.finest(
'Avatar path ${state.avatarUrl} does not exist. Bypass id check',
);
bypassIdCheck = true;
}
if (id == state.avatarHash && !bypassIdCheck) {
_log.finest(
'Not requesting our own avatar because the server-side id ($id) is equal to our current id (${state.avatarHash})',
);
_requestedInStream.remove(jid);
return true;
}
// Request the new avatar.
final oldAvatarPath = state.avatarUrl;
final newAvatarPath = await _maybeFetchAvatarForJid(
jid,
id,
false,
);
if (newAvatarPath == null) {
_log.warning('Failed to request own avatar');
_requestedInStream.remove(jid);
return false;
}
// Update the state and the UI.
await xss.modifyXmppState(
(s) {
return s.copyWith(
avatarUrl: newAvatarPath,
avatarHash: id,
);
},
);
sendEvent(SelfAvatarChangedEvent(path: newAvatarPath, hash: id));
// Try to safely delete the old avatar.
await safeRemoveAvatar(oldAvatarPath, true);
// Update the notification UI.
await GetIt.I.get<NotificationsService>().maybeSetAvatarFromState();
// Remove our JID from the pending requests list.
_requestedInStream.remove(jid);
return true;
}
Future<bool> setNewAvatar(String path, String hash) async {
final file = File(path);
final bytes = await file.readAsBytes();
final base64 = base64Encode(bytes);
final isPublic = (await GetIt.I.get<PreferencesService>().getPreferences())
.isAvatarPublic;
// Copy the avatar into the cache, if we don't already have it.
final avatarPath = _computeAvatarPath(hash);
if (!_hasAvatar(hash)) {
await file.copy(avatarPath);
}
// Get image metadata.
final imageSize = (await getImageSizeFromPath(avatarPath))!;
// Publish data and metadata
final am = GetIt.I
.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!;
_log.finest('Publishing avatar');
final dataResult = await am.publishUserAvatar(base64, hash, isPublic);
if (dataResult.isType<AvatarError>()) {
_log.warning('Failed to publish avatar data');
return false;
}
// Publish the metadata.
final metadataResult = await am.publishUserAvatarMetadata(
UserAvatarMetadata(
hash,
bytes.length,
imageSize.width.toInt(),
imageSize.height.toInt(),
// TODO(Unknown): Make sure
'image/png',
null,
),
isPublic,
);
if (metadataResult.isType<AvatarError>()) {
_log.warning('Failed to publish avatar metadata');
return false;
}
// Update the state
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.state;
final oldAvatarPath = state.avatarUrl;
await xss.modifyXmppState(
(s) {
return s.copyWith(
avatarUrl: avatarPath,
avatarHash: hash,
);
},
);
// Update the UI
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: hash));
// Update the notifications.
await GetIt.I.get<NotificationsService>().maybeSetAvatarFromState();
// Safely remove the old avatar
await safeRemoveAvatar(oldAvatarPath, true);
// Remove the temp file.
await file.delete();
return true;
}
}