fletwork 0.92.71 copy "fletwork: ^0.92.71" to clipboard
fletwork: ^0.92.71 copied to clipboard

unlisted

Allows applications to easily communicate with `Antano Server` instances by handling connection logic.

Build Status

Quality Gate Status Coverage Duplicated Lines (%) Reliability Rating Security Rating

Allows applications to easily communicate with Antano Server instances by handling connection logic in the background and only informing them of pivotal events such as changes in connection state or incoming messages.

Features:

  • TCP Client
  • UDP Client
  • REST Client
  • WebSocket Client
  • PushService WIP
  • CallService (Android) WIP
  • Multi-Services
  • OAuth 2.0 for all Clients

Supported platforms:

  • iOS
  • Android
  • MacOS
  • Web

Availability of some features may be platform-dependent.

Getting started #

NOTE: fletwork is a plugin which cannot be run on its own. This ReadMe is aimed at developers who want to integrate it into their existing applications. If you're new to Flutter and have yet to create an application, refer to the official Flutter documentation.


Adding fletwork to your application #

To add fletwork to your application, git clone the repository into the same directory as the application's project folder. For instance, if the application is located in ~/projects/example, fletwork should be located in ~/projects/io.antano.fletwork.

Next, add an entry to your application's pubspec.yaml:

dependencies:
  ...
  io.antano.fletwork:
    path: ../io.antano.fletwork

Finally, from your application's project directory, run flutter pub get.


Adding Firebase to your application #

fletwork uses Firebase Cloud Messaging to send and receive Push Messaging as part of handling communication. In order for this to function correctly, your application needs to implement Firebase.

Core Firebase #

Official guide: Add Firebase to your Flutter app

Steps 1 and 2 will lead you through the process of using Firebase and Flutterfire CLIs to generate the necessary configuration files for your application. Step 3 will show you how to initialize the Firebase plugin in your application's code.

Firebase Messaging #

Official guide: Set up a Firebase Cloud Messaging client app on Flutter

Follow the platform-dependent instructions for each of the platforms supported by your application. After you have done so and installed the FCM plugin, import FirebaseMessaging in the same place you initialized Firebase (Adding Firebase) and set its requested permissions according to your requirements:

  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  if (!kIsWeb && Platform.isIOS) {
    final FirebaseMessaging firebaseMessaging = FirebaseMessaging.instance;
    firebaseMessaging.requestPermission(
      ...
    );
  }

Vapid Key for Web builds #

As you will have noticed while going through the steps in Configuring Firebase Messaging, Web builds require a Vapid Key for FirebaseMessaging to work. It is important that you follow the instructions in Configure Web Credentials with FCM to generate the Vapid Key pair. The public portion will be passed to AntanoClient later on.

Usage #

The primary interface for server communication provided by fletwork is the AntanoClient class. Your application will use it for tasks such as opening or closing connections, user registration, authentication and sending messages.

Client setup #

Before accessing its functions, AntanoClient needs to be instantiated with parameters indicating the server to connect to and credentials to use for authentication:

Parameter Type Description Required
server String Host (URL) of target Antano Server Yes
appID String Unique ID issued to the application when it was registered with the server. Used for authentication. Yes
appSecret String Secret key issued to the application when it was registered with the server. Used for authentication. Yes
fcmVapidKey String Firebase Vapid Key for the application. Only for applications supporting Web.

Example

AntanoClient myClient = AntanoClient(
    server: "https://example-server.com",
    appID: "exampleUniqueID",
    appSecret: "12345",
    fcmVapidKey: "exampleVapidKey123");

Instantiation should happen fairly high up in your application's hierarchy, e.g. the home page/widget's state.

The created AntanoClient will be a global singleton which can be accessed from anywhere within your application. This is done by simply calling the default constructor without any parameters. For example, to get an attribute:

if (AntanoClient().loggedIn) { ... }

Client initialization #

When the AntanoClient instance has been created successfully, it can be initialized by calling initClient. This will cause the client to prepare for server communication by loading any existing session data, requesting an App Token and fetching information about all services available on the server. Once the initialization process is finished and the client is ready to connect, an OnClientInitialized event will be fired.

The client can now send requests to the server using RESTful http requests (RestService). It will use the App Token it just received for authorization purposes.

Associating a user with the client #

To have access to a wider scope of APIs and protocols besides REST, a Client Token is required. To request this type of token, a User needs to be associated with the AntanoClient so its credentials can be used for authentication. This can be done by calling logIn and passing the respective username and password Strings:

await AntanoClient().logIn("exampleUser", "examplePassword");

If the AntanoClient was able to sucessfully request a Client Token using these credentials, an OnClientTokenReceived event will be triggered. The client will then create a session for the user and use the received token to connect each available service to the server.

Registering users #

Credentials passed to logIn must match a user registered with the server indicated during setup. User registration is done by calling registerUser with the following parameters:

Parameter Type Description Required
username String Username that will be used for authentication. Yes
password String User password that will be used for authentication. Yes
uniqueID String Unique identifier for the user when using this application. Can be used for addressing messages. Yes
clientName String Display name for the user when using this application. Yes
email String Email address belonging to the user. No

Example

await AntanoClient().registerUser(
    username: "exampleUsername",
    password: "examplePassword",
    uniqueID: "exampleClientUID",
    clientName: "exampleClientname",
    email: "example@email.com");

Successful registration will cause an OnUserRegistered event to be fired.


Sending messages #

The Message class offers multiple pre-configured constructors to easily compose messages depending on their intended target(s) and purpose.

Messages to other clients or groups #

To send a message to a single client or group, use the Message.fromReceiver constructor. For multiple clients or groups, use Message.fromReceivers.

Client receivers are identified by their ID (int), groups by their unique ID (String). To get a client's ID from its unique ID, send a getClient message or use the MessageReceivers class.

Example: Send Hello 1 to the client with ID 1

Message helloMsg = Message.fromReceiver("Hello", 1);
AntanoClient().sendMessage(helloMsg);

Example: Send Hello everyone to the groups with unique IDs friends and family

Message helloMsg = Message.fromReceivers("Hello everyone", ["friends", "family"]);
AntanoClient().sendMessage(helloMsg);

Message to the server to get information about a client #

To get information about a client based on its unique ID, use Message.getClient.

Example: Get information for unique ID example_client_123

Message clientInfoMsg = Message.getClient("example_client_123");
AntanoClient().sendMessage(clientInfoMsg);

Receiving messages #

OnMessageReceived will be fired for all incoming messages, so receiving messages is as simple as subscribing your message handling functions to this event.


Starting calls #

To start a call, it must be created on the server. To do this, use the Message.createCall constructor and send the generated Message using the AntanoClient. If call creation was successful, a OnCallCreated event will be fired. The Call object contained in OnCallCreated holds information about the call. Use it to establish the call within your application, e.g. by passing its information to your own custom WebRTC client or an SDK such as livekit.

Example: Create a call

AntanoEvent().addListener<OnCallCreated>((e) {
  log("Starting successfully created call: ${event.call}");
  /// your connection/call screen logic
});
Message callMessage = Message.createCall(hasVideo: true);
AntanoClient().sendMessage(callMessage);

Sending call invites #

You can invite other clients to a call by passing the respective Call object along with a list containing client or group IDs to Message.callInvite and sending the generated message to the server. If the invite was successfully passed to the receiver(s), an OnInviteSuccess event will be fired.

Example: Inviting client 123 to a call myCall

/// Call myCall = ...
AntanoEvent().addListener<OnInviteSuccess>((e) => log("Successfully invited receiver(s) ${e.receiver} to call ${e.call}"));
Message invite = Message.callInvite(myCall, [123]);
AntanoClient().sendMessage(invite);

Receiving calls #

The plugin will handle incoming call invites by creating a notification on the user's device. The user can interact with this notification to either accept or reject the call.

The exact events triggered in response to this user interaction depend on whether the application is in the foreground or not.

Receiving calls in the foreground #

In the foreground, depending on user action, either OnCallAccepted or OnCallRejected will be fired. The event will contain information about the respective call, allowing you to handle it as required by your application.

Example: Set up listeners for incoming calls

AntanoEvent().addListener<OnCallAccepted>((e) => log("User accepted incoming call: ${event.call}"));
AntanoEvent().addListener<OnCallRejected>((e) => log("User rejected incoming call: ${event.call}"));

Receiving calls in the background #

There are dedicated background handlers for calls received while the application is in the background or terminated. You can set these handlers by calling AntanoClient.setCallBackgroundHandlers. They must be top-level functions accepting one CallEvent object as an argument and returning a Future.

Example: Set a background handler for rejected calls

/// Top level handler function
Future<void> backgroundRejectHandler(CallEvent event) async {
  /// Easily get a [Call] object from the event
  Call rejectedCall = Call.fromEvent(event);
  log("User rejected incoming call: ${rejectedCall}");
}

class MyWidgetState extends State<MyWidget> {
  @override
  void initState() {
    AntanoClient().setCallBackgroundHandlers(callRejectedHandler: backgroundRejectHandler);
  }
}

If the user accepts a call in the background, the plugin will launch the application, fire an OnCallAccepted event and the background handler (if set). You can handle this the same way as you do in the foreground state.

If they reject a call, the plugin will only fire the background handler (if set).


Event System #

io.antano.fletwork comes with an event bus system in order to fire each important event. These events can be subscribed to by registering callbacks, allowing your application to react to changes in the AntanoClient's state and incoming messages.

As a global singleton, AntanoEvent can be accessed from anywhere by calling the default constructor. In order to function correctly, it must be assigned a context. This can be done by calling setBuildContext using the dependent Flutter application's BuildContext:

Widget build(BuildContext context) {
  ...
  AntanoEvent().setBuildContext(myContext);
  ...
}

Subscribing to events #

To be notified when an event is fired, you can subscribe to it by adding listeners. This can be done either using the helper method addListener in AntanoEvent or directly through its eventBus. Both will return the listener as a StreamSubscription object which can be used to alter or cancel it.

Unsubscribing from events #

To stop a subscribed callback from receiving events, the associated listener needs to be cancelled. This is possible either through the helper method removeListener or by directly cancelling the associated StreamSubscription.

Examples #

The following example registers a callback with OnMessageReceived which prints the event message and then cancels the listener:

Using addListener and removeListener

void exampleCallback(OnMessageReceived event) {
  log(event.message);
  AntanoEvent().removeListener<OnMessageReceived>(exampleCallback);
}

void main() {
  AntanoEvent().addListener<OnMessageReceived>(exampleCallback);
}

Using the eventBus

StreamSubscription? listener;

void exampleCallback(OnMessageReceived event) {
  log(event.message);
  listener?.cancel();
}

void main() {
  listener =
        AntanoEvent().eventBus.on<OnMessageReceived>().listen(exampleCallback);
}

When to use EventBus vs. addListener/removeListener #

A downside to using eventBus is that cancelling subscriptions or avoiding duplicates (the same callback listening to the same event more than once) requires your application to keep track of all listeners itself.

An upside is that it allows inline callbacks to be added and removed:

void main() {
  StreamSubscription? listener;

  listener = AntanoEvent().eventBus.on<OnMessageReceived>().listen((event) {
    log(event.message);
    listener?.cancel();
  });
}

This is not possible when using addListener and removeListener due to the way listeners are tracked in AntanoEvent.

Event Types #

This list may be changed or enhanced in future versions.

OnClientInitialized
Property Type Summary
isInitialized bool Whether Client initialization was successful.

Summary #

This event is fired when the AntanoClient has finished initializing.

Use case #

High importance event. Add listeners to know when your application can start registering users or log in to establish server connections.

OnClientLoggedIn

Properties #

Name Type Summary
response Message Positive server response to a connection request.

Summary #

This event is fired when any of the AntanoClient's services has successfully established a connection to the server. It is only fired once per session, i.e. will only re-fire if a logout occurred in the meantime.

Use case #

High importance event. Add listeners to know when your application can start communicating with the server.

OnClientLoggedInService
Property Type Summary
response Message Positive server response to a connection request.

Summary #

This event is fired every time one of the AntanoClient's services has successfully established a connection to the server.

Use case #

Low importance event. Add listeners to keep track of all established server connections.

OnClientLoggedOut

Properties #

-

Summary #

This event is fired when the AntanoClient has logged out, i.e. terminated the user session, reset its tokens and closed all established connections to the server.

Use case #

Medium importance event. Logout completion can be tracked by simply awaiting AntanoClient.logOut, but if the information is needed in multiple locations, adding listeners to OnClientLoggedOut may be more efficient.

OnUserRegistered
Property Type Summary
response Message Server response to a registration request. Message value contains registered user's data.

Summary #

Fired when a user is successfully registered with the server.

Use case #

High importance event if your application includes user registration. Add listeners to know whether your application's attempts to register users have succeeded.

OnAppTokenReceived
Property Type Summary
accessToken JsonWebToken Deserialized access token from the token response.

Summary #

Fired when a token response for an App Token is received from the server.

Use case #

Low importance event. Add listeners to keep track of all received App Tokens.

OnClientTokenReceived
Property Type Summary
accessToken JsonWebToken Deserialized access token from the token response.
refreshToken JsonWebToken Deserialized refresh token if the token response contained one, otherwise null.

Summary #

Fired when a token response for a Client Token is received from the server.

Use case #

Low importance event. Add listeners to keep track of all received Client Tokens.

OnMessageReceived
Property Type Summary
response Message An incoming message.

Summary #

Fired when the AntanoClient receives a message from the server.

Use case #

High importance event. Add listeners to enable your application to communicate with the server, handling incoming messages according to its requirements.

OnCallCreated
Property Type Summary
call Call A created call.

Summary #

Fired when a call is successfully created on the server.

Use case #

High importance event if your application includes calls. Add listeners to enable your application to know whether it can start user-initiated calls / send invites to other clients.

OnCallAccepted
Property Type Summary
call Call An accepted call.

Summary #

Fired when an incoming call is accepted by the user.

Use case #

High importance event if your application includes calls. Add listeners to join the call / transition to your application's call screen widget using the information contained in the received Call object.

OnCallRejected
Property Type Summary
call Call A rejected call.

Summary #

Fired when an incoming call is rejected by the user (foreground only)

Use case #

High importance event if your application includes calls. Add listeners to send a message to the server and/or caller client informing them of the rejection.

OnCallEnded
Property Type Summary
call Call An ended call.

Summary #

Fired when a call is ended.

Use case #

High importance event if your application includes calls. Add listeners to exit the call / your application's call screen widget.

OnCallMuted
Property Type Summary
call Call A muted/unmuted call.
muted bool Whether the call was muted.

Summary #

Fired when a call's muted state is changed.

Use case #

Medium importance event if your application includes calls. The plugin will fire this on iOS only. Add listeners to be notified about changes in the call's mute state, e.g. to update your call screen UI.

OnCallHeld
Property Type Summary
call Call A held/unheld call.
isHeld bool Whether the call was put on hold.

Summary #

Fired when a call's hold state is changed.

Use case #

The plugin does not currently fire this event.

OnInviteSuccess
Property Type Summary
call Call A call for which an invite was sent.
receiver dynamic The invited receiver(s).

Summary #

Fired when an invite to a call was successfully handled by the server.

Use case #

High importance event if your application includes calls. Add listeners so your application knows whether its call invites were successfully sent, e.g. to notify the user, retry, or cancel the call.

OnCallMessage
Property Type Summary
message Map<String, dynamic> A call for which an invite was sent.

Summary #

Fired when the client receives any message with the entity CALL.

Use case #

Low importance event. The plugin will parse call messages and handle them by firing the appropriate event. Add listeners if you want your application to track all incoming call messages regardless.

OnClientReceived
Property Type Summary
id int ID of the requested Client.
uniqueID String Unique ID of the requested Client.
clientName String Name of the requested Client.

Summary #

Fired when a request for information about a specific Client is answered by the server. Used by the MessageReceiver class.

Use case #

Low importance event. Add listeners to track responses to Client information requests, made either by MessageReceiver or your application.

OnException
Property Type Summary
exception Exception The exception that triggered the event.

Summary #

Fired when an exception is thrown within fletwork.

Use case #

High importance event. Add listeners to catch and handle plugin-specific exceptions in your application.


MessageReceivers #

An application may sometimes know only a client's Unique ID, not its ID. MessageReceivers is a helper class that will compose Client information requests for your application and keep a cache of all known ID <-> Unique ID mappings. It will also save the client's Client Name if it has one. These mappings come in the form of MessageReceiver objects: ClientReceiver for clients, GroupReceiver for groups.

MessageReceivers is a global singleton and does not require any initialization. It is accessible anywhere by calling MessageReceivers().

Getting a ClientReceiver #

To get the mapping for a specific client, await getClient, passing the client's Unique ID and a callback function as parameters. The function will send a request to the server and wait for the response. By default, the maximum waiting time is 5 seconds, and the delay between checks 50 milliseconds. You can modify these values by passing the optional parameters delay and maxWaitTime.

Example: Basic structure

void getClientCallback(ClientReceiver receiver, bool result) {
  if (result) {
    print("Got ID ${receiver.id} for client ${receiver.uniqueID}");
  } else {
    print("Could not get ID for client ${receiver.uniqueID}");
  }
}

void callingFunc() async {
  bool result = await MessageReceivers().getClient(
      "example_client_123", getClientCallback
}

Unit tests #

Unit tests for fletwork are located in the test directory. The naming convention is such that tests for a file lib/src/example.dart would be located in test/example_test.dart. To contribute unit tests, either open the existing test file for code you want to test or create a new one.

Test file structure #

import 'package:flutter_test/flutter_test.dart';

void main() {
  setUp(() {
    // Setup logic executed before all tests
  });

  test("Test case summary", () {
    // Actual test logic
  });

  group("Test group summary", () {
    // Group of related test cases    
    setUp(() {
      // Setup logic executed before all tests within this group
    });

    test("Grouped test case summary", () {
      // Actual test logic
    });

    tearDown(() {
      // Teardown logic executed after all tests within this group
    });
  });

  tearDown(() {
    // Teardown logic executed after all tests
  });
}

All tests reside in main and are executed in order. They can be categorized into groups of related test cases by putting them within a group. Setup and teardown logic can be declared using functions passed to setUp and tearDown respectively. When called at the top level, they will be executed for every test, regardless of grouping; when nested within a group, they will only be executed for tests within that group.

Test assertions/conditions #

Assertions are made by passing the object to validate and a Matcher to expect. Information about the various available Matchers and how to extend them can be found in the flutter_test documentation. A common example is equals:

Asserting that a test variable equals some expected value

  test("Equals example", () {
    expect(test_value, equals(expected_value));
  });

Mocking #

mockito provides the option to generate mock versions of classes for testing purposes. For general information, see:

Basic mocking example

import 'package:flutter_test/flutter_test.dart';
/// import the necessary packages
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

/// Generate a Mock version of the class `Example`
/// (Default name scheme is `Mock{ClassName}` => `MockExample`)
@GenerateMocks([Example])
void main() {
  /// Create a mock instance
  MockExample mock = MockExample();

  /// Stub a method by associating argument(s) with a return value
  when(mock.doThing("yes")).thenReturn(True);
  when(mock.doThing("no")).thenReturn(False);

  test("Yes", () {    
    /// Stubbed method call returns the value defined above
    expect(mock.doThing("yes"), isTrue);
    /// Verify subbed method was called with specific argument(s)
    verify(mock.doThing("yes"));
  });
}

Considerations and examples for writing unit-testable code #

Instances of these mock classes can then passed to the code you want to test within your unit tests. That means anything not instantiated within your test function can't be mocked, e.g.:

  • static references
  • parts of a class, e.g. individual functions
  • objects instantiated within tested code
  • extensions

This has implications regarding which patterns to use when writing or refactoring code.

Using static references, locally instantiated objects, extensions etc. is fine if execution of the original code during testing won't lead to errors and it's possible to formulate useful test conditions without having access to verify. In that case, mocking is not required. Whenever these conditions aren't met, however, code must be structured to allow for injection of mocked dependencies.

Since code that is safe to test as-is could change to not be safe anymore at a later date, it's best to default to dependency injection-friendly patterns and only diverge from them when absolutely necessary.

BAD #

dependentMethod in Example depends on the class Dependency. Since the Dependency object it relies on is instantiated within Example itself, there is no way for it to be mocked. It can't be reliably unit tested.

class Dependency {
  void executeLogic() { ... }
}

class Example {
  void dependentMethod() {    
    Dependency dependency = Dependency();
    dependency.executeLogic();
    ...
  }
}

/// in the test file
void main() {
  Example example = Example();

  test("dependent method", () {
    /// No way to mock, no way to directly validate
    example.dependentMethod();
  });
}

BETTER #

A workaround more than a true solution. Example still depends on Dependency which is instantiated within the class, but it is now assigned to and accessed through the dependency property. While it can still not be mocked within the method responsible for its creation, the value of dependency can be changed afterwards, allowing the remaining code in Example to be tested with a mock version.

class Dependency {
  void executeLogic() { ... }
}

class Example {
  late Dependency dependency;

  Example() {
    dependency = Dependency();
  }

  void dependentMethod() {
    dependency.executeLogic();
  }
}

/// in the test file
@GenerateMocks([Dependency])
void main() {
  MockDependency mockDependency = MockDependency();
  Example example = Example();
  /// Change it to the `MockDependency`
  example.dependency = mock;

  test("dependent method", () {
    example.dependentMethod();
    /// We can `verify` the call
    verify(mockDependency.executeLogic());
  });
}

GOOD #

Instead of instantiating the class itself, an instance of Dependency is now passed to Example. 100% of the code can be tested using the mocked version.

class Dependency {
  void executeLogic() { ... }
}

class Example {
  void dependentMethod(Dependency dependency) {
    dependency.executeLogic();
  }
}

/// in the test file
@GenerateMocks([Dependency])
void main() {
  MockDependency mockDependency = MockDependency();
  Example example = Example();

  test("dependent method", () {
    /// Directly inject the `MockDependency`
    example.dependentMethod(mockDependency);
    /// We can `verify` the call
    verify(mockDependency.executeLogic());
  });
}

Class Structure #

The basic Class structure should follow best-practices for flutter development. A current classdiagram you can find here: ClassDiagram

Generate new ClassDiagram (UML) #

In order to generate a ClassDiagram you need to install:

dart pub global activate dcdg

Once this is done you can simply create a uml with:

dart pub global run dcdg -o umlfile

0
likes
140
points
71
downloads

Publisher

verified publisherantano.io

Weekly Downloads

Allows applications to easily communicate with `Antano Server` instances by handling connection logic.

Homepage

Documentation

API reference

License

BSD-3-Clause (license)

Dependencies

event_bus, firebase_core, firebase_messaging, flutter, flutter_secure_storage, http, jose, json_annotation, logger, msgpack_dart, permission_handler, recase, shared_preferences, universal_io, uuid, web_socket_channel

More

Packages that depend on fletwork