Merge pull request #1883 from famedly/krille/refactor-profile-caching-in-database

refactor: Cache profiles in database and refactor API
This commit is contained in:
Krille-chan 2024-07-23 10:37:30 +02:00 committed by GitHub
commit 805ee24d93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 274 additions and 67 deletions

View File

@ -52,6 +52,7 @@ export 'src/voip/utils/wrapped_media_stream.dart';
export 'src/room.dart'; export 'src/room.dart';
export 'src/timeline.dart'; export 'src/timeline.dart';
export 'src/user.dart'; export 'src/user.dart';
export 'src/utils/cached_profile_information.dart';
export 'src/utils/commands_extension.dart'; export 'src/utils/commands_extension.dart';
export 'src/utils/crypto/encrypted_file.dart'; export 'src/utils/crypto/encrypted_file.dart';
export 'src/utils/device_keys_list.dart'; export 'src/utils/device_keys_list.dart';

View File

@ -91,14 +91,14 @@ class MatrixWidget {
// See https://github.com/matrix-org/matrix-doc/issues/1236 for a // See https://github.com/matrix-org/matrix-doc/issues/1236 for a
// description, specifically the section // description, specifically the section
// `What does the other stuff in content mean?` // `What does the other stuff in content mean?`
final userProfile = await room.client.fetchOwnProfile(); final userProfile = await room.client.getUserProfile(room.client.userID!);
var parsedUri = url; var parsedUri = url;
// a key-value map with the strings to be replaced // a key-value map with the strings to be replaced
final replaceMap = { final replaceMap = {
r'$matrix_user_id': userProfile.userId, r'$matrix_user_id': room.client.userID!,
r'$matrix_room_id': room.id, r'$matrix_room_id': room.id,
r'$matrix_display_name': userProfile.displayName ?? '', r'$matrix_display_name': userProfile.displayname ?? '',
r'$matrix_avatar_url': userProfile.avatarUrl?.toString() ?? '', r'$matrix_avatar_url': userProfile.avatarUrl?.toString() ?? '',
// removing potentially dangerous keys containing anything but // removing potentially dangerous keys containing anything but
// `[a-zA-Z0-9_-]` as well as non string values // `[a-zA-Z0-9_-]` as well as non string values

View File

@ -911,7 +911,7 @@ class Client extends MatrixApi {
return id; return id;
} }
@Deprecated('Use fetchOwnProfile() instead') @Deprecated('Use getUserProfile(userID) instead')
Future<Profile> get ownProfile => fetchOwnProfile(); Future<Profile> get ownProfile => fetchOwnProfile();
/// Returns the user's own displayname and avatar url. In Matrix it is possible that /// Returns the user's own displayname and avatar url. In Matrix it is possible that
@ -919,6 +919,7 @@ class Client extends MatrixApi {
/// Tries to get the profile from homeserver first, if failed, falls back to a profile /// Tries to get the profile from homeserver first, if failed, falls back to a profile
/// from a room where the user exists. Set `useServerCache` to true to get any /// from a room where the user exists. Set `useServerCache` to true to get any
/// prior value from this function /// prior value from this function
@Deprecated('Use fetchOwnProfile() instead')
Future<Profile> fetchOwnProfileFromServer( Future<Profile> fetchOwnProfileFromServer(
{bool useServerCache = false}) async { {bool useServerCache = false}) async {
try { try {
@ -943,64 +944,86 @@ class Client extends MatrixApi {
/// This returns the profile from the first room by default, override `getFromRooms` /// This returns the profile from the first room by default, override `getFromRooms`
/// to false to fetch from homeserver. /// to false to fetch from homeserver.
Future<Profile> fetchOwnProfile({ Future<Profile> fetchOwnProfile({
bool getFromRooms = true, @Deprecated('No longer supported') bool getFromRooms = true,
bool cache = true, @Deprecated('No longer supported') bool cache = true,
}) => }) =>
getProfileFromUserId( getProfileFromUserId(userID!);
userID!,
getFromRooms: getFromRooms, /// Get the combined profile information for this user. First checks for a
cache: cache, /// non outdated cached profile before requesting from the server. Cached
); /// profiles are outdated if they have been cached in a time older than the
/// [maxCacheAge] or they have been marked as outdated by an event in the
final Map<String, ProfileInformation> _profileRoomsCache = {}; /// sync loop.
final Map<String, ProfileInformation> _profileServerCache = {}; /// In case of an
///
/// Get the combined profile information for this user. /// [userId] The user whose profile information to get.
/// If [getFromRooms] is true then the profile will first be searched from the @override
/// room memberships. This is unstable if the given user makes use of different displaynames Future<CachedProfileInformation> getUserProfile(
/// and avatars per room, which is common for some bots and bridges. String userId, {
/// If [cache] is true then Duration timeout = const Duration(seconds: 30),
/// the profile get cached for this session. Please note that then the profile may Duration maxCacheAge = const Duration(days: 1),
/// become outdated if the user changes the displayname or avatar in this session. }) async {
Future<Profile> getProfileFromUserId(String userId, final cachedProfile = await database?.getUserProfile(userId);
{bool cache = true, bool getFromRooms = true}) async { if (cachedProfile != null &&
var profile = !cachedProfile.outdated &&
getFromRooms ? _profileRoomsCache[userId] : _profileServerCache[userId]; DateTime.now().difference(cachedProfile.updated) < maxCacheAge) {
if (cache && profile != null) { return cachedProfile;
return Profile( }
userId: userId,
displayName: profile.displayname, final ProfileInformation profile;
avatarUrl: profile.avatarUrl, try {
profile = await (_userProfileRequests[userId] ??=
super.getUserProfile(userId).timeout(timeout));
} catch (e) {
Logs().d('Unable to fetch profile from server', e);
if (cachedProfile == null) rethrow;
return cachedProfile;
} finally {
unawaited(_userProfileRequests.remove(userId));
}
final newCachedProfile = CachedProfileInformation.fromProfile(
profile,
outdated: false,
updated: DateTime.now(),
);
await database?.storeUserProfile(userId, newCachedProfile);
return newCachedProfile;
}
final Map<String, Future<ProfileInformation>> _userProfileRequests = {};
final CachedStreamController<String> onUserProfileUpdate =
CachedStreamController<String>();
/// Get the combined profile information for this user from the server or
/// from the cache depending on the cache value. Returns a `Profile` object
/// including the given userId but without information about how outdated
/// the profile is. If you need those, try using `getUserProfile()` instead.
Future<Profile> getProfileFromUserId(
String userId, {
@Deprecated('No longer supported') bool? getFromRooms,
@Deprecated('No longer supported') bool? cache,
Duration timeout = const Duration(seconds: 30),
Duration maxCacheAge = const Duration(days: 1),
}) async {
CachedProfileInformation? cachedProfileInformation;
try {
cachedProfileInformation = await getUserProfile(
userId,
timeout: timeout,
maxCacheAge: maxCacheAge,
); );
} catch (e) {
Logs().d('Unable to fetch profile for $userId', e);
} }
if (getFromRooms) {
final room = rooms.firstWhereOrNull((Room room) =>
room.getParticipants().indexWhere((User user) => user.id == userId) !=
-1);
if (room != null) {
final user =
room.getParticipants().firstWhere((User user) => user.id == userId);
final profileFromRooms = Profile(
userId: userId,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
);
_profileRoomsCache[userId] = ProfileInformation(
avatarUrl: profileFromRooms.avatarUrl,
displayname: profileFromRooms.displayName,
);
return profileFromRooms;
}
}
profile = await getUserProfile(userId);
if (cache || _profileServerCache.containsKey(userId)) {
_profileServerCache[userId] = profile;
}
return Profile( return Profile(
userId: userId, userId: userId,
displayName: profile.displayname, displayName: cachedProfileInformation?.displayname,
avatarUrl: profile.avatarUrl, avatarUrl: cachedProfileInformation?.avatarUrl,
); );
} }
@ -2295,6 +2318,19 @@ class Client extends MatrixApi {
content: update.content))); content: update.content)));
} }
} }
// Any kind of member change? We should invalidate the profile then:
if (event is StrippedStateEvent && event.type == EventTypes.RoomMember) {
final userId = event.stateKey;
if (userId != null) {
// We do not re-request the profile here as this would lead to
// an unknown amount of network requests as we never know how many
// member change events can come down in a single sync update.
await database?.markUserProfileAsOutdated(userId);
onUserProfileUpdate.add(userId);
}
}
if (event.type == EventTypes.Message && if (event.type == EventTypes.Message &&
!room.isDirectChat && !room.isDirectChat &&
database != null && database != null &&

View File

@ -81,6 +81,13 @@ abstract class DatabaseApi {
Future<void> forgetRoom(String roomId); Future<void> forgetRoom(String roomId);
Future<CachedProfileInformation?> getUserProfile(String userId);
Future<void> storeUserProfile(
String userId, CachedProfileInformation profile);
Future<void> markUserProfileAsOutdated(String userId);
Future<void> clearCache(); Future<void> clearCache();
Future<void> clear(); Future<void> clear();

View File

@ -1597,6 +1597,22 @@ class HiveCollectionsDatabase extends DatabaseApi {
@override @override
Future<void> delete() => _collection.deleteFromDisk(); Future<void> delete() => _collection.deleteFromDisk();
@override
Future<void> markUserProfileAsOutdated(userId) async {
return;
}
@override
Future<CachedProfileInformation?> getUserProfile(String userId) async {
return null;
}
@override
Future<void> storeUserProfile(
String userId, CachedProfileInformation profile) async {
return;
}
} }
class TupleKey { class TupleKey {

View File

@ -1403,6 +1403,22 @@ class FamedlySdkHiveDatabase extends DatabaseApi with ZoneTransactionMixin {
@override @override
Future<void> delete() => Hive.deleteFromDisk(); Future<void> delete() => Hive.deleteFromDisk();
@override
Future<void> markUserProfileAsOutdated(userId) async {
return;
}
@override
Future<CachedProfileInformation?> getUserProfile(String userId) async {
return null;
}
@override
Future<void> storeUserProfile(
String userId, CachedProfileInformation profile) async {
return;
}
} }
dynamic _castValue(dynamic value) { dynamic _castValue(dynamic value) {

View File

@ -103,6 +103,8 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
late Box<String> _seenDeviceKeysBox; late Box<String> _seenDeviceKeysBox;
late Box<Map> _userProfilesBox;
@override @override
final int maxFileSize; final int maxFileSize;
@ -159,6 +161,8 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
static const String _seenDeviceKeysBoxName = 'box_seen_device_keys'; static const String _seenDeviceKeysBoxName = 'box_seen_device_keys';
static const String _userProfilesBoxName = 'box_user_profiles';
Database? database; Database? database;
/// Custom IdbFactory used to create the indexedDB. On IO platforms it would /// Custom IdbFactory used to create the indexedDB. On IO platforms it would
@ -214,6 +218,7 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
_eventsBoxName, _eventsBoxName,
_seenDeviceIdsBoxName, _seenDeviceIdsBoxName,
_seenDeviceKeysBoxName, _seenDeviceKeysBoxName,
_userProfilesBoxName,
}, },
sqfliteDatabase: database, sqfliteDatabase: database,
sqfliteFactory: sqfliteFactory, sqfliteFactory: sqfliteFactory,
@ -283,6 +288,9 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
_seenDeviceKeysBox = _collection.openBox( _seenDeviceKeysBox = _collection.openBox(
_seenDeviceKeysBoxName, _seenDeviceKeysBoxName,
); );
_userProfilesBox = _collection.openBox(
_userProfilesBoxName,
);
// Check version and check if we need a migration // Check version and check if we need a migration
final currentVersion = int.tryParse(await _clientBox.get('version') ?? ''); final currentVersion = int.tryParse(await _clientBox.get('version') ?? '');
@ -340,6 +348,7 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
await _timelineFragmentsBox.clear(); await _timelineFragmentsBox.clear();
await _outboundGroupSessionsBox.clear(); await _outboundGroupSessionsBox.clear();
await _presencesBox.clear(); await _presencesBox.clear();
await _userProfilesBox.clear();
await _clientBox.delete('prev_batch'); await _clientBox.delete('prev_batch');
}); });
@ -1618,4 +1627,32 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
name, name,
sqfliteFactory ?? idbFactory, sqfliteFactory ?? idbFactory,
); );
@override
Future<void> markUserProfileAsOutdated(userId) async {
final profile = await getUserProfile(userId);
if (profile == null) return;
await _userProfilesBox.put(
userId,
CachedProfileInformation.fromProfile(
profile as ProfileInformation,
outdated: true,
updated: profile.updated,
).toJson(),
);
}
@override
Future<CachedProfileInformation?> getUserProfile(String userId) =>
_userProfilesBox.get(userId).then((json) => json == null
? null
: CachedProfileInformation.fromJson(copyMap(json)));
@override
Future<void> storeUserProfile(
String userId, CachedProfileInformation profile) =>
_userProfilesBox.put(
userId,
profile.toJson(),
);
} }

View File

@ -1717,10 +1717,10 @@ class Room {
displayName: null displayName: null
)) { )) {
try { try {
final profile = await client.getProfileFromUserId(mxID); final profile = await client.getUserProfile(mxID);
foundUser = User( foundUser = User(
mxID, mxID,
displayName: profile.displayName, displayName: profile.displayname,
avatarUrl: profile.avatarUrl?.toString(), avatarUrl: profile.avatarUrl?.toString(),
membership: foundUser?.membership.name ?? Membership.leave.name, membership: foundUser?.membership.name ?? Membership.leave.name,
room: this, room: this,

View File

@ -0,0 +1,29 @@
import 'package:matrix/matrix_api_lite.dart';
class CachedProfileInformation extends ProfileInformation {
final bool outdated;
final DateTime updated;
CachedProfileInformation.fromProfile(
ProfileInformation profile, {
required this.outdated,
required this.updated,
}) : super(
avatarUrl: profile.avatarUrl,
displayname: profile.displayname,
);
factory CachedProfileInformation.fromJson(Map<String, Object?> json) =>
CachedProfileInformation.fromProfile(
ProfileInformation.fromJson(json),
outdated: json['outdated'] as bool,
updated: DateTime.fromMillisecondsSinceEpoch(json['updated'] as int),
);
@override
Map<String, Object?> toJson() => {
...super.toJson(),
'outdated': outdated,
'updated': updated.millisecondsSinceEpoch,
};
}

File diff suppressed because one or more lines are too long

View File

@ -616,6 +616,38 @@ void main() {
storedPresence?.toJson(), storedPresence?.toJson(),
); );
}); });
test(
'storeUserProfile',
() async {
final profile1 = await database.getUserProfile('@alice:example.com');
expect(profile1, null);
await database.storeUserProfile(
'@alice:example.com',
CachedProfileInformation.fromProfile(
ProfileInformation(
avatarUrl: Uri.parse('mxc://test'), displayname: 'Alice M'),
outdated: false,
updated: DateTime.now(),
),
);
// ignore: deprecated_member_use_from_same_package
if (database is! HiveCollectionsDatabase &&
// ignore: deprecated_member_use_from_same_package
database is! FamedlySdkHiveDatabase) {
final profile2 =
await database.getUserProfile('@alice:example.com');
expect(profile2?.displayname, 'Alice M');
expect(profile2?.outdated, false);
await database.markUserProfileAsOutdated('@alice:example.com');
final profile3 =
await database.getUserProfile('@alice:example.com');
expect(profile3?.displayname, 'Alice M');
expect(profile3?.outdated, true);
}
},
);
// Clearing up from here // Clearing up from here
test('clearSSSSCache', () async { test('clearSSSSCache', () async {

View File

@ -490,6 +490,9 @@ void main() => group('Integration tests', () {
if (testClientB.getRoomById(dmRoom) == null) { if (testClientB.getRoomById(dmRoom) == null) {
await testClientB.waitForRoomInSync(dmRoom, invite: true); await testClientB.waitForRoomInSync(dmRoom, invite: true);
} }
// Wait at least for one additional sync to make sure the invite landed
// correctly. Workaround for synapse CI job failing.
await testClientB.onSync.stream.first;
Logs().i('++++ (Bob) Create DM ++++'); Logs().i('++++ (Bob) Create DM ++++');
final dmRoomFromB = final dmRoomFromB =