service: Make [XmppState] and [PreferencesState] migratable

This commit is contained in:
PapaTutuWawa 2022-04-06 14:46:08 +02:00
parent cf353ce5ce
commit 75811d7dee
9 changed files with 402 additions and 184 deletions

View File

@ -30,6 +30,7 @@ files:
jid: String?
displayName: String?
avatarUrl: String?
avatarHash: String?
conversations:
type: List<Conversation>?
deserialise: true

View File

@ -54,16 +54,14 @@ Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra })
final id = extra as String;
final xmpp = GetIt.I.get<XmppService>();
final account = await xmpp.getAccountData();
final settings = await xmpp.getConnectionSettings();
final state = await xmpp.getXmppState();
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
GetIt.I.get<Logger>().finest("account != null: " + (account != null).toString());
GetIt.I.get<Logger>().finest("settings != null: " + (settings != null).toString());
if (account!= null && settings != null) {
if (settings != null && settings.jid != null && settings.password != null) {
await GetIt.I.get<RosterService>().loadRosterFromDatabase();
// Check some permissions
@ -80,9 +78,10 @@ Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra })
sendEvent(
PreStartDoneEvent(
state: "logged_in",
jid: account.jid,
displayName: account.displayName,
jid: state.jid,
displayName: state.displayName,
avatarUrl: state.avatarUrl,
avatarHash: state.avatarHash,
permissionsToRequest: permissions,
preferences: preferences,
conversations: await GetIt.I.get<DatabaseService>().loadConversations(),
@ -264,7 +263,8 @@ Future<void> performRequestDownload(RequestDownloadCommand command, { dynamic ex
Future<void> performSetAvatar(SetAvatarCommand command, { dynamic extra }) async {
await GetIt.I.get<XmppService>().modifyXmppState((state) => state.copyWith(
avatarUrl: command.path
avatarUrl: command.path,
avatarHash: command.hash
));
GetIt.I.get<AvatarService>().publishAvatar(command.path, command.hash);
}

View File

@ -1,6 +1,7 @@
import "dart:convert";
import "package:moxxyv2/shared/preferences.dart";
import "package:moxxyv2/shared/migrator.dart";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
import "package:logging/logging.dart";
@ -9,16 +10,67 @@ const currentVersion = 7;
const preferencesVersionKey = "prefs_version";
const preferencesDataKey = "prefs_data";
class PreferencesService {
int _version = -1;
PreferencesState? _preferences;
class _PreferencesMigrator extends Migrator<PreferencesState> {
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true)
);
final Logger _log;
PreferencesService() : _log = Logger("PreferencesService");
_PreferencesMigrator() : super(
currentVersion,
[
Migration<PreferencesState>(1, (data) => PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!
)),
Migration<PreferencesState>(2, (data) => PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!,
autoDownloadWifi: data["autoDownloadWifi"]!,
autoDownloadMobile: data["autoDownloadMobile"]!
)),
Migration<PreferencesState>(3, (data) => PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!,
autoDownloadWifi: data["autoDownloadWifi"]!,
autoDownloadMobile: data["autoDownloadMobile"]!,
maximumAutoDownloadSize: data["maximumAutoDownloadSize"]!
)),
Migration<PreferencesState>(4, (data) => PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!,
autoDownloadWifi: data["autoDownloadWifi"]!,
autoDownloadMobile: data["autoDownloadMobile"]!,
maximumAutoDownloadSize: data["maximumAutoDownloadSize"]!,
backgroundPath: data["backgroundPath"]!
)),
Migration<PreferencesState>(5, (data) => PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!,
autoDownloadWifi: data["autoDownloadWifi"]!,
autoDownloadMobile: data["autoDownloadMobile"]!,
maximumAutoDownloadSize: data["maximumAutoDownloadSize"]!,
backgroundPath: data["backgroundPath"]!,
isAvatarPublic: data["isAvatarPublic"]!
)),
Migration<PreferencesState>(6, (data) => PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!,
autoDownloadWifi: data["autoDownloadWifi"]!,
autoDownloadMobile: data["autoDownloadMobile"]!,
maximumAutoDownloadSize: data["maximumAutoDownloadSize"]!,
backgroundPath: data["backgroundPath"]!,
isAvatarPublic: data["isAvatarPublic"]!,
autoAcceptSubscriptionRequests: data["autoAcceptSubscriptionRequests"]
))
]
);
// TODO: Deduplicate with XmppService. Maybe a StorageService?
Future<String?> _readKeyOrNull(String key) async {
if (await _storage.containsKey(key: key)) {
@ -27,111 +79,45 @@ class PreferencesService {
return null;
}
}
Future<void> _commitPreferences() async {
await _storage.write(key: preferencesVersionKey, value: _version.toString());
await _storage.write(key: preferencesDataKey, value: json.encode(_preferences!.toJson()));
@override
Future<Map<String, dynamic>?> loadRawData() async {
final raw = await _readKeyOrNull(preferencesDataKey);
if (raw != null) return json.decode(raw);
return null;
}
@override
Future<int?> loadVersion() async {
final raw = await _readKeyOrNull(preferencesVersionKey);
if (raw != null) return int.parse(raw);
return null;
}
@override
PreferencesState fromData(Map<String, dynamic> data) => PreferencesState.fromJson(data);
@override
PreferencesState fromDefault() => PreferencesState();
@override
Future<void> commit(int version, PreferencesState data) async {
await _storage.write(key: preferencesVersionKey, value: version.toString());
await _storage.write(key: preferencesDataKey, value: json.encode(data.toJson()));
}
}
class PreferencesService {
PreferencesState? _preferences;
final _PreferencesMigrator _migrator;
final Logger _log;
PreferencesService() : _migrator = _PreferencesMigrator(), _log = Logger("PreferencesService");
Future<void> _loadPreferences() async {
final version = int.parse((await _readKeyOrNull(preferencesVersionKey)) ?? "-1");
final dataRaw = await _readKeyOrNull(preferencesDataKey);
if (version < 1 || dataRaw == null) {
_log.finest("Creating preferences...");
_preferences = PreferencesState();
_version = currentVersion;
await _commitPreferences();
} else if(version < 2) {
final data = json.decode(dataRaw);
_log.finest("Upgrading from a 0 < version < 2 to current version");
_preferences = PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!
);
_version = currentVersion;
await _commitPreferences();
} else if (version < 3) {
final data = json.decode(dataRaw);
_log.finest("Upgrading from a 1 < version < 3 to current version");
_preferences = PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!,
autoDownloadWifi: data["autoDownloadWifi"]!,
autoDownloadMobile: data["autoDownloadMobile"]!
);
_version = currentVersion;
await _commitPreferences();
} else if (version < 4) {
final data = json.decode(dataRaw);
_log.finest("Upgrading from a 2 < version < 4 to current version");
_preferences = PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!,
autoDownloadWifi: data["autoDownloadWifi"]!,
autoDownloadMobile: data["autoDownloadMobile"]!,
maximumAutoDownloadSize: data["maximumAutoDownloadSize"]!
);
_version = currentVersion;
await _commitPreferences();
} else if (version < 5) {
final data = json.decode(dataRaw);
_log.finest("Upgrading from a 4 < version < 5 to current version");
_preferences = PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!,
autoDownloadWifi: data["autoDownloadWifi"]!,
autoDownloadMobile: data["autoDownloadMobile"]!,
maximumAutoDownloadSize: data["maximumAutoDownloadSize"]!,
backgroundPath: data["backgroundPath"]!
);
_version = currentVersion;
await _commitPreferences();
} else if (version < 6) {
final data = json.decode(dataRaw);
_log.finest("Upgrading from a 5 < version < 6 to current version");
_preferences = PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!,
autoDownloadWifi: data["autoDownloadWifi"]!,
autoDownloadMobile: data["autoDownloadMobile"]!,
maximumAutoDownloadSize: data["maximumAutoDownloadSize"]!,
backgroundPath: data["backgroundPath"]!,
isAvatarPublic: data["isAvatarPublic"]!
);
_version = currentVersion;
await _commitPreferences();
} else if (version < 7) {
final data = json.decode(dataRaw);
_log.finest("Upgrading from a 6 < version < 7 to current version");
_preferences = PreferencesState(
sendChatMarkers: data["sendChatMarkers"]!,
sendChatStates: data["sendChatStates"]!,
showSubscriptionRequests: data["showSubscriptionRequests"]!,
autoDownloadWifi: data["autoDownloadWifi"]!,
autoDownloadMobile: data["autoDownloadMobile"]!,
maximumAutoDownloadSize: data["maximumAutoDownloadSize"]!,
backgroundPath: data["backgroundPath"]!,
isAvatarPublic: data["isAvatarPublic"]!,
autoAcceptSubscriptionRequests: data["autoAcceptSubscriptionRequests"]
);
_version = currentVersion;
await _commitPreferences();
} else {
_version = currentVersion;
_preferences = PreferencesState.fromJson(json.decode(dataRaw));
}
_preferences = await _migrator.load();
}
Future<PreferencesState> getPreferences() async {
@ -144,6 +130,6 @@ class PreferencesService {
if (_preferences == null) await _loadPreferences();
_preferences = func(_preferences!);
await _commitPreferences();
await _migrator.commit(currentVersion, _preferences!);
}
}

