abe519dae7
* Add macOS support * Update README * Update to v0.2.0 * Document breaking changes * Delete widget tests
606 lines
21 KiB
Markdown
606 lines
21 KiB
Markdown

|
|
|
|
# Nearby Service
|
|
|
|
#### Connecting phones in a P2P network
|
|
|
|
[](https://xenikii.one)
|
|
[](https://github.com/ksenia312/nearby_service/blob/main/LICENSE)
|
|
[](https://pub.dev/packages/nearby_service)
|
|
[](https://pub.dev/packages/nearby_service)
|
|
|
|
Nearby Service Flutter Plugin is used to create connections in a P2P network.
|
|
The plugin supports sending text messages and files. With it,
|
|
you can easily create any kind of information sharing application **without Internet connection**.
|
|
|
|
The package does not support communication between Android and IOS/macOS (Darwin) devices, the connection is available for
|
|
**Android-Android** and **Darwin-Darwin** relations.
|
|
|
|
Your feedback and suggestions would be greatly
|
|
appreciated! [You can leave your opinion here](https://forms.gle/FbAtW2dG5RYCxb1DA)
|
|
|
|
## Video
|
|
|
|
Watch a quick demonstration in MP4 format:
|
|
- [Android](https://drive.google.com/file/d/1nxdzDjBC6pMmT5GEyf4u88X5Q77khCWy/view?usp=drive_link)
|
|
- [iOS](https://drive.google.com/file/d/1MDyDxMJ5jjU9lr-wGeqcAY1uEEHNHTZ_/view?usp=drive_link)
|
|
|
|
Or find the GIF demo at the end of the README:
|
|
- [Demo](#demo)
|
|
|
|
## Table of Contents
|
|
|
|
- [About](#about)
|
|
- [Android](#about-android-plugin)
|
|
- [IOS and macOS](#about-ios-and-macos-plugin)
|
|
- [Features](#features)
|
|
- [Setup](#setup)
|
|
- [Android](#android-setup)
|
|
- [IOS and macOS](#ios-and-macos-setup)
|
|
- [Usage](#usage)
|
|
- [Data sharing](#data-sharing)
|
|
- [Text messages](#text-messages)
|
|
- [Resource messages](#resource-messages)
|
|
- [Exceptions](#exceptions)
|
|
- [Additional options](#additional-options)
|
|
- [Demo](#demo)
|
|
- [Migration Guide](#migration-guide)
|
|
- [Migrating from v0.1.0 to v0.2.0](#migrating-from-v010-to-v020)
|
|
|
|
## About
|
|
|
|
> A peer-to-peer (P2P) network is a decentralized network architecture in which each participant, called a peer, can act
|
|
> as both a client and a server. This means that peer-to-peer networks can exchange resources such as files, data or
|
|
> services directly with each other without needing a central authority or server.
|
|
|
|
### About Android Plugin
|
|
|
|
For Android, **Wi-fi Direct** is used as a P2P network.
|
|
|
|
It is implemented through the `android.net.wifi.p2p` module.
|
|
It **requires** permissions for `ACCESS_FINE_LOCATION` and `NEARBY_WIFI_DEVICES` (for Android 13+).
|
|
|
|
It is also important that you need **Wi-fi enabled** on your device to use it.
|
|
|
|
To check permissions and Wi-fi status, the
|
|
`nearby_service` plugin includes a set of methods:
|
|
|
|
- `checkWifiService()` (Android only): returns true if Wi-fi is enabled
|
|
- `requestPermissions()` (Android only): requests permissions for location and nearby devices and returns true if
|
|
granted
|
|
- `openServicesSettings()`: opens Wi-fi settings for Android and general settings for iOS and macOS
|
|
|
|
> Testing the plugin **is not possible** on Android emulators, as they usually do not contain the Wi-fi Direct function
|
|
> in general! Use physical devices for that.
|
|
|
|
### About IOS and macOS Plugin
|
|
|
|
For IOS and macOS, the P2P connection is implemented through the `Multipeer Connectivity` framework.
|
|
|
|
This framework **automatically selects** the best network technology depending on the situation—using **Wi-Fi**
|
|
if both devices are on the same network, or using **peer-to-peer Wi-Fi** or **Bluetooth** otherwise.
|
|
|
|
The module will work even with Wi-fi turned off (via Bluetooth), but you can still use `openServicesSettings()` from
|
|
the `nearby_service` plugin to open the settings and prompt the user to turn Wi-fi on.
|
|
|
|
## Features
|
|
|
|
- **Device Preparation**
|
|
- Requesting permissions to use Wi-Fi Direct (Android only)
|
|
- Checking Wi-Fi status (Android only)
|
|
- Opening settings to enable Wi-Fi (Android and IOS/macOS)
|
|
- Role selection - Browser or Advertiser (IOS and macOS only)
|
|
|
|
- **Connecting to the Device from a P2P Network**
|
|
- Listening for discovered devices (peers)
|
|
- Creating connections over the P2P network
|
|
- Monitoring the status of the connected device
|
|
|
|
- **The Data Transmission Channel**
|
|
- Establishing the channel for data exchange
|
|
- Monitoring the status of the channel
|
|
|
|
- **Typed Data Exchange**
|
|
- Transmitting text data over the P2P network
|
|
- Receiving confirmation of successful text message delivery
|
|
- Transmitting files over the P2P network
|
|
- The option to confirm or deny receiving of files over the network
|
|
|
|
## Setup
|
|
|
|
### Android setup
|
|
|
|
All necessary Android permissions are already in the **AndroidManifest.xml** of the plugin,
|
|
so you don't need to add anything **to work with p2p network**.
|
|
|
|
### IOS and macOS setup
|
|
|
|
For IOS and macOS, you need to add the following values to **Info.plist**:
|
|
|
|
```
|
|
<key>NSBonjourServices</key>
|
|
<array>
|
|
<string>_mp-connection._tcp</string>
|
|
</array>
|
|
<key>UIRequiresPersistentWiFi</key>
|
|
<true/>
|
|
<key>NSLocalNetworkUsageDescription</key>
|
|
<string>[Your description here]</string>
|
|
```
|
|
|
|
- `NSBonjourServices`
|
|
|
|
For the `nearby_service` plugin, you should add the value `<string>_mp-connection._tcp</string>`. This key is
|
|
required for **Multipeer Connectivity**. It defines the Bonjour services
|
|
that your application will look for. Bonjour is Apple's implementation of zero-configuration networking, which allows
|
|
devices on a LAN to discover each other and establish connections without requiring manual configuration.
|
|
|
|
- `UIRequiresPersistentWiFi`
|
|
|
|
This key is not strictly required for **Multipeer Connectivity** to work. This key is used
|
|
to indicate that your application requires a persistent Wi-Fi connection even when the screen is off. This can be
|
|
useful for maintaining a network connection for continuous data transfer.
|
|
|
|
- `NSLocalNetworkUsageDescription`
|
|
|
|
This key is needed to inform users why your application will use the local network. You must provide a string value
|
|
that explains why your application needs LAN access.
|
|
|
|
> Note that if you want to use the plugin **to send files**, you also need to follow the instructions about permissions
|
|
> of the filesystem management package you are using.
|
|
|
|
## Usage
|
|
|
|
> The full example demonstrating the functionality can be viewed at
|
|
> the [link](https://github.com/ksenia312/nearby_service/tree/main/example_full).
|
|
|
|
> **Important note:** for functionality that is only available for one of the platforms, you should use the
|
|
> corresponding
|
|
> API element from `NearbyService`.
|
|
>
|
|
> For Android:
|
|
>
|
|
> ```dart
|
|
> final _nearbyService = NearbyService.getInstance();
|
|
> _nearbyService.android..
|
|
> ```
|
|
> For IOS and macOS:
|
|
>
|
|
> ```dart
|
|
> final _nearbyService = NearbyService.getInstance();
|
|
> _nearbyService.darwin..
|
|
> ```
|
|
|
|
1. Import the package:
|
|
|
|
```dart
|
|
import 'package:nearby_service/nearby_service.dart';
|
|
```
|
|
|
|
2. Create an instance of NearbyService:
|
|
|
|
```dart
|
|
// getInstance() returns an instance for the current platform.
|
|
final _nearbyService = NearbyService.getInstance();
|
|
```
|
|
|
|
3. Initialize the plugin:
|
|
|
|
```dart
|
|
// You can change the device name on a P2P network only for iOS.
|
|
// Optionally pass the [darwinDeviceName].
|
|
await _nearbyService.initialize(
|
|
data: NearbyInitializeData(darwinDeviceName: darwinDeviceName),
|
|
);
|
|
```
|
|
|
|
**Extra step for Android:** ask for permissions and make sure Wi-fi is enabled:
|
|
|
|
```dart
|
|
final granted = await _nearbyService.android?.requestPermissions();
|
|
if (granted ?? false) {
|
|
// go to the checking Wi-fi step
|
|
}
|
|
```
|
|
|
|
```dart
|
|
final isWifiEnabled = await _nearbyService.android?.checkWifiService();
|
|
if (isWifiEnabled ?? false) {
|
|
// go to the starting discovery step
|
|
}
|
|
```
|
|
|
|
**Extra step for IOS and macOS:** ask the user to choose whether they are a Browser or Advertiser:
|
|
|
|
> In IOS and macOS Multipeer Connectivity, there are 2 roles for the discovery process and connection between devices: **browser**
|
|
> and **advertiser**.
|
|
>
|
|
> **Browser**: This component discovers nearby devices that report their availability. It is
|
|
> responsible for finding peers that advertise themselves and inviting them to join the shared session.
|
|
>
|
|
> **Advertiser**: This component advertises the availability of the device to nearby peers. It is used to let know
|
|
> the browser that the device is available for inviting and connecting.
|
|
|
|
The code for selecting a role:
|
|
|
|
```dart
|
|
_nearbyService.darwin?.setIsBrowser(value: isBrowser);
|
|
// go to the starting discovery step
|
|
```
|
|
|
|
4. Start to discover the P2P network.
|
|
|
|
```dart
|
|
final result = await _nearbyService.discover();
|
|
if (result) {
|
|
// go to the listening peers step
|
|
}
|
|
```
|
|
|
|
5. Start listening to peers:
|
|
|
|
```dart
|
|
_nearbyService.getPeersStream().listen((event) => peers = event);
|
|
```
|
|
|
|
6. Each of the peers is a `NearbyDevice` and you can connect to it:
|
|
|
|
> Remember that when used on the Android platform, you can only pass `NearbyAndroidDevice` to the `connect()` method.
|
|
> Similarly for iOS and macOS, `NearbyDarwinDevice`. Devices automatically come from the discovery state in the correct type, so you
|
|
> just need to use the received data.
|
|
|
|
```dart
|
|
final result = await _nearbyService.connect(device);
|
|
if (result) {
|
|
// go the the listening the device step
|
|
}
|
|
```
|
|
|
|
7. Once you are connected via P2P network, you can start listening to the connected device. If `null` comes from the
|
|
stream, it means the devices lost connection.
|
|
|
|
```dart
|
|
_connectedDeviceSubscription = _nearbyService.getConnectedDeviceStream(device).listen(
|
|
(event) async {
|
|
final wasConnected = connectedDevice?.status.isConnected ?? false;
|
|
final nowConnected = event?.status.isConnected ?? false;
|
|
if (wasConnected && !nowConnected) {
|
|
// return to the discovery state
|
|
}
|
|
connectedDevice = event;
|
|
},
|
|
);
|
|
```
|
|
|
|
8. Once you have connected over a P2P network, you still need to create a **communication channel** to transfer data.
|
|
For Android, this is a **socket** embedded in `NearbyService`, for iOS and macOS it's a setup to listen to messages and
|
|
resources from the desired device. There is a method `startCommunicationChannel()` for this purpose. You should pass
|
|
to it listeners for messages and resources received from the connected device. There can only be one communication
|
|
channel, if you create a new one, the previous one will be **cancelled**.
|
|
|
|
```dart
|
|
final messagesListener = NearbyServiceMessagesListener(
|
|
onData: (message) {
|
|
// handle the message from NearbyServiceMessagesListener
|
|
},
|
|
);
|
|
final filesListener = NearbyServiceFilesListener(
|
|
onData: (pack) async {
|
|
// handle the files pack from NearbyServiceFilesListener
|
|
},
|
|
);
|
|
|
|
await _nearbyService.startCommunicationChannel(
|
|
NearbyCommunicationChannelData(
|
|
connectedDevice.info.id,
|
|
messagesListener: messagesListener,
|
|
filesListener: filesListener,
|
|
),
|
|
);
|
|
```
|
|
|
|
9. I think you've already guessed that since there are listeners of messages from another device, we can send them
|
|
ourselves too :) There is a method `send()` for that:
|
|
|
|
```dart
|
|
_nearbyService.send(
|
|
OutgoingNearbyMessage(
|
|
content: NearbyMessageTextRequest.create(value: message),
|
|
receiver: connectedDevice.info,
|
|
),
|
|
);
|
|
```
|
|
|
|
## Data sharing
|
|
|
|
This is a very important topic for the `nearby_service` plugin because it provides unique functionality for sharing
|
|
typed data.
|
|
|
|
First, it's essential to say that when you send a message, you are required to pass the `OutgoingNearbyMessage` model.
|
|
This contains the `receiver` - the recipient to whom the message is addressed. The receiver is someone with whom you
|
|
already have an established communication channel. Also `OutgoingNearbyMessage` contains a `content` field, it can be
|
|
one of 4 types:
|
|
|
|
- `NearbyMessageTextRequest`
|
|
- `NearbyMessageTextResponse`
|
|
- `NearbyMessageFilesRequest`
|
|
- `NearbyMessageFilesResponse`
|
|
|
|
We will talk more about them later.
|
|
|
|
Second, there is another type of message, `ReceivedNearbyMessage`. This is what comes to you from
|
|
the `NearbyServiceMessagesListener`. It contains a `sender` field so that you can identify who the message came from and
|
|
use that information.
|
|
|
|
### Text messages
|
|
|
|
Description of the operating logic:
|
|
|
|
1. One of the devices sends a message using the `send()` method:
|
|
|
|
```dart
|
|
// DEVICE A
|
|
_nearbyService.send(
|
|
OutgoingNearbyMessage(
|
|
content: NearbyMessageTextRequest.create(value: message),
|
|
receiver: connectedDevice.info,
|
|
),
|
|
);
|
|
```
|
|
|
|
2. Another device receives the `ReceivedNearbyMessage` with content cast as `NearbyMessageTextRequest` from the
|
|
`NearbyServiceMessagesListener`.
|
|
|
|
```dart
|
|
// DEVICE B
|
|
final messagesListener = NearbyServiceMessagesListener(
|
|
onData: (message) {
|
|
// message is ReceivedNearbyMessage with content cast as NearbyMessageTextRequest here
|
|
},
|
|
);
|
|
```
|
|
|
|
3. If you want the sender to make sure the message has been received, you can send them `NearbyMessageTextResponse`. You
|
|
need to add the `id` from the received `NearbyMessageTextRequest` to it:
|
|
|
|
```dart
|
|
// DEVICE B
|
|
_nearbyService.send(
|
|
OutgoingNearbyMessage(
|
|
receiver: connectedDevice.info,
|
|
content: NearbyMessageTextResponse(id: requestId),
|
|
),
|
|
);
|
|
```
|
|
|
|
4. If everything is fine, the sender will receive your `NearbyMessageTextResponse` from
|
|
their `NearbyServiceMessagesListener`:
|
|
|
|
```dart
|
|
// DEVICE A
|
|
final messagesListener = NearbyServiceMessagesListener(
|
|
onData: (message) {
|
|
// message is ReceivedNearbyMessage with content cast as NearbyMessageTextResponse here
|
|
},
|
|
);
|
|
```
|
|
|
|
> Throughout the process, you can use the `id`s of the messages to identify them.
|
|
|
|
### Resource messages
|
|
|
|
Description of the operating logic:
|
|
|
|
1. One of the devices sends a message with `NearbyMessageFilesRequest` using the `send()` method:
|
|
|
|
```dart
|
|
// DEVICE A
|
|
_nearbyService.send(
|
|
OutgoingNearbyMessage(
|
|
// files here is List<NearbyFileInfo>, it can be easily created from the file path: NearbyFileInfo(path: file.path)
|
|
content: NearbyMessageFilesRequest.create(files: files),
|
|
receiver: connectedDevice.info,
|
|
),
|
|
);
|
|
```
|
|
|
|
2. Another device receives the `ReceivedNearbyMessage` with content cast as `NearbyMessageFilesRequest` from the
|
|
`NearbyServiceMessagesListener`.
|
|
|
|
> The file request comes from the `NearbyServiceMessagesListener` because it doesn't contain the transferred files, only
|
|
> the request to send them! For files, confirmation by the other party before starting the data transfer is required!
|
|
|
|
```dart
|
|
// DEVICE B
|
|
final messagesListener = NearbyServiceMessagesListener(
|
|
onData: (message) {
|
|
// message is ReceivedNearbyMessage with content cast as NearbyMessageFilesRequest here
|
|
},
|
|
);
|
|
```
|
|
|
|
3. In order to receive the files, the other party must confirm that it wants to do so and send a message
|
|
with `NearbyMessageFilesResponse` content. The `NearbyMessageFilesResponse` contains the `isAccepted` field,
|
|
which will determine whether the file sending will start or not.
|
|
|
|
> If you don't want to use confirmation logic to send files, just send the automatic positive responses
|
|
> to `NearbyMessageFilesRequest` in `NearbyServiceMessagesListener`.
|
|
|
|
```dart
|
|
// DEVICE B
|
|
_nearbyService.send(
|
|
OutgoingNearbyMessage(
|
|
receiver: connectedDevice!.info,
|
|
content: NearbyMessageFilesResponse(
|
|
id: request.id,
|
|
isAccepted: isAccepted,
|
|
),
|
|
),
|
|
);
|
|
```
|
|
|
|
4. The sender will receive `NearbyMessageFilesResponse` in their `NearbyServiceMessagesListener` and can notify the
|
|
user.
|
|
|
|
```dart
|
|
// DEVICE A
|
|
final messagesListener = NearbyServiceMessagesListener(
|
|
onData: (message) {
|
|
// message is ReceivedNearbyMessage with content cast as NearbyMessageFilesResponse here
|
|
},
|
|
);
|
|
```
|
|
|
|
At this time under the hood, the sending of files will begin and the recipient will receive `ReceivedNearbyFilesPack` in
|
|
the `NearbyServiceFilesListener` when sending is complete. `ReceivedNearbyFilesPack` holds the paths of the received
|
|
files already stored on the device's temporary directory. You can overwrite them to the desired location, delete them,
|
|
or do anything else you like.
|
|
|
|
```dart
|
|
// DEVICE B
|
|
final filesListener = NearbyServiceFilesListener(
|
|
onData: (pack) async {
|
|
// pack is ReceivedNearbyFilesPack here
|
|
},
|
|
);
|
|
```
|
|
|
|
## Exceptions
|
|
|
|
**NearbyService** includes custom errors that you can catch in your implementation.
|
|
|
|
**Common exceptions [See here](https://github.com/ksenia312/nearby_service/blob/main/lib/src/utils/exception.dart):**
|
|
|
|
- `NearbyServiceUnsupportedPlatformException`: Usage of the plugin on an unsupported platform
|
|
- `NearbyServiceUnsupportedDecodingException`: Error decoding messages from native platform to Dart (open an issue if
|
|
this happens)
|
|
- `NearbyServiceInvalidMessageException`: An attempt to send an invalid message on the sender's side. Add content
|
|
validation to your messages
|
|
|
|
**Exceptions that can be caught from the `discover()`, `stopDiscovery()`, `connect()`, and `disconnect()` methods for
|
|
the Android platform
|
|
[See here](https://github.com/ksenia312/nearby_service/blob/main/lib/src/platforms/android/utils/exception.dart):**
|
|
|
|
- `NearbyServiceBusyException`: The Wi-Fi P2P framework is currently busy. Usually this means that you have sent a
|
|
request to some device and now one of the peers is **CONNECTING**
|
|
- `NearbyServiceP2PPUnsupportedException`: Wi-Fi P2P is not supported on this device
|
|
- `NearbyServiceNoServiceRequestsException`: No service discovery requests have been made. Ensure that you have
|
|
initiated a service discovery request before attempting to connect
|
|
- `NearbyServiceGenericErrorException`: A generic error occurred. This could be due to various reasons such as hardware
|
|
issues, Wi-Fi being turned off, or temporary issues with the Wi-Fi P2P framework
|
|
- `NearbyServiceUnknownException`: An unknown error occurred. Please check the device's Wi-Fi P2P settings and ensure
|
|
the device supports Wi-Fi P2P
|
|
|
|
## Additional options
|
|
|
|
- Each `NearbyDevice` contains `NearbyDeviceInfo` that presents different meanings depending on the platform. For
|
|
Android `id` is the MAC address of the device, and for iOS it is the unique identifier of its MCPeerID. In general, id
|
|
identifies the device
|
|
in its platform.
|
|
- Use the getter `communicationChannelState` of `NearbyService` to know the state of the communication channel. It may
|
|
be:
|
|
|
|
```dart
|
|
enum CommunicationChannelState {
|
|
notConnected,
|
|
loading,
|
|
connected;
|
|
|
|
bool get isNotConnected => this == CommunicationChannelState.notConnected;
|
|
|
|
bool get isLoading => this == CommunicationChannelState.loading;
|
|
|
|
bool get isConnected => this == CommunicationChannelState.connected;
|
|
}
|
|
```
|
|
|
|
The getter is `ValueListenable` so you can listen to its state.
|
|
- There are methods `getPlatformVersion()` and `getPlatformModel()` of `NearbyService` to determine the version and
|
|
model of the device respectively. You can use them to specify a
|
|
name for the IOS.
|
|
- Use `getCurrentDeviceInfo()` of `NearbyService` to get information about the current device on the P2P network. Note
|
|
that the ID obtained from `getCurrentDeviceInfo()` for Android will always be **02:00:00:00:00:00** for privacy
|
|
issues.
|
|
- For Android, it is possible to listen to the current state of the connection using
|
|
method `getConnectionInfoStream` of `NearbyAndroidService`.
|
|
- When you are creating a service with `getInstance()`, you can define the logging level in the plugin using
|
|
field `logLevel`.
|
|
Possible logging levels:
|
|
|
|
```dart
|
|
enum NearbyServiceLogLevel {
|
|
debug,
|
|
info,
|
|
error,
|
|
disabled,
|
|
}
|
|
```
|
|
|
|
## Demo
|
|
|
|
### Android
|
|
|
|

|
|
|
|
### IOS
|
|
|
|

|
|
|
|
## Migration Guide
|
|
|
|
### Migrating from v0.1.0 to v0.2.0
|
|
|
|
Version 0.2.0 adds support for macOS but introduces breaking changes to accommodate the unified Darwin platform (iOS and macOS).
|
|
|
|
#### API Changes
|
|
|
|
1. **Property Renaming**
|
|
- `.ios` property has been renamed to `.darwin`
|
|
```dart
|
|
// Before (v0.1.0)
|
|
_nearbyService.ios?.setIsBrowser(value: isBrowser);
|
|
|
|
// After (v0.2.0)
|
|
_nearbyService.darwin?.setIsBrowser(value: isBrowser);
|
|
```
|
|
|
|
2. **Class Renaming**
|
|
- `NearbyIOSDevice` has been renamed to `NearbyDarwinDevice`
|
|
- `NearbyIOSService` has been renamed to `NearbyDarwinService`
|
|
- `NearbyServiceIOSExceptionMapper` has been renamed to `NearbyServiceDarwinExceptionMapper`
|
|
|
|
3. **Parameter Renaming in NearbyInitializeData**
|
|
- `iosDeviceName` has been renamed to `darwinDeviceName`
|
|
```dart
|
|
// Before (v0.1.0)
|
|
await _nearbyService.initialize(
|
|
data: NearbyInitializeData(iosDeviceName: deviceName),
|
|
);
|
|
|
|
// After (v0.2.0)
|
|
await _nearbyService.initialize(
|
|
data: NearbyInitializeData(darwinDeviceName: deviceName),
|
|
);
|
|
```
|
|
|
|
#### Assert Statements
|
|
|
|
If you have assertions in your code that check for specific types, make sure to update them:
|
|
|
|
```dart
|
|
// Before (v0.1.0)
|
|
assert(
|
|
device is NearbyIOSDevice,
|
|
'The Nearby IOS Service can only work with the NearbyIOSDevice'
|
|
);
|
|
|
|
// After (v0.2.0)
|
|
assert(
|
|
device is NearbyDarwinDevice,
|
|
'The Nearby Darwin Service can only work with the NearbyDarwinDevice'
|
|
);
|
|
```
|
|
|
|
#### Flutter Configuration
|
|
|
|
If you're developing a plugin based on `nearby_service`, note that the `pubspec.yaml` now uses `sharedDarwinSource: true` to share code between iOS and macOS platforms. |