@picsa/capacitor-offline-transfer
v8.2.0
Published
Capacitor plugin for offline, large file transfer (Android Nearby & iOS Multipeer)
Downloads
31
Maintainers
Readme
@picsa/capacitor-offline-transfer
A bespoke Capacitor plugin designed for completely offline, cross-device large file sharing (50MB+ videos and Universal APKs). Engineered specifically for rural environments with zero internet connectivity, this plugin operates as a multi-tier transfer engine to ensure maximum compatibility across device types.
Architecture
The plugin provides two P2P transfer tiers for devices with the app installed, plus native OS sharing for uninstalled devices:
graph TD
A[Start Transfer] --> B{Has the app installed?}
B -- Yes (Android) --> C[Tier 1: Google Nearby Connections]
B -- Yes (iOS) --> D[Tier 2: Apple Multipeer Connectivity]
B -- No --> E[Use @capacitor/share<br/>Native Share Sheet<br/>Bluetooth / Nearby Share]
C --> F[P2P Mesh - High Speed]
D --> G[P2P Infrastructure - High Speed]Documentation
- User Guide - Learn how to use several peer-to-peer (P2P) file transfer strategies.
- Testing Guide - Manual E2E testing instructions and best practices.
Tier 1: Android-to-Android
Utilizes the Google Nearby Connections API (Strategy: P2P_CLUSTER). This provides a high-speed, offline mesh network capable of multi-device transfers.
Tier 2: iOS-to-iOS
Utilizes Apple's Multipeer Connectivity framework. Handles discovery and session management natively for seamless Apple-to-Apple transfers.
Sharing to Uninstalled Devices
For devices that don't have the app installed, use the native share sheet via @capacitor/share. This opens the system share dialog, allowing the user to send the APK via Bluetooth, Nearby Share, or any other registered app — without any code in this plugin.
import { Share } from '@capacitor/share';
const canShare = await Share.canShare();
if (!canShare.value) return;
await Share.share({
title: 'Install Picsa App',
files: ['file:///path/to/app.apk'],
});Android will present Bluetooth, Nearby Share, Wi-Fi Direct, and any other sharing apps registered on the device. The transfer is handled entirely by the OS.
🛠 Technical Constraints & Performance
This plugin is optimized for low-end devices in rural environments:
- No In-Memory Buffering: We never use byte arrays for large files. All transfers use
Payload.Type.FILEwithParcelFileDescriptor(Android) andsendResource(iOS) to prevent Out of Memory (OOM) crashes. - Scoped Storage (Android 11+): Respects modern Android security. Downloaded files are saved directly to the app's private
Context.getFilesDir(), accessible viaCapacitor.convertFileSrc(). - Universal APK Sharing: Use
@capacitor/shareto send the APK via the native share sheet (Bluetooth, Nearby Share, etc.).
Install
npm install @picsa/capacitor-offline-transfer
npx cap syncBasic Usage
1. Initialization
// serviceId ensures your app only connects to other instances of your app.
// For iOS compatibility: 1-15 chars, lowercase, and hyphens only.
await OfflineTransfer.initialize({ serviceId: 'picsa-transfer' });[!TIP] Choose a unique, short name for your service (e.g.,
company-app). This string will also be used in your iOSInfo.plistconfiguration.
2. Large File Transfer (OOM-Safe)
// Sending a 100MB video
await OfflineTransfer.sendFile({
endpointId: 'peer-123',
filePath: 'path/to/video.mp4', // Local file URL
fileName: 'training_video.mp4',
});Event Handling
// Monitor transfer progress
OfflineTransfer.addListener('transferProgress', (event) => {
const percentage = (event.bytesTransferred / event.totalBytes) * 100;
console.log(`Transfer ${event.payloadId}: ${percentage.toFixed(2)}%`);
});
// Handle successful file reception
OfflineTransfer.addListener('fileReceived', (event) => {
console.log(`File saved to: ${event.path}`);
// Use Capacitor.convertFileSrc(event.path) to display in WebView
});Platform Configuration
Android Configuration
Permissions
This plugin automatically includes the required permissions via Manifest Merging. You do not need to add them manually to your app's AndroidManifest.xml.
The following permissions are requested by the plugin:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:maxSdkVersion="30" android:name="android.permission.BLUETOOTH" />
<uses-permission android:maxSdkVersion="30" android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:minSdkVersion="29" android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:minSdkVersion="31" android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:minSdkVersion="31" android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:minSdkVersion="31" android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:minSdkVersion="32" android:name="android.permission.NEARBY_WIFI_DEVICES" />Why are these required?
To ensure the transfer engine survives the constraints of rural deployments, many of these permissions are strictly required for backwards compatibility:
- Location Permissions (
ACCESS_FINE_LOCATION): On Android 11 and below, Bluetooth and Wi-Fi scanning (used by Nearby Connections) are tied to Location services. Without this permission, the API will fail to discover or advertise on older devices. - Bluetooth & Wi-Fi Permissions: Required for Tier 1 (Nearby Connections) and Tier 3 (Local Hotspot) to establish socket connections and manage high-speed data transfers.
- Nearby WiFi Devices: Introduced in Android 13 to allow Wi-Fi operations without needing Location access on newer devices.
Requesting Permissions
The plugin automatically requests permissions when you call startAdvertising() or startDiscovery(). If the user denies permissions, the call will reject with an error.
If you prefer to check or request permissions explicitly (e.g., at app startup), you can use the built-in methods:
import { OfflineTransfer } from '@picsa/capacitor-offline-transfer';
// Optional: Check permission status at startup
const check = await OfflineTransfer.checkPermissions();
if (check.nearby !== 'granted') {
// Requesting is optional - the plugin will prompt automatically on first use
await OfflineTransfer.requestPermissions();
}iOS Configuration
[!IMPORTANT] Manual configuration is required for iOS. Unlike Android, Capacitor cannot automatically modify your
Info.plist.
Add these to your Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Required for P2P device discovery</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Required for Multipeer Connectivity data transfer</string>
<key>NSBonjourServices</key>
<array>
<string>_picsa-transfer._tcp</string>
<string>_picsa-transfer._udp</string>
</array>Platform Compatibility
| API Method | Android | iOS | Web |
| ------------------------ | ------- | --- | --- |
| initialize | ✅ | ✅ | - |
| checkCapabilities | ✅ | ✅ | - |
| checkPermissions | ✅ | ✅ | - |
| requestPermissions | ✅ | ✅ | - |
| startAdvertising | ✅ | ✅ | - |
| stopAdvertising | ✅ | ✅ | - |
| startDiscovery | ✅ | ✅ | - |
| stopDiscovery | ✅ | ✅ | - |
| connect | ✅ | ✅ | - |
| connectByAddress | ✅ | ❌ | - |
| acceptConnection | ✅ | ✅ | - |
| rejectConnection | ✅ | ✅ | - |
| disconnectFromEndpoint | ✅ | ✅ | - |
| disconnect | ✅ | ✅ | - |
| sendMessage | ✅ | ✅ | - |
| sendFile | ✅ | ✅ | - |
| startLanServer | ✅ | ❌ | - |
| stopLanServer | ✅ | ❌ | - |
| setLogLevel | ✅ | ✅ | - |
| getState | ✅ | ✅ | - |
| syncFromPlugin | ✅ | ✅ | - |
- ✅ = Supported
- ❌ = Not available (rejects with error)
- - = Not applicable (Web is not a target platform for this offline plugin)
LAN Server methods (startLanServer, stopLanServer, connectByAddress) are Android-only dev tooling and will reject on iOS. For uninstalled devices, use @capacitor/share.
API
initialize(...)checkCapabilities()startAdvertising(...)stopAdvertising()startDiscovery()stopDiscovery()connect(...)acceptConnection(...)rejectConnection(...)disconnectFromEndpoint(...)disconnect()sendMessage(...)sendFile(...)setLogLevel(...)addListener('connectionRequested', ...)addListener('connectionResult', ...)addListener('endpointFound', ...)addListener('endpointLost', ...)addListener('messageReceived', ...)addListener('transferProgress', ...)addListener('fileReceived', ...)addListener('advertisingStarted', ...)addListener('discoveryStarted', ...)addListener('discoveryFailed', ...)checkPermissions()requestPermissions()removeAllListeners()getState()syncFromPlugin()- Interfaces
- Type Aliases
initialize(...)
initialize(options: { serviceId: string; }) => Promise<{ success: true; }>Initializes the plugin with a unique service identifier.
The serviceId is used to isolate your app's communication from other apps using this plugin.
Only devices using the same serviceId will be able to discover and connect to each other.
| Param | Type | Description |
| ------------- | ----------------------------------- | ---------------------- |
| options | { serviceId: string; } | Initialization options |
Returns: Promise<{ success: true; }>
checkCapabilities()
checkCapabilities() => Promise<PlatformCapabilities>Checks platform capabilities and determines the best available transfer method. Call this after initialization to know what features are available.
Returns: Promise<PlatformCapabilities>
startAdvertising(...)
startAdvertising(options: { displayName: string; }) => Promise<{ success: true; }>Starts advertising the device to nearby peers.
| Param | Type |
| ------------- | ------------------------------------- |
| options | { displayName: string; } |
Returns: Promise<{ success: true; }>
stopAdvertising()
stopAdvertising() => Promise<{ success: true; }>Stops advertising.
Returns: Promise<{ success: true; }>
startDiscovery()
startDiscovery() => Promise<{ success: true; }>Starts discovery of nearby peers.
Returns: Promise<{ success: true; }>
stopDiscovery()
stopDiscovery() => Promise<{ success: true; }>Stops discovery.
Returns: Promise<{ success: true; }>
connect(...)
connect(options: { endpointId: string; displayName: string; }) => Promise<{ success: true; }>Requests a connection to a discovered endpoint.
| Param | Type |
| ------------- | --------------------------------------------------------- |
| options | { endpointId: string; displayName: string; } |
Returns: Promise<{ success: true; }>
acceptConnection(...)
acceptConnection(options: { endpointId: string; }) => Promise<{ success: true; }>Accepts an incoming connection request.
| Param | Type |
| ------------- | ------------------------------------ |
| options | { endpointId: string; } |
Returns: Promise<{ success: true; }>
rejectConnection(...)
rejectConnection(options: { endpointId: string; }) => Promise<{ success: true; }>Rejects an incoming connection request.
| Param | Type |
| ------------- | ------------------------------------ |
| options | { endpointId: string; } |
Returns: Promise<{ success: true; }>
disconnectFromEndpoint(...)
disconnectFromEndpoint(options: { endpointId: string; }) => Promise<{ success: true; }>Disconnects from a specific endpoint.
| Param | Type |
| ------------- | ------------------------------------ |
| options | { endpointId: string; } |
Returns: Promise<{ success: true; }>
disconnect()
disconnect() => Promise<{ success: true; }>Disconnects from all connected endpoints.
Returns: Promise<{ success: true; }>
sendMessage(...)
sendMessage(options: { endpointId: string; data: string; }) => Promise<{ success: true; }>Sends a small text message to a connected endpoint.
| Param | Type |
| ------------- | -------------------------------------------------- |
| options | { endpointId: string; data: string; } |
Returns: Promise<{ success: true; }>
sendFile(...)
sendFile(options: { endpointId: string; filePath: string; fileName: string; }) => Promise<{ payloadId: string; }>Sends a large file to a connected endpoint. Uses Payload.Type.FILE (Android) or Resource URLs (iOS) to avoid OOM.
| Param | Type |
| ------------- | ------------------------------------------------------------------------ |
| options | { endpointId: string; filePath: string; fileName: string; } |
Returns: Promise<{ payloadId: string; }>
setLogLevel(...)
setLogLevel(options: { logLevel: number; }) => Promise<{ success: true; }>Sets the logging level.
| Param | Type |
| ------------- | ---------------------------------- |
| options | { logLevel: number; } |
Returns: Promise<{ success: true; }>
addListener('connectionRequested', ...)
addListener(eventName: 'connectionRequested', listenerFunc: (event: ConnectionRequestEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandleEvent Listeners
| Param | Type |
| ------------------ | --------------------------------------------------------------------------------------------- |
| eventName | 'connectionRequested' |
| listenerFunc | (event: ConnectionRequestEvent) => void |
Returns: Promise<PluginListenerHandle> & PluginListenerHandle
addListener('connectionResult', ...)
addListener(eventName: 'connectionResult', listenerFunc: (event: ConnectionResultEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandle| Param | Type |
| ------------------ | ------------------------------------------------------------------------------------------- |
| eventName | 'connectionResult' |
| listenerFunc | (event: ConnectionResultEvent) => void |
Returns: Promise<PluginListenerHandle> & PluginListenerHandle
addListener('endpointFound', ...)
addListener(eventName: 'endpointFound', listenerFunc: (event: EndpointFoundEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandle| Param | Type |
| ------------------ | ------------------------------------------------------------------------------------- |
| eventName | 'endpointFound' |
| listenerFunc | (event: EndpointFoundEvent) => void |
Returns: Promise<PluginListenerHandle> & PluginListenerHandle
addListener('endpointLost', ...)
addListener(eventName: 'endpointLost', listenerFunc: (event: EndpointLostEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandle| Param | Type |
| ------------------ | ----------------------------------------------------------------------------------- |
| eventName | 'endpointLost' |
| listenerFunc | (event: EndpointLostEvent) => void |
Returns: Promise<PluginListenerHandle> & PluginListenerHandle
addListener('messageReceived', ...)
addListener(eventName: 'messageReceived', listenerFunc: (event: MessageReceivedEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandle| Param | Type |
| ------------------ | ----------------------------------------------------------------------------------------- |
| eventName | 'messageReceived' |
| listenerFunc | (event: MessageReceivedEvent) => void |
Returns: Promise<PluginListenerHandle> & PluginListenerHandle
addListener('transferProgress', ...)
addListener(eventName: 'transferProgress', listenerFunc: (event: TransferProgressEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandle| Param | Type |
| ------------------ | ------------------------------------------------------------------------------------------- |
| eventName | 'transferProgress' |
| listenerFunc | (event: TransferProgressEvent) => void |
Returns: Promise<PluginListenerHandle> & PluginListenerHandle
addListener('fileReceived', ...)
addListener(eventName: 'fileReceived', listenerFunc: (event: FileReceivedEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandle| Param | Type |
| ------------------ | ----------------------------------------------------------------------------------- |
| eventName | 'fileReceived' |
| listenerFunc | (event: FileReceivedEvent) => void |
Returns: Promise<PluginListenerHandle> & PluginListenerHandle
addListener('advertisingStarted', ...)
addListener(eventName: 'advertisingStarted', listenerFunc: (event: AdvertisingStartedEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandle| Param | Type |
| ------------------ | ----------------------------------------------------------------------------------------------- |
| eventName | 'advertisingStarted' |
| listenerFunc | (event: AdvertisingStartedEvent) => void |
Returns: Promise<PluginListenerHandle> & PluginListenerHandle
addListener('discoveryStarted', ...)
addListener(eventName: 'discoveryStarted', listenerFunc: (event: DiscoveryStartedEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandle| Param | Type |
| ------------------ | ------------------------------------------------------------------------------------------- |
| eventName | 'discoveryStarted' |
| listenerFunc | (event: DiscoveryStartedEvent) => void |
Returns: Promise<PluginListenerHandle> & PluginListenerHandle
addListener('discoveryFailed', ...)
addListener(eventName: 'discoveryFailed', listenerFunc: (event: DiscoveryFailedEvent) => void) => Promise<PluginListenerHandle> & PluginListenerHandle| Param | Type |
| ------------------ | ----------------------------------------------------------------------------------------- |
| eventName | 'discoveryFailed' |
| listenerFunc | (event: DiscoveryFailedEvent) => void |
Returns: Promise<PluginListenerHandle> & PluginListenerHandle
checkPermissions()
checkPermissions() => Promise<PermissionStatus>Check permission status
Returns: Promise<PermissionStatus>
requestPermissions()
requestPermissions() => Promise<PermissionStatus>Request permissions
Returns: Promise<PermissionStatus>
removeAllListeners()
removeAllListeners() => Promise<void>Removes all listeners added by the plugin
getState()
getState() => TransferStateReturns the shared reactive state instance for the plugin. Subscribe to state keys to receive updates on connection, transfer, and discovery events.
Returns: TransferState
syncFromPlugin()
syncFromPlugin() => Promise<TransferStateSnapshot>Syncs the reactive state from the native plugin's current snapshot. Call this after initialization to populate the reactive store with native state. Returns the state snapshot directly in the resolved promise.
Returns: Promise<TransferStateSnapshot>
Interfaces
PlatformCapabilities
| Prop | Type |
| -------------------- | --------------------------------------------------------- |
| platform | PlatformType |
| transferMethod | TransferMethod |
| supportsNearby | boolean |
| isEmulator | boolean |
| reason | string |
PluginListenerHandle
| Prop | Type |
| ------------ | ----------------------------------------- |
| remove | () => Promise<void> |
ConnectionRequestEvent
| Prop | Type |
| -------------------------- | -------------------- |
| endpointId | string |
| endpointName | string |
| authenticationToken | string |
| isIncomingConnection | boolean |
ConnectionResultEvent
| Prop | Type |
| ---------------- | ------------------------------------------------- |
| endpointId | string |
| status | 'SUCCESS' | 'FAILURE' | 'REJECTED' |
EndpointFoundEvent
| Prop | Type |
| ------------------ | ------------------- |
| endpointId | string |
| endpointName | string |
| serviceId | string |
EndpointLostEvent
| Prop | Type |
| ---------------- | ------------------- |
| endpointId | string |
MessageReceivedEvent
| Prop | Type |
| ---------------- | ------------------- |
| endpointId | string |
| data | string |
TransferProgressEvent
| Prop | Type |
| ---------------------- | ------------------------------------------------------------------- |
| endpointId | string |
| payloadId | string |
| bytesTransferred | number |
| totalBytes | number |
| status | 'SUCCESS' | 'FAILURE' | 'CANCELLED' | 'IN_PROGRESS' |
FileReceivedEvent
| Prop | Type |
| ---------------- | ------------------- |
| endpointId | string |
| payloadId | string |
| fileName | string |
| path | string |
AdvertisingStartedEvent
| Prop | Type |
| ------------ | ---------------------- |
| status | 'SUCCESS' |
DiscoveryStartedEvent
| Prop | Type |
| ------------ | ---------------------- |
| status | 'SUCCESS' |
DiscoveryFailedEvent
| Prop | Type |
| ------------- | ------------------- |
| message | string |
PermissionStatus
| Prop | Type |
| ------------ | ----------------------------------------------------------- |
| nearby | PermissionState |
TransferStateSnapshot
| Prop | Type |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| endpoints | Record<string, EndpointFoundEvent> |
| connectedEndpoints | Record<string, ConnectedEndpoint> |
| activeTransfers | Record<string, TransferProgressEvent> |
| transferHistory | TransferRecord[] |
| stats | StatsSnapshot |
ConnectedEndpoint
| Prop | Type |
| ------------------ | ------------------- |
| endpointId | string |
| endpointName | string |
| connectedAt | number |
TransferRecord
| Prop | Type |
| ---------------------- | ------------------------------------------------------------------- |
| id | string |
| endpointId | string |
| fileName | string |
| totalBytes | number |
| bytesTransferred | number |
| direction | 'sent' | 'received' |
| status | 'SUCCESS' | 'FAILURE' | 'CANCELLED' | 'IN_PROGRESS' |
| startedAt | number |
| completedAt | number |
| speedBps | number |
StatsSnapshot
| Prop | Type |
| --------------------------- | ------------------- |
| totalBytesTransferred | number |
| filesTransferred | number |
| sessionStart | number |
| currentSpeedBps | number |
Type Aliases
PlatformType
'android' | 'ios' | 'web' | 'unknown'
TransferMethod
'nearby' | 'lan' | 'none'
PermissionState
'prompt' | 'prompt-with-rationale' | 'granted' | 'denied'
Record
Construct a type with a set of properties K of type T
{ [P in K]: T; }
Why is serviceId required?
The serviceId acts as a namespace for your offline network. It ensures that your application doesn't accidentally discover or connect to other unrelated apps that might also be using this plugin nearby.
Only devices that initialize with the exact same string will be able to see each other.
⚠️ iOS Configuration Rules
On iOS, this string is used as the Multipeer Connectivity serviceType, which has strict system requirements:
- Strict Format: Must be 1-15 characters long, containing only lowercase ASCII letters, numbers, and hyphens (
-). - Info.plist Match: You must add this service to your
Info.plistunderNSBonjourServiceswith._tcpand._udpsuffixes.
Example Configuration:
If you choose serviceId: 'my-app-xfer':
In TypeScript:
await OfflineTransfer.initialize({ serviceId: 'my-app-xfer' });In Info.plist: Add your serviceId with ._tcp and ._udp suffixes to NSBonjourServices:
<key>NSBonjourServices</key>
<array>
<string>_my-app-xfer._tcp</string>
<string>_my-app-xfer._udp</string>
</array>The underscore prefix is required as per Bonjour service naming conventions.
Failure to match these exactly will result in discovery failing or the app crashing on iOS.
