locality_social_cloud 1.5.0 locality_social_cloud: ^1.5.0 copied to clipboard
Write social apps with Flutter. This code connects to the backend of Locality Social Cloud to provide realtime state synchronization across devices.
locality_social_cloud #
Locality is there to fuel the development of serverless Social Apps with Flutter. Social Apps are fundamentally a technological reflection of real social interactions. In real social interactions, we interact through objective space that transports state changes at light speed. The challenge of creating objective spaces for multiple observers across many devices is fundamental. Traditionally, state management and business logic are performed on servers. This creates significant challenges when dealing with End-to-End-encrypted interactions. With Locality Social Cloud, you can create serverless Social Apps and model all your domains on the frontend. This allows for improved flexibility when building or updating features (no deployment) and allows you to develop Social Apps in a way where the problem modelling just fits the problem space more naturally.
Examples of this pattern #
For example, a chat you can view as just a distributed list of messages. All observers want to modify and see the same list of messages. The same way, a FriendList is just a distributed List of Friends. This has the advantage that the user can restore their FriendList on another device, even though it may be encrypted. A cryptocurrency you could see as a distributed Map, Key-Value-store, with an authoritative timeline; all observers want to modify and view the same map.
Build Social Networks and other collaborative or together-apps. #
Locality Social Cloud provides you with a PubSub mixin that allows you to publish to and receive events from addresses in realtime. You are provided with the guarantee, that each PubSub processes each event that was published to its address exactly once. This holds true, even if the user goes offline or loses connection; once he is online again, the state is seamlessly synchronized. In addition to this, Locality Social Cloud allows you to easily build End-to-End-Encryption; Each user has a public ECDH key and you have methods of generating a shared key and encrypting PubSubs with keys. On top of that, users can securely share keys accross devices and yield keys for end-to-end-encrypted topics, so that you can, for example, write encrypted group chats.
Project Setup #
Create a developer account for your company #
First, register a developer account and an app. You can do that here.
Add the locality_social_cloud library to Flutter #
Write
flutter pub add locality_social_cloud
flutter pub get
to add the Locality Social Cloud to your Flutter App.
Configure Locality Social Cloud #
Obtain your app-ID and app secret from your developer account and write
LocalitySocialCloud.configure(app_id: 'YOUR APP ID', app_secret: 'YOUR APP SECRET');
to set up your social cloud.
Classes #
LocalitySocialCloud #
LocalitySocialCloud is your primary interface to easily interact with the social cloud. It is a facade.
LocalitySocialCloud.configure({required String appId, required String appSecret})
Future<LoggedInUserOrError> LocalitySocialCloud.auth(String userName, String password, {bool testUser = false})
LocalitySocialCloud.connect(LoggedInUser loggedInUser)
LocalitySocialCloud.supervise(PubSub pubSub, {ChaCha20Key? key})
The method 'configure' sets your appId and appSecret obtained from the developer console. Locality Social Cloud backend will deny your request without comment, if you have no valid appId and appSecret. The method 'auth' will create a new account, if no account exists for the userName, and also give it a public ECDH M-511 key and a private key. If a user exists, it will log in to the account and restore the ECDH key. In both cases, in the background, we receive an AccessToken from the server and exchange it for a new one once it expired. The method 'auth' returns a LoggedInUserOrError. Check if it is a user by using isUser and then you can proceed to connect to the LIVE-connection of the cloud. The method 'connect' takes the loggedInUser and starts the PubSub system. It must only be called once, globally, somewhere. Once connected, it will send all messages from the WaitingMessagesQueue. The WaitingMessagesQueue gets filled if you send events to a PubSub, but are not connected. Finally, the method 'supervise' is needed to actually start observing a PubSub and throw / receive events from the PubSub. Provide a ChaCha20Key to encrypt all traffic in the PubSub with ChaCha20.
These are the most important methods. Additionally, you can use
DiscoverUsers LocalitySocialCloud.discoverUsers()
to get a DiscoverUsers object that we will explain later.
LoggedInUser, LocalityUser, End-to-End encryption #
Using LocalitySocialCloud.auth will give you a LoggedInUser. The difference to a LocalityUser is that a LoggedInUser has a private key and an access token, but you need not worry about that. If you have received LocalityUsers, for example from LocalitySocialCloud.discoverUsers(), you can then use your logged in user to get a common ECDH key, like:
Future<ChaCha20Key> getKeyFor(LocalityUser localityUser, {SharedKeyRepository? sharedKeyRepository})
This will get a common ChaCha20Key for the loggedInUser and localityUser. Now, if the other user would perform the same method in the other way round, where he is the loggedInUser and we are the localityUser, he would compute the same ChaCha20Key, because that is, how ECDH works. Yet from the public key, the private key can not be computed. Since M-511 keys are 512 bit numbers and use elliptic curve additions and multiplications, this method may take some time. Even if it is just a couple tens of miliseconds, this would create a significant delay in user interfaces if you have lists of many localityUsers and have to compute like 50 common keys. That is why, in this case, you can pass a sharedKeyRepository. It will cache the common key for a user in an SQFlite database, in an encrypted way, based on the users password. Thus, without the password, an attacker can not steal this database. The common key you computed for another user is then intended to be used, for example, as a key-Parameter in LocalitySocialCloud.supervise to ensure all traffic via that PubSub is encrypted.
PubSub, LocalityEvent #
These classes are at the core of the framework and allow you to write most functionality. Each PubSub has an address that keeps its context separate from other PubSubs. You can emit and receive events with the following guarantee:
- Each PubSub processes each event that was published to its address exactly once. This even holds true, if users go offline in between. PubSub is a mixin with this signature:
mixin PubSub {
Timeline timeline
String getTopic();
void onReceive(LocalityEvent localityEvent);
WaitingMessage send(String event, Map<String,dynamic> payload);
}
The methods 'getTopic' and 'onReceive' will have to be implemented by you, while the 'Timeline' and 'send' method are given to you. Generally the recommended pattern of development for many problems is:
- The PubSub is generally 'ViewModel' and 'Controller' at the same time. Often it extends ThrottledChangeNotifier (more on that later).
- If you need many PubSubs of the same 'type', you generally create some kind of 'Supervisor' object that keeps track of the many PubSubs it opens (or closes).
- If you work with conditional events, where some depend on others, use 'Timeline' and 'EventWaiter'. More on that later.
You implement the state changes in your onReceive method. Generally the pattern is like
void onReceive(LocalityEvent localityEvent){
switch(localityEvent.event) {
case 'event-a':
// do something with localityEvent.payload)
break;
}
}
It is not advised to split up the code for event handling into different methods. Instead, it should become a really long onReceive method. This will allow for easy reading and modification, while avoiding uneccessary jumping and scrolling back and forth in your code.
BAD:
void onReceive(LocalityEvent localityEvent){
switch(localityEvent.event) {
case 'event-a':
performMethodA(localityEvent.payload)
break;
case 'event-b':
performMethodB(localityEvent.payload)
break;
}
}
void performMethodA(Map<String, dynamic> payload) {
action1(payload['key']);
action2();
}
....
GOOD:
void onReceive(LocalityEvent localityEvent){
switch(localityEvent.event) {
case 'event-a':
action1(localityEvent.payload['key']);
action2();
break;
case 'event-b':
....
break;
}
}
In addition to a payload, a LocalityEvent comes with these fields that are autogenerated for you whenever you send an event (a Map<String, dynamic>):
class LocalityEvent {
Map<String, dynamic> payload;
String uuid;
String event;
String channel;
int timestamp;
int servertime;
}
'channel' is a combination of the topic and your app-id. 'uuid' is a globally unique ID for the event. 'timestamp' is milis since epoch on the users device when the event was sent. 'servertime' is a timestamp that the event receives from the server when it arrives there. Generally, these fields should not interest you and working with the payload suffices.
Timeline #
Timeline lets you wait for synchronization between the local and global Timeline of a PubSub, before executing functions. When Locality Social Cloud connects to the server, it fetches state synchronization updates from the server. Methods in Timeline.whenSynchronized will be called shortly after all synchronization updates from the server (or Cache, if you use a cache) have been processed. The only method you need from timeline is
somePubSub.timeline.whenSynchronized(() {
....
});
ThrottledChangeNotifier #
ThrottledChangeNotifier is used when a stateful object with state changes in a massive frequency, such as a PubSub, should be throttled. You can use notifyListeners() on a ThrottledChangeNotifier, but the listeners will only be notified of the current state at a max FPS. You can change it, on standard is 120. This also has the effect that, let us say, you have a List in your state object and have a method that adds 10 elements to the list and calls notifyListeners() each time, then the listeners will still only be notified once with the complete list. You can seamlessly just extend ThrottledChangeNotifier instead of ChangeNotifier.
BEFORE:
class Bla extends ChangeNotifier
AFTER:
class Bla extends ThrottledChangeNotifier.
WaitingEvents #
In some cases, you want certain kinds of events to wait for other kinds of events. In that case you can use the WaitingEvents mixin on a PubSub like
class DirectMessagesChat extends ThrottledChangeNotifier with PubSub, WaitingEvents {
}
mixin WaitingEvents on PubSub {
List<EventWaitInstruction> getEventWaitInstructions();
void onRelease(LocalityEvent localityEvent);
}
When you use this mixin, it will prompt you to override the 'getEventWaitInstructions' method. Use this to provide the instructions, which conditional events should wait for other events before being processed. Events that are waited for will not call 'onReceive'. If you use WaitingEvents, WaitingEvents itself will override the 'onReceive' method. Instead, use 'onRelease' in this case. It will only be called after an event saw another event that it waited for.
For example, in a chat, it could look like this:
@override
List<EventWaitInstruction> getEventWaitInstructions() {
return [
EventWaitInstruction(
event: 'message_seen',
waitsForEvent: 'message_added',
traitName: 'message_uuid'
),
EventWaitInstruction(
event: 'message_reacted',
waitsForEvent: 'message_added',
traitName: 'message_uuid'
),
EventWaitInstruction(
event: 'message_delivered',
waitsForEvent: 'message_added',
traitName: 'message_uuid'
)
];
}
This means, 'messaged_delivered' events will get held back, as long as no 'message_added' event has not been processed that has a payload with the same 'message_uuid'. That is, both the 'message_delivered' and the 'message_added' events must share a 'message_uuid' before a particular 'message_added' event gets released.
OrderedTimeline #
For most cases, you need no authoritative exactness; the order of processing of chat messages, for example, does not matter, as long as they appear in the UI ordered by timestamp. In the most general case, you never have problem with Timeline desync if you can model your problem as something like an abelian group, wher your final state gets computed from order-agnostic state transformations.
However, In some cases, you need a globally authoritative exact timeline; think of an example where you have a limited stock of tickets and many users want to buy these tickets. All users should agree on who bought a ticket first, if it was the last ticket. In this case use can use OrderedTimeline as a mixin on a PubSub. However, you should not do it in all cases, because it means, that for each event(s) received, the 'result' has to be computed entirely from scratch. OrderedTimeline will provide you with an 'onReceivedInOrder' method that may be executed multiple times for the same event.
mixin OrderedTimeline on PubSub {
onReceiveInOrder(List<LocalityEvent> event);
}
You will receive the events in temporal order, ordered by servertime. Again, opposing to onReceive, this will be called multiple times for the same events (quadratic runtime).
DiscoverUsers #
DiscoverUsers discoverUsers = LocalitySocialCloud.discoverUsers();
discoverUsers.startingWith(....);
discoverUsers.addListener(() {
discoverUsers.discoveredUsers.forEach((user) {
});
});
GeoChannel #
Real social interactions have physical proximity as a crucial factor for fun. Only, nobody can touch us. That is why, as a bonus, we offer for you a way to handle geo-objects and write geo-code.
You can obtain a GeoChannel via
LocalitySocialCloud.makeGeoChannel({required String channel})
The GeoChannel works like this:
class Locality {
double long;
double lat;
Map<String, dynamic> metadata;
Locality({required this.long, required this.lat, required this.metadata});
}
GeoChannel LocalitySocialCloud.makeGeoChannel({required String channel})
class GeoChannel
{
GeoChannel(this.channel);
void putGeoEntity(String name, Map<String, dynamic> payload, {required double latitude, required double longitude, double lifetimeSeconds = 30})
Future<List<Locality>> getNearbyEntities(double latitude, double longitude, double range) async
}
Use 'putGeoEntity' to place an item in the GeoChannel with a physical location. It will disappear after lifetimeSeconds seconds. You can also add a payload to the geo entity that will be fetched in addition to its name and location by 'getNearbyEntities'. If there is an existing item with the same name, no new entity is created. Instead, the payload of the old entity is updated. Note that this update is not notified to listeners, you would have to fetch the geo entity again to note the payload change.
Cache #
You can also use SQlite for caching. This will improve the performance. The cache is based on SQlite. Thus, on windows, you have to use
databaseFactory = databaseFactoryFfi;
sqfliteFfiInit();
You set a cache by calling
PubSubSupervisor.cache = Cache(await MessageRepository.getInstance());
Examples #
We provide a couple of examples for simple and advanced usage of locality social cloud. For more and more sophisticated examples, check out the source code of locality_social_cloud_friendlist and locality_social_cloud_chat. For now, we will stick to a classic example: A click counter.
Distributed global click counter. #
Our first example is a global click-counter. The topic is 'global-click-counter'. Every user who is connected to that topic on any device will work with the same state object and modify and view the same click counter. Thus, any user can increase the click counter. If you log in way later, after many users clicked the same click counter, it will synchronize to the global state.
Create a controller #
class ExampleController extends ThrottledChangeNotifier with PubSub {
int counter = 0;
@override
String getTopic() {
return 'global-click-counter';
}
void increaseCounter(int amount) {
send('increaseCounter', {
'amount': amount
});
}
@override
void onReceive(LocalityEvent localityEvent) {
switch(localityEvent.event) {
case 'increaseCounter':
counter += localityEvent.payload['amount'] as int;
notifyListeners();
break;
}
}
}
Create a view for your controller #
class CounterView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final exampleController = ExampleController();
return ChangeNotifierProvider.value(
value: exampleController,
.....
Consumer<ExampleController>(
builder: (context, exampleController, child) {
......
},
),
.....
);
}
}
Connect to the Social Cloud #
Somewhere in the main:
LocalitySocialCloud.configure(app_id: '....', app_secret: '.....');
LoggedInUserOrError userOrError = await Auth.login("123", "pwd");LoggedInUser? loggedInUser = await Auth.login("test_user1235", "pwd");
if ( userOrError.isUser() ) {
LocalitySocialCloud.connect(userOrError.getUser());
controllerExample(userOrError.getUser());
} else {
print("ERROR: "+loggedInUserOrError.authError.toString());
}
LocalitySocialCloud.connect(userOrError!);
Example: End-to-end encrypted chat #
Below is a full implementation of an end-to-end-encrypted chat. This is from the source code of locality_social_cloud_chat, which is also available as a standalone library, in case you want to build chat functionality for your product.
import 'package:flutter/cupertino.dart';
import 'package:locality_social_cloud/api/event_waiter.dart';
import 'package:locality_social_cloud/api/locality_event.dart';
import 'package:locality_social_cloud/api/locality_user.dart';
import 'package:locality_social_cloud/api/one_to_many.dart';
import 'package:locality_social_cloud/api/pub_sub.dart';
import 'package:locality_social_cloud/api/waiting_events.dart';
import 'package:locality_social_cloud/api/throttled_change_notifier.dart';
import 'package:locality_social_cloud_chat/group_chats.dart';
import 'package:uuid/uuid.dart';
class MessageReaction {
String userId;
String name;
MessageReaction(this.userId, this.name);
}
class MessageObserver extends ChangeNotifier {
List<MessageReaction> reactions = [];
LocalityEvent chatMessage;
bool seen;
bool delivered;
void react(String userId, String reaction) {
reactions.add(MessageReaction(userId, reaction));
notifyListeners();
}
void setSeen() {
seen = true;
delivered = true;
notifyListeners();
}
void setDelivered() {
delivered = true;
notifyListeners();
}
MessageObserver(this.chatMessage,
{required this.seen, required this.delivered});
}
class DirectMessagesChat extends ThrottledChangeNotifier
with PubSub, WaitingEvents {
List<LocalityEvent> chatMessages = [];
OneToMany<String, Function(MessageObserver)> messageWatchers = OneToMany();
Map<String, MessageObserver> messageObserversByChatMessageUUID = {};
bool isChatViewedByUser = false;
LocalityUser fromThePerspectiveOf;
String topic;
DirectMessagesChat(this.fromThePerspectiveOf, this.topic);
@override
String getTopic() {
return topic;
}
/// React to the reception of certain types of messages, for example you could react to a photo message
/// by downloading the photo.
void whenMessageOfTypeReceived(
String messageType, Function(MessageObserver) action) {
messageWatchers.put(messageType, action);
}
@override
void onRelease(LocalityEvent localityEvent) {
String messageUuid = localityEvent.payload['message_uuid'];
switch (localityEvent.event) {
case 'group_chat_invitation_accepted':
Map<String, dynamic> payload = messageObserversByChatMessageUUID[localityEvent.payload['message_uuid']]!
.chatMessage
.payload;
timeline.whenSynchronized(() {
GroupChats.getInstance().registerGroupChatInvitationAcceptance(
fromThePerspectiveOf: fromThePerspectiveOf,
groupChatId: payload['group_chat_uuid'],
topic: payload['topic'],
title: payload['title'],
key: BigInt.parse(payload['key'], radix: 36),
);
});
case 'message_reacted':
if (localityEvent.payload.containsKey('reaction')) {
messageObserversByChatMessageUUID[messageUuid]!.react(
localityEvent.payload['sender_uuid'],
localityEvent.payload['reaction']);
}
case 'message_added':
chatMessages.add(localityEvent);
MessageObserver messageObserver = MessageObserver(localityEvent, seen: false, delivered: false);
messageObserversByChatMessageUUID[messageUuid] = messageObserver;
List<Function(MessageObserver)> messageObservers = messageWatchers.get(localityEvent.payload['message_type']!);
if (messageObservers.isNotEmpty) {
for (int i = 0; i < messageObservers.length; i++) {
messageObservers[i](messageObserver);
}
}
if (localityEvent.payload['sender_uuid'] != fromThePerspectiveOf.id) {
timeline.whenSynchronized(() {
if (isChatViewedByUser && messageObserver.seen == false) {
send('message_seen', {'message_uuid': messageUuid});
}
if (!isChatViewedByUser && messageObserver.delivered == false) {
send('message_delivered', {'message_uuid': messageUuid});
}
});
}
notifyListeners();
case 'message_deleted':
String messageUuid = localityEvent.payload['message_uuid'];
chatMessages.removeWhere((LocalityEvent testEvent) =>
testEvent.event == 'message_added' &&
testEvent.payload['message_uuid'] == messageUuid);
notifyListeners();
case 'message_seen':
messageObserversByChatMessageUUID[messageUuid]!.setSeen();
case 'message_delivered':
messageObserversByChatMessageUUID[messageUuid]!.setDelivered();
}
}
static String getChatMessageUUID(Map<String, dynamic> payload) {
return payload['message_uuid'];
}
void reactToMessage(String messageUuid, String reaction) {
send('message_reacted', {
'reaction': reaction,
'message_uuid': messageUuid,
'sender_uuid': fromThePerspectiveOf.id
});
}
/// Send a payload as chat message. Put anything into the payload you like, but make sure to process it accordingly.
void sendMessage(Map<String, dynamic> payload,
{String messageType = "text_message"}) {
payload['message_uuid'] = const Uuid().v4();
payload['message_type'] = messageType;
payload['sender_uuid'] = fromThePerspectiveOf.id;
send('message_added', payload);
}
/// Marks all messages as seen
void enterChat() {
isChatViewedByUser = true;
timeline.whenSynchronized(() {
for (MessageObserver chatMessage
in messageObserversByChatMessageUUID.values) {
if (chatMessage.seen == false) {
send('message_seen', {
'message_uuid': chatMessage.chatMessage.payload['message_uuid']
});
}
}
});
}
/// Call this to invite both users to the groupchat.
void inviteToGroupChat(
String groupChatTitle, String groupChatId, String topic, BigInt key) {
Map<String, dynamic> payload = {};
payload['title'] = groupChatTitle;
payload['topic'] = topic;
payload['group_chat_uuid'] = groupChatId;
payload['key'] = key.toRadixString(36);
sendMessage(payload, messageType: 'group_chat_invitation');
}
void acceptGroupChatInvitation(String messageUuid, String groupChatId) {
send('accept_group_chat_invitation',
{'message_uuid': messageUuid, 'group_chat_uuid': groupChatId});
}
void leaveChat() {
isChatViewedByUser = false;
}
@override
List<EventWaitInstruction> getEventWaitInstructions() {
return [
EventWaitInstruction(
event: 'message_seen',
waitsForEvent: 'message_added',
traitName: 'message_uuid'
),
EventWaitInstruction(
event: 'message_reacted',
waitsForEvent: 'message_added',
traitName: 'message_uuid'
),
EventWaitInstruction(
event: 'message_delivered',
waitsForEvent: 'message_added',
traitName: 'message_uuid'
)
];
}
}
You can then create chats like this:
class DirectMessagesFacade {
/// Create an end-to-end-encrypted direct messages chat for the target user.
static Future<DirectMessagesChat> getTargetUserChat(LoggedInUser loggedInUser, LocalityUser chatPartner) async {
DirectMessagesChat directMessagesChat = DirectMessagesChat(
loggedInUser.user, getTopicForUsers(loggedInUser, chatPartner)
);
ChaCha20Key chaCha20Key = await loggedInUser.getKeyFor(chatPartner);
LocalitySocialCloud.supervise(directMessagesChat, key: chaCha20Key);
return directMessagesChat;
}
}
Wrapup #
Locality Social Cloud offers powerful tools for Flutter Developers to enhance their social apps. Once you get used to modeling your data as sequences of abelian state diffs, your mind will finally be at peace with state management.