npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@picsa/capacitor-offline-transfer

v8.2.0

Published

Capacitor plugin for offline, large file transfer (Android Nearby & iOS Multipeer)

Downloads

31

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.FILE with ParcelFileDescriptor (Android) and sendResource (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 via Capacitor.convertFileSrc().
  • Universal APK Sharing: Use @capacitor/share to send the APK via the native share sheet (Bluetooth, Nearby Share, etc.).

Install

npm install @picsa/capacitor-offline-transfer
npx cap sync

Basic 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 iOS Info.plist configuration.

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(...)

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> & PluginListenerHandle

Event 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() => TransferState

Returns 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:

  1. Strict Format: Must be 1-15 characters long, containing only lowercase ASCII letters, numbers, and hyphens (-).
  2. Info.plist Match: You must add this service to your Info.plist under NSBonjourServices with ._tcp and ._udp suffixes.

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.