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.up(
    appId: "YOUR_APP_ID",
    appSecret: "YOUR_APP_SECRET",
    username: "USERNAME_OF_APP_USER",
    password: "PASSWORD_OF_APP_USER",
    onError: (AuthError error) {
      print(error.toString());
    },
  );

to set up your social cloud. You can also start the cloud in test mode with

LocalitySocialCloud.startInTestMode();

This will perform all actions locally and not synchronize events.

Next, use the PubSub mixin in one of your classes

mixin PubSub {
  Timeline timeline
  String getTopic();
  void onReceive(LocalityEvent localityEvent);
  WaitingMessage send(String event, Map<String,dynamic> payload);
}

and use

LocalitySocialCloud.supervise(somePubSub);

to start the PubSub. All messages you send to that particular topic will be received by onReceive on all PubSubs on that topic exactly once. If you restart the app later, all old events will be loaded and replayed in onReceive.

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:

LocalitySocialCloud

LocalitySocialCloud is your primary interface to easily interact with the social cloud. These are the methods you can use:

void                  LocalitySocialCloud.supervise(...)
DiscoverUsers         LocalitySocialCloud.discoverUsers(...)
GeoChannel            LocalitySocialCloud.makeGeoChannel(...)
LocalitySocialCloud   LocalitySocialCloud.up(..)

DiscoverUsers will allow you to search for users. Users are global and shared across apps.
GeoChannel allows you to place geo-entities with metadata that can be queried. LocalitySocialCloud hold your loggedInUser, which has a secret ECDH-key. Supervise starts a PubSub.

LoggedInUser, LocalityUser, End-to-End encryption

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.

Recommended Development Pattern

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:

  1. The PubSub is generally 'ViewModel' and 'Controller' at the same time. Often it extends ThrottledChangeNotifier (more on that later).
  2. 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).
  3. 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. 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 an examples for a simple end-to-end-encrypted chat based on Locality Social Cloud. You can check out the full example at locality_social_cloud_chat.

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.