View File

@ -194,10 +194,9 @@ void onStart() {
]);
GetIt.I.registerSingleton<XmppConnection>(connection);
final account = await xmpp.getAccountData();
final settings = await xmpp.getConnectionSettings();
if (account!= null && settings != null) {
if (settings != null && settings.jid != null && settings.password != null) {
xmpp.connect(settings, false);
}
})();

View File

@ -22,9 +22,11 @@ class XmppState with _$XmppState {
String? srid,
String? resource,
String? jid,
String? displayName,
String? password,
String? lastRosterVersion,
@Default("") avatarUrl,
@Default("") avatarHash,
@Default(false) bool askedStoragePermission
}) = _XmppState;

View File

@ -4,8 +4,8 @@ import "dart:convert";
import "package:moxxyv2/ui/helpers.dart";
import "package:moxxyv2/shared/events.dart";
import "package:moxxyv2/shared/helpers.dart";
import "package:moxxyv2/shared/account.dart";
import "package:moxxyv2/shared/eventhandler.dart";
import "package:moxxyv2/shared/migrator.dart";
import "package:moxxyv2/shared/models/message.dart";
import "package:moxxyv2/xmpp/settings.dart";
import "package:moxxyv2/xmpp/jid.dart";
@ -37,20 +37,63 @@ import "package:flutter_background_service/flutter_background_service.dart";
import "package:logging/logging.dart";
import "package:permission_handler/permission_handler.dart";
const currentXmppStateVersion = 1;
const xmppStateKey = "xmppState";
const xmppAccountDataKey = "xmppAccount";
const xmppStateVersionKey = "xmppState_version";
class XmppService {
class _XmppStateMigrator extends Migrator<XmppState> {
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true)
);
_XmppStateMigrator() : super(currentXmppStateVersion, []);
// TODO: Deduplicate
Future<String?> _readKeyOrNull(String key) async {
if (await _storage.containsKey(key: key)) {
return await _storage.read(key: key);
} else {
return null;
}
}
@override
Future<Map<String, dynamic>?> loadRawData() async {
final raw = await _readKeyOrNull(xmppStateKey);
if (raw != null) return json.decode(raw);
return null;
}
@override
Future<int?> loadVersion() async {
final raw = await _readKeyOrNull(xmppStateVersionKey);
if (raw != null) return int.parse(raw);
return null;
}
@override
XmppState fromData(Map<String, dynamic> data) => XmppState.fromJson(data);
@override
XmppState fromDefault() => XmppState();
@override
Future<void> commit(int version, XmppState data) async {
await _storage.write(key: xmppStateVersionKey, value: currentXmppStateVersion.toString());
await _storage.write(key: xmppStateKey, value: json.encode(data.toJson()));
}
}
class XmppService {
final Logger _log;
final EventHandler _eventHandler;
final _XmppStateMigrator _migrator;
bool loginTriggeredFromUI = false;
String _currentlyOpenedChatJid;
StreamSubscription<ConnectivityResult>? _networkStateSubscription;
XmppState? _state;
ConnectivityResult _currentConnectionType;
XmppService() :
@ -59,6 +102,7 @@ class XmppService {
_state = null,
_currentConnectionType = ConnectivityResult.none,
_eventHandler = EventHandler(),
_migrator = _XmppStateMigrator(),
_log = Logger("XmppService") {
_eventHandler.addMatchers([
EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
@ -77,41 +121,17 @@ class XmppService {
]);
}
Future<String?> _readKeyOrNull(String key) async {
if (await _storage.containsKey(key: key)) {
return await _storage.read(key: key);
} else {
return null;
}
}
Future<XmppState> getXmppState() async {
if (_state != null) return _state!;
final data = await _readKeyOrNull(xmppStateKey);
// GetIt.I.get<Logger>().finest("data != null: " + (data != null).toString());
if (data == null) {
_state = XmppState();
await _commitXmppState();
return _state!;
}
_state = XmppState.fromJson(json.decode(data));
_state = await _migrator.load();
return _state!;
}
Future<void> _commitXmppState() async {
// final logger = GetIt.I.get<Logger>();
// logger.finest("Commiting _xmppState to EncryptedSharedPrefs");
// logger.finest("=> ${json.encode(_state!.toJson())}");
await _storage.write(key: xmppStateKey, value: json.encode(_state!.toJson()));
}
/// A wrapper to modify the [XmppState] and commit it.
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
_state = func(_state!);
await _commitXmppState();
await _migrator.commit(currentXmppStateVersion, _state!);
}
Future<ConnectionSettings?> getConnectionSettings() async {
@ -152,25 +172,6 @@ class XmppService {
/// Returns the JID of the chat that is currently opened. Null, if none is open.
String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid;
/// Load the [AccountState] from storage. Returns null if not found.
Future<AccountState?> getAccountData() async {
final data = await _readKeyOrNull(xmppAccountDataKey);
if (data == null) {
return null;
}
return AccountState.fromJson(jsonDecode(data));
}
/// Save [state] to storage such that it can be loaded again by [getAccountData].
Future<void> setAccountData(AccountState state) async {
return await _storage.write(key: xmppAccountDataKey, value: jsonEncode(state.toJson()));
}
/// Removes the account data from storage.
Future<void> removeAccountData() async {
// TODO: This sometimes fails
await _storage.delete(key: xmppAccountDataKey);
}
/// Sends a message to [jid] with the body of [body].
Future<void> sendMessage({
required String body,
@ -365,10 +366,11 @@ class XmppService {
if (loginTriggeredFromUI) {
// TODO: Trigger another event so the UI can see this aswell
await setAccountData(AccountState(
await modifyXmppState((state) => state.copyWith(
jid: connection.getConnectionSettings().jid.toString(),
displayName: connection.getConnectionSettings().jid.local,
avatarUrl: ""
avatarUrl: "",
avatarHash: ""
));
}
}

View File

@ -1,16 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "account.freezed.dart";
part "account.g.dart";
@freezed
class AccountState with _$AccountState {
factory AccountState({
@Default("") String jid,
@Default("") String displayName,
@Default("") String avatarUrl
}) = _AccountState;
// JSON serialization
factory AccountState.fromJson(Map<String, dynamic> json) => _$AccountStateFromJson(json);
}

57
lib/shared/migrator.dart Normal file
View File

@ -0,0 +1,57 @@
class Migration<T> {
final int version;
/// Return a version that is upgraded to the newest version.
final Function(Map<String, dynamic>) migrationFunction;
Migration(this.version, this.migrationFunction);
bool canMigrate(int version) => version <= this.version;
}
abstract class Migrator<T> {
final int latestVersion;
final List<Migration<T>> migrations;
Migrator(this.latestVersion, this.migrations) {
migrations.sort((a, b) => -1 * a.version.compareTo(b.version));
}
/// Override: Return the raw data or null if not set yet.
Future<Map<String, dynamic>?> loadRawData();
/// Override: Return the version or null if not set yet.
Future<int?> loadVersion();
/// Override: Return [T] from [data] if the data is already at the newest version.
T fromData(Map<String, dynamic> data);
/// Override: If no data is available
T fromDefault();
/// Override: Commit the latest version and data back to the store.
Future<void> commit(int version, T data);
Future<T> load() async {
final version = await loadVersion();
final data = await loadRawData();
if (version == null || data == null) {
final ret = fromDefault();
await commit(latestVersion, ret);
return ret;
}
if (version == latestVersion) return fromData(data);
for (final migration in migrations) {
if (migration.canMigrate(version)) {
final ret = migration.migrationFunction(data);
await commit(latestVersion, ret);
return ret;
}
}
final ret = fromDefault();
await commit(latestVersion, ret);
return ret;
}
}

187
test/migrations.dart Normal file
View File

@ -0,0 +1,187 @@
import "package:moxxyv2/shared/migrator.dart";
import "package:test/test.dart";
class Greeting {
final String action;
final String entity;
final bool beNice;
Greeting(this.entity, this.action, this.beNice);
}
class TestMigrator extends Migrator<Greeting> {
final void Function(int, Greeting) onCommited;
TestMigrator(this.onCommited) : super(
2, // Latest version
[
Migration<Greeting>(
1,
(data) => Greeting(
data["name"]!,
data["action"]!,
true
)
)
]
);
@override
Future<Map<String, dynamic>?> loadRawData() async {
return {
"name": "Welt",
"action": "welcome"
};
}
@override
Future<int?> loadVersion() async => 1;
@override
Greeting fromData(Map<String, dynamic> data) => Greeting(
data["name"],
data["action"],
data["beNice"]
);
@override
Greeting fromDefault() => Greeting(
"Moxxy",
"hug",
true
);
Future<void> commit(int version, Greeting data) async {
onCommited(version, data);
}
}
class NoDataMigrator extends Migrator<Greeting> {
final void Function(int, Greeting) onCommited;
NoDataMigrator(this.onCommited) : super(
2, // Latest version
[
Migration<Greeting>(
1,
(data) => Greeting(
data["name"]!,
data["action"]!,
true
)
)
]
);
@override
Future<Map<String, dynamic>?> loadRawData() async => null;
@override
Future<int?> loadVersion() async => null;
@override
Greeting fromData(Map<String, dynamic> data) => Greeting(
data["name"],
data["action"],
data["beNice"]
);
@override
Greeting fromDefault() => Greeting(
"Moxxyv2",
"hug_more",
true
);
Future<void> commit(int version, Greeting data) async {
onCommited(version, data);
}
}
class MultipleStagedMigrator extends Migrator<Greeting> {
final void Function(int, Greeting) onCommited;
MultipleStagedMigrator(this.onCommited) : super(
3, // Latest version
[
Migration<Greeting>(
1,
(data) => Greeting(
data["name"]!,
"hug1",
true
)
),
Migration<Greeting>(
2,
(data) => Greeting(
data["name"]!,
"hug2",
true
)
)
]
);
@override
Future<Map<String, dynamic>?> loadRawData() async {
return {
"name": "Welt",
"action": "welcome"
};
}
@override
Future<int?> loadVersion() async => 2;
@override
Greeting fromData(Map<String, dynamic> data) => Greeting(
data["name"],
data["action"],
data["beNice"]
);
@override
Greeting fromDefault() => Greeting(
"Moxxyv2",
"hug_more",
true
);
Future<void> commit(int version, Greeting data) async {
onCommited(version, data);
}
}
void main() {
test("Test a simple migration", () async {
final mig = TestMigrator((v, g) {
expect(v, 2);
});
final greeting = await mig.load();
expect(greeting.entity, "Welt");
expect(greeting.action, "welcome");
expect(greeting.beNice, true);
});
test("Test loading data where there was none", () async {
final mig = NoDataMigrator((v, g) {
expect(v, 2);
});
final greeting = await mig.load();
expect(greeting.entity, "Moxxyv2");
expect(greeting.action, "hug_more");
expect(greeting.beNice, true);
});
test("Test that only the correct stage is ran", () async {
final mig = MultipleStagedMigrator((v, g) {
expect(v, 3);
});
final greeting = await mig.load();
expect(greeting.entity, "Welt");
expect(greeting.action, "hug2");
expect(greeting.beNice, true);
});
}