Merge pull request #5661 from pangeachat/respond-to-analytics-server-updates

Respond to analytics server updates
This commit is contained in:
ggurdin 2026-02-11 16:34:48 -05:00 committed by GitHub
commit 81e68fe556
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 225 additions and 107 deletions

View file

@ -130,29 +130,24 @@ class AnalyticsDataService {
await _analyticsClientGetter.database.updateUserID(client.userID!);
}
final resp = await client.getUserProfile(client.userID!);
final analyticsProfile = AnalyticsProfileModel.fromJson(
resp.additionalProperties,
);
_syncController?.dispose();
_syncController = AnalyticsSyncController(
client: client,
dataService: this,
);
await _syncController!.bulkUpdate();
final vocab = await getAggregatedConstructs(ConstructTypeEnum.vocab);
final morphs = await getAggregatedConstructs(ConstructTypeEnum.morph);
final constructs = [...vocab.values, ...morphs.values];
final totalXP = constructs.fold(0, (total, c) => total + c.points);
await _analyticsClientGetter.database.updateDerivedStats(
DerivedAnalyticsDataModel(
totalXP: totalXP,
offset: analyticsProfile.xpOffset ?? 0,
),
final resp = await client.getUserProfile(client.userID!);
final analyticsProfile = AnalyticsProfileModel.fromJson(
resp.additionalProperties,
);
final l2 = MatrixState.pangeaController.userController.userL2;
if (l2 != null) {
await updateXPOffset(analyticsProfile.xpOffsetByLanguage(l2) ?? 0);
}
_syncController!.start();
await _initMergeTable();
@ -161,7 +156,7 @@ class AnalyticsDataService {
} finally {
Logs().i("Analytics database initialized.");
initCompleter.complete();
updateDispatcher.sendConstructAnalyticsUpdate(AnalyticsUpdate([]));
updateDispatcher.sendEmptyAnalyticsUpdate();
updateDispatcher.sendActivityAnalyticsUpdate(null);
}
}
@ -413,7 +408,7 @@ class AnalyticsDataService {
final newConstructs = await getConstructUses(updateIds);
int points = 0;
if (update.blockedConstruct == null || updateIds.isNotEmpty) {
if (updateIds.isNotEmpty) {
for (final id in updateIds) {
final prevPoints = prevConstructs[id]?.points ?? 0;
final newPoints = newConstructs[id]?.points ?? 0;
@ -422,7 +417,7 @@ class AnalyticsDataService {
events.add(XPGainedEvent(points, update.targetID));
}
final newData = prevData.copyWith(totalXP: prevData.totalXP + points);
final newData = prevData.addXP(points);
await _analyticsClientGetter.database.updateDerivedStats(newData);
// Update public profile each time that new analytics are added.
@ -474,10 +469,6 @@ class AnalyticsDataService {
}
}
if (update.blockedConstruct != null) {
events.add(ConstructBlockedEvent(update.blockedConstruct!));
}
if (newUnusedConstructs.isNotEmpty) {
events.add(NewConstructsEvent(newUnusedConstructs));
}
@ -494,6 +485,12 @@ class AnalyticsDataService {
_mergeTable.addConstructsByUses(event.content.uses, blocked);
}
await _analyticsClientGetter.database.updateServerAnalytics(events);
final vocab = await getAggregatedConstructs(ConstructTypeEnum.vocab);
final morphs = await getAggregatedConstructs(ConstructTypeEnum.morph);
final constructs = [...vocab.values, ...morphs.values];
final totalXP = constructs.fold(0, (total, c) => total + c.points);
await _analyticsClientGetter.database.updateTotalXP(totalXP);
}
Future<void> updateBlockedConstructs(ConstructIdentifier constructId) async {
@ -512,14 +509,8 @@ class AnalyticsDataService {
level: newLevel,
);
await _analyticsClientGetter.database.updateDerivedStats(
DerivedAnalyticsDataModel(totalXP: newXP),
);
await _analyticsClientGetter.database.updateTotalXP(newXP);
_invalidateCaches();
updateDispatcher.sendConstructAnalyticsUpdate(
AnalyticsUpdate([], blockedConstruct: constructId),
);
}
Future<void> clearLocalAnalytics() async {

View file

@ -468,7 +468,15 @@ class AnalyticsDatabase with DatabaseFileStorage {
Future<void> updateXPOffset(int offset) {
return _transaction(() async {
final stats = await getDerivedStats();
final updatedStats = stats.copyWith(offset: offset);
final updatedStats = stats.copyWithOffset(offset);
await _derivedStatsBox.put('derived_stats', updatedStats.toJson());
});
}
Future<void> updateTotalXP(int totalXP) {
return _transaction(() async {
final stats = await getDerivedStats();
final updatedStats = stats.copyWithTotalXP(totalXP);
await _derivedStatsBox.put('derived_stats', updatedStats.toJson());
});
}

View file

@ -3,13 +3,36 @@ import 'dart:async';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart';
import 'package:fluffychat/pangea/analytics_settings/analytics_settings_model.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
import 'package:fluffychat/widgets/matrix.dart';
enum _AnalyticsUpdateEvent {
constructAnalytics,
activityAnalytics,
lemmaInfo,
blockedConstruct;
String get eventType {
switch (this) {
case _AnalyticsUpdateEvent.constructAnalytics:
return PangeaEventTypes.construct;
case _AnalyticsUpdateEvent.activityAnalytics:
return PangeaEventTypes.activityRoomIds;
case _AnalyticsUpdateEvent.lemmaInfo:
return PangeaEventTypes.userSetLemmaInfo;
case _AnalyticsUpdateEvent.blockedConstruct:
return PangeaEventTypes.analyticsSettings;
}
}
}
class AnalyticsSyncController {
final Client client;
final AnalyticsDataService dataService;
@ -31,15 +54,43 @@ class AnalyticsSyncController {
final analyticsRoom = _getAnalyticsRoom();
if (analyticsRoom == null) return;
final events = update.rooms?.join?[analyticsRoom.id]?.timeline?.events
?.where(
(e) =>
e.type == PangeaEventTypes.construct &&
e.senderId == client.userID,
);
final roomUpdates = update.rooms?.join?[analyticsRoom.id]?.timeline?.events;
if (roomUpdates == null) return;
if (events == null || events.isEmpty) return;
for (final type in _AnalyticsUpdateEvent.values) {
await _dispatchSyncEvents(type, roomUpdates, analyticsRoom);
}
}
Future<void> _dispatchSyncEvents(
_AnalyticsUpdateEvent type,
List<MatrixEvent> events,
Room analyticsRoom,
) async {
final updates = events
.where((e) => e.type == type.eventType && e.senderId == client.userID)
.toList();
switch (type) {
case _AnalyticsUpdateEvent.constructAnalytics:
await _onConstructEvents(updates, analyticsRoom);
break;
case _AnalyticsUpdateEvent.activityAnalytics:
_onActivityEvents(updates);
break;
case _AnalyticsUpdateEvent.lemmaInfo:
_onLemmaInfoEvents(updates);
break;
case _AnalyticsUpdateEvent.blockedConstruct:
await _onBlockedConstructEvents(updates);
break;
}
}
Future<void> _onConstructEvents(
List<MatrixEvent> events,
Room analyticsRoom,
) async {
final constructEvents = events
.map(
(e) => ConstructAnalyticsEvent(
@ -50,16 +101,65 @@ class AnalyticsSyncController {
.toList();
if (constructEvents.isEmpty) return;
await dataService.updateServerAnalytics(constructEvents);
// Server updates do not usually need to update the UI, since usually they are only
// transfering local data to the server. However, if a user if using multiple devices,
// we do need to update the UI when new data comes from the server.
dataService.updateDispatcher.sendConstructAnalyticsUpdate(
AnalyticsUpdate([]),
await dataService.updateDispatcher.sendServerAnalyticsUpdate(
constructEvents,
);
}
void _onActivityEvents(List<MatrixEvent> events) {
for (final event in events) {
if (event.content[ModelKey.roomIds] is! List) continue;
final roomIds = List<String>.from(
event.content[ModelKey.roomIds]! as List,
);
final prevContent =
event.unsigned?['prev_content'] as Map<String, Object?>?;
final prevRoomIds =
prevContent != null && prevContent[ModelKey.roomIds] is List
? List<String>.from(prevContent[ModelKey.roomIds] as List)
: [];
final newRoomIds = roomIds
.where((id) => !prevRoomIds.contains(id))
.toList();
if (newRoomIds.isNotEmpty) {
dataService.updateDispatcher.sendActivityAnalyticsUpdate(null);
}
}
}
void _onLemmaInfoEvents(List<MatrixEvent> events) {
for (final event in events) {
if (event.stateKey == null) continue;
final cID = ConstructIdentifier.fromString(event.stateKey!);
if (cID == null) continue;
final update = UserSetLemmaInfo.fromJson(event.content);
dataService.updateDispatcher.sendLemmaInfoUpdate(cID, update);
}
}
Future<void> _onBlockedConstructEvents(List<MatrixEvent> events) async {
for (final event in events) {
final current = AnalyticsSettingsModel.fromJson(event.content);
final prevContent =
event.unsigned?['prev_content'] as Map<String, Object?>?;
final prev = prevContent != null
? AnalyticsSettingsModel.fromJson(prevContent)
: null;
final newBlocked = current.blockedConstructs;
final prevBlocked = prev?.blockedConstructs ?? {};
final newlyBlocked = newBlocked.where((c) => !prevBlocked.contains(c));
for (final constructId in newlyBlocked) {
await dataService.updateDispatcher.sendBlockedConstructUpdate(
constructId,
);
}
}
}
Future<void> waitForSync(String analyticsRoomId) async {
await client.onSync.stream.firstWhere((update) {
final roomUpdate = update.rooms?.join?[analyticsRoomId];

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_update_events.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
@ -16,10 +17,9 @@ class LevelUpdate {
class AnalyticsUpdate {
final List<OneConstructUse> addedConstructs;
final ConstructIdentifier? blockedConstruct;
final String? targetID;
AnalyticsUpdate(this.addedConstructs, {this.blockedConstruct, this.targetID});
AnalyticsUpdate(this.addedConstructs, {this.targetID});
}
class ConstructLevelUpdate {
@ -85,9 +85,26 @@ class AnalyticsUpdateDispatcher {
UserSetLemmaInfo lemmaInfo,
) => _lemmaInfoUpdateStream.add(MapEntry(constructId, lemmaInfo));
Future<void> sendConstructAnalyticsUpdate(
AnalyticsUpdate analyticsUpdate,
Future<void> sendBlockedConstructUpdate(
ConstructIdentifier blockedConstruct,
) async {
await dataService.updateBlockedConstructs(blockedConstruct);
final update = AnalyticsStreamUpdate(blockedConstruct: blockedConstruct);
constructUpdateStream.add(update);
}
void sendEmptyAnalyticsUpdate() {
constructUpdateStream.add(AnalyticsStreamUpdate());
}
Future<void> sendServerAnalyticsUpdate(
List<ConstructAnalyticsEvent> events,
) async {
await dataService.updateServerAnalytics(events);
sendEmptyAnalyticsUpdate();
}
Future<void> sendLocalAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async {
final events = await dataService.updateLocalAnalytics(analyticsUpdate);
for (final event in events) {
_dispatch(event);
@ -105,9 +122,6 @@ class AnalyticsUpdateDispatcher {
case final XPGainedEvent e:
_onXPGained(e.points, e.targetID);
break;
case final ConstructBlockedEvent e:
_onBlockedConstruct(e.blockedConstruct);
break;
case final ConstructLevelUpEvent e:
_onConstructLevelUp(e.constructId, e.level, e.targetID);
break;
@ -155,11 +169,6 @@ class AnalyticsUpdateDispatcher {
);
}
void _onBlockedConstruct(ConstructIdentifier constructId) {
final update = AnalyticsStreamUpdate(blockedConstruct: constructId);
constructUpdateStream.add(update);
}
void _onNewConstruct(Set<ConstructIdentifier> constructIds) {
if (constructIds.isEmpty) return;
newConstructsStream.add(constructIds);

View file

@ -27,11 +27,6 @@ class XPGainedEvent extends AnalyticsUpdateEvent {
XPGainedEvent(this.points, this.targetID);
}
class ConstructBlockedEvent extends AnalyticsUpdateEvent {
final ConstructIdentifier blockedConstruct;
ConstructBlockedEvent(this.blockedConstruct);
}
class NewConstructsEvent extends AnalyticsUpdateEvent {
final Set<ConstructIdentifier> newConstructs;
NewConstructsEvent(this.newConstructs);

View file

@ -62,7 +62,7 @@ class AnalyticsUpdateService {
List<OneConstructUse> newConstructs, {
bool forceUpdate = false,
}) async {
await dataService.updateDispatcher.sendConstructAnalyticsUpdate(
await dataService.updateDispatcher.sendLocalAnalyticsUpdate(
AnalyticsUpdate(newConstructs, targetID: targetID),
);
@ -127,9 +127,6 @@ class AnalyticsUpdateService {
if (analyticsRoom == null) return;
await analyticsRoom.addActivityRoomId(roomId);
if (lang.langCodeShort == _l2?.langCodeShort) {
dataService.updateDispatcher.sendActivityAnalyticsUpdate(roomId);
}
}
Future<void> blockConstruct(ConstructIdentifier constructId) async {
@ -143,7 +140,6 @@ class AnalyticsUpdateService {
);
await analyticsRoom.setAnalyticsSettings(updated);
await dataService.updateBlockedConstructs(constructId);
}
Future<void> setLemmaInfo(
@ -160,7 +156,6 @@ class AnalyticsUpdateService {
meaning: meaning,
);
if (userLemmaInfo == updated) return;
dataService.updateDispatcher.sendLemmaInfoUpdate(constructId, updated);
try {
await analyticsRoom.setUserSetLemmaInfo(constructId, updated);

View file

@ -1,6 +1,5 @@
import 'dart:math';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
class DerivedAnalyticsDataModel {
@ -12,7 +11,7 @@ class DerivedAnalyticsDataModel {
int get totalXP => _totalXP + offset;
int get level => calculateLevelWithXp(_totalXP);
int get level => calculateLevelWithXp(totalXP);
// the minimum XP required for a given level
int get _minXPForLevel => calculateXpWithLevel(level);
@ -23,7 +22,7 @@ class DerivedAnalyticsDataModel {
// the progress within the current level as a percentage (0.0 to 1.0)
double get levelProgress {
final progress =
(_totalXP - _minXPForLevel) / (minXPForNextLevel - _minXPForLevel);
(totalXP - _minXPForLevel) / (minXPForNextLevel - _minXPForLevel);
return progress >= 0 ? progress : 0;
}
@ -60,35 +59,29 @@ class DerivedAnalyticsDataModel {
}
}
DerivedAnalyticsDataModel update(List<OneConstructUse> uses) {
int xp = _totalXP;
for (final u in uses) {
xp += u.xp;
}
return copyWith(totalXP: xp);
DerivedAnalyticsDataModel copyWithOffset(int offset) {
return DerivedAnalyticsDataModel(totalXP: _totalXP, offset: offset);
}
DerivedAnalyticsDataModel merge(DerivedAnalyticsDataModel other) {
DerivedAnalyticsDataModel copyWithTotalXP(int totalXP) {
return DerivedAnalyticsDataModel(totalXP: totalXP, offset: offset);
}
DerivedAnalyticsDataModel addXP(int xpToAdd) {
return DerivedAnalyticsDataModel(
totalXP: _totalXP + other.totalXP,
totalXP: _totalXP + xpToAdd,
offset: offset,
);
}
DerivedAnalyticsDataModel copyWith({int? totalXP, int? offset}) {
factory DerivedAnalyticsDataModel.fromJson(Map<String, dynamic> map) {
return DerivedAnalyticsDataModel(
totalXP: totalXP ?? this.totalXP,
offset: offset ?? this.offset,
totalXP: map['total_xp'] ?? 0,
offset: map['offset'] ?? 0,
);
}
factory DerivedAnalyticsDataModel.fromJson(Map<String, dynamic> map) {
return DerivedAnalyticsDataModel(totalXP: map['total_xp'] ?? 0);
}
Map<String, dynamic> toJson() {
return {'total_xp': _totalXP};
return {'total_xp': _totalXP, 'offset': offset};
}
}

View file

@ -136,6 +136,9 @@ class AnalyticsProfileModel {
int? get level => languageAnalytics?[targetLanguage]?.level;
int? get xpOffset => languageAnalytics?[targetLanguage]?.xpOffset;
int? xpOffsetByLanguage(LanguageModel language) =>
languageAnalytics?[language]?.xpOffset;
}
class LanguageAnalyticsProfileEntry {

View file

@ -37,8 +37,8 @@ class LanguageUpdate {
class UserController {
final StreamController<LanguageUpdate> languageStream =
StreamController.broadcast();
final StreamController settingsUpdateStream =
StreamController<Profile>.broadcast();
final StreamController<Profile> settingsUpdateStream =
StreamController.broadcast();
/// Cached version of the user profile, so it doesn't have
/// to be read in from client's account data each time it is accessed.
@ -52,10 +52,40 @@ class UserController {
matrix.Client get client => MatrixState.pangeaController.matrixState.client;
void _onProfileUpdate(matrix.SyncUpdate sync) {
final prevTargetLang = userL2;
final prevBaseLang = userL1;
final profileData = client.accountData[ModelKey.userProfile]?.content;
final Profile? fromAccountData = Profile.fromAccountData(profileData);
if (fromAccountData != null) {
if (fromAccountData != null && fromAccountData != _cachedProfile) {
_cachedProfile = fromAccountData;
if ((prevTargetLang != userL2) || (prevBaseLang != userL1)) {
if (userL1 == null || userL2 == null) {
// if either language is null, then we want to send a settings update instead of a language update
ErrorHandler.logError(
e: "One of the user languages is null. Sending settings update instead of language update.",
data: {
'prevBaseLang': prevBaseLang?.langCode,
'prevTargetLang': prevTargetLang?.langCode,
'userL1': userL1?.langCode,
'userL2': userL2?.langCode,
},
);
settingsUpdateStream.add(fromAccountData);
return;
}
languageStream.add(
LanguageUpdate(
baseLang: userL1!,
targetLang: userL2!,
prevBaseLang: prevBaseLang,
prevTargetLang: prevTargetLang,
),
);
} else {
settingsUpdateStream.add(fromAccountData);
}
}
}
@ -92,8 +122,6 @@ class UserController {
waitForDataInSync = false,
}) async {
await initialize();
final prevTargetLang = userL2;
final prevBaseLang = userL1;
final prevHash = profile.hashCode;
final Profile updatedProfile = update(profile);
@ -103,19 +131,6 @@ class UserController {
}
await updatedProfile.saveProfileData(waitForDataInSync: waitForDataInSync);
if ((prevTargetLang != userL2) || (prevBaseLang != userL1)) {
languageStream.add(
LanguageUpdate(
baseLang: userL1!,
targetLang: userL2!,
prevBaseLang: prevBaseLang,
prevTargetLang: prevTargetLang,
),
);
} else {
settingsUpdateStream.add(updatedProfile);
}
}
/// A completer for the profile model of a user.

View file

@ -538,6 +538,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
fcm_shared_isolate:
dependency: "direct overridden"
description:
path: "pangea_packages/fcm_shared_isolate"
relative: true
source: path
version: "0.2.0"
ffi:
dependency: transitive
description:

View file

@ -170,4 +170,6 @@ flutter:
# 1. Don't do it if you can avoid it or fix it upstream in a manageable time
# 2. Always link an (upstream?) issue
# 3. Explain how and when this can be removed (overrides must be temporarily)
dependency_overrides:
dependency_overrides:
fcm_shared_isolate:
path: pangea_packages/fcm_shared_isolate