fletwork 0.92.71
fletwork: ^0.92.71 copied to clipboard
Allows applications to easily communicate with `Antano Server` instances by handling connection logic.
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