openvpn_flutter 2.0.0
openvpn_flutter: ^2.0.0 copied to clipboard
Connect to OpenVPN servers from Flutter on Android, iOS and macOS. Stage, traffic and log streams, per-app VPN, session timeouts, 16 KB page size ready.
openvpn_flutter #
Connect to OpenVPN servers from Flutter apps on Android, iOS and macOS. Contributions through issues and pull requests are highly appreciated!
- Android: a bundled OpenVPN 2.7 engine (ics-openvpn, OpenSSL 3.5) with
16 KB page size support required by Google Play, per-app VPN (allow or
bypass apps), a connection notification and no
MainActivitychanges. - iOS and macOS: a NetworkExtension packet tunnel running OpenVPN 3 in your app's extension, with the same Dart API.
- One API everywhere: stage, traffic and log streams, an awaitable
connect()anddisconnect(), error reasons, session timeouts and encrypted private keys.
| Android | iOS | macOS | |
|---|---|---|---|
| Minimum version | 5.0 (API 21) | 12.0 | 10.15 |
| Engine | OpenVPN 2.7, bundled | OpenVPN 3 (OpenVPNAdapter) in your Network Extension | same as iOS |
| Detailed stages | yes | connecting / connected / reconnect / disconnecting | same as iOS |
| Per-app VPN | allowedPackages, bypassPackages |
– | – |
| Session timeout | yes | yes (with the provided extension code) | same as iOS |
| Engine log | yes | yes (with the provided extension code) | same as iOS |
Flutter 3.10 / Dart 3.0 or newer.
Contents #
Installation #
flutter pub add openvpn_flutter
Android setup #
1. Extract native libraries (required) #
OpenVPN runs as its own process from a native library, so the library must be
extracted when the app is installed. Recent Android Gradle Plugin versions do
not extract native libraries unless the app asks for it, and the VPN then stops
right after connecting ("OpenVPN process exited"). Add this to the android
block of android/app/build.gradle.kts:
android {
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}
or, in android/app/build.gradle:
android {
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}
This covers APKs and App Bundles (Google Play). Without it, connect() fails
with a native_libraries_not_extracted error that says the same, and the
Gradle build prints a warning.
2. Notification permission #
The VPN runs as a foreground service with a notification. On Android 13+ ask for the notification permission, for example with permission_handler:
await Permission.notification.request();
The VPN works without it, but the notification is then hidden.
3. Publishing on Google Play #
-
Fill in the VPN declaration in the Play Console (VpnService).
-
Declare the systemExempted foreground service type (used for VPN apps on Android 14+) in the Play Console's foreground service declaration.
-
Only ship the ABIs you need to make the app smaller:
android { defaultConfig { ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") } } }
Optional: the notification #
- Icon: add your own
android/app/src/main/res/drawable/ic_notification.png(or.xml); it replaces the engine's icon. - Title: the
nameyou pass toconnect(). - "Disconnect" button label: override the
cancel_connectionstring inandroid/app/src/main/res/values/strings.xml. initialize(showTrafficInNotification: false)keeps the notification static instead of refreshing it with the traffic every two seconds (some launchers show that as a blinking icon).
A foreground service cannot run without a notification, so it cannot be hidden.
Optional: dependency resolution #
The engine is downloaded from JitPack.
The plugin adds that repository to the project. If your settings.gradle(.kts)
uses dependencyResolutionManagement with RepositoriesMode.FAIL_ON_PROJECT_REPOS,
add it there:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
Nothing else is needed: the plugin asks for the VPN permission itself, so the
onActivityResult / connectWhileGranted code of 1.x can be removed from your
MainActivity (it is harmless if it stays).
iOS setup #
VPN apps on iOS run the VPN in a Network Extension. You add one to your app and
put the provided PacketTunnelProvider.swift in it. VPN extensions do not run
in the simulator: test on a device.
1. Capabilities #
Add the App Groups and Network Extensions (Packet Tunnel) capabilities to the Runner target:
2. Network Extension target #
In Xcode, File → New → Target… → Network Extension, name it (for example)
VPNExtension, provider type Packet Tunnel:
Give the extension the same two capabilities and the same App Group:
Set the extension's Minimum Deployments to the same iOS version as Runner. An extension that requires a newer iOS than the device fails with "Update required" in the system VPN settings.
3. OpenVPNAdapter #
The extension runs OpenVPN with OpenVPNAdapter.
With CocoaPods, add a top-level target to ios/Podfile (not nested inside
target 'Runner', or the extension inherits every Flutter plugin and fails to
link):
target 'VPNExtension' do
use_frameworks!
pod 'OpenVPNAdapter', :git => 'https://github.com/ss-abramchuk/OpenVPNAdapter.git', :tag => '0.8.0'
end
Without CocoaPods, add it with Swift Package Manager instead: File → Add Package
Dependencies…, https://github.com/ss-abramchuk/OpenVPNAdapter.git, version
0.8.0, added to the VPNExtension target.
4. Packet tunnel provider #
Replace VPNExtension/PacketTunnelProvider.swift with
PacketTunnelProvider.swift.
It reads what the plugin sends (configuration, credentials, private key
password, session end), answers status requests and writes the OpenVPN log and
errors to the App Group so the app can show them.
Writing your own provider (another OpenVPN engine)
The plugin does not depend on OpenVPNAdapter; any packet tunnel provider that follows this contract works (TunnelKit/Partout, a fork, ...):
protocolConfiguration.providerConfigurationholdsconfig(the.ovpncontent,Data),groupIdentifier,username,passwordandprivateKeyPassword(Data, may be empty),name(String) andexpireAt(NSNumber, milliseconds since 1970, optional).startTunnel(options:)receivesusername,password,privateKeyPasswordandexpireAttoo.handleAppMessagereceivesOPENVPN_STATSand answers JSON withconnected_on(milliseconds),byte_in,byte_out,packets_in,packets_out.- Log lines go to
<App Group container>/openvpn_flutter/vpn.log, one per line:<milliseconds>\t<level>\t<message>. - The last fatal error goes to the App Group's
UserDefaultsunderopenvpn_flutter.lastError.
5. Build phase order #
In the Runner target's Build Phases, drag Embed Foundation Extensions (or "Embed App Extensions") above Thin Binary and Run Script. Otherwise Xcode reports "Cycle inside Runner; building could produce unreliable results".
6. Initialize with your identifiers #
Pass the App Group and the extension's bundle identifier to initialize(), see
Usage.
macOS setup #
macOS uses the same NETunnelProviderManager API, so the Dart side and the
PacketTunnelProvider.swift above are the same. What differs is how the
provider is packaged:
| Distribution | Provider is a | Entitlement value (com.apple.developer.networking.networkextension) |
|---|---|---|
| Mac App Store | App Extension (.appex) |
packet-tunnel-provider |
| Developer ID (outside the store) | System Extension | packet-tunnel-provider-systemextension (Developer ID profile), packet-tunnel-provider while developing |
- App Group identifiers are Team ID prefixed on macOS:
$(TeamIdentifierPrefix)group.com.example.app. Pass that full value toinitialize(groupIdentifier:), otherwise the app and the extension use different containers and status and logs stay empty. - A System Extension needs a
main.swift(autoreleasepool { NEProvider.startSystemExtensionMode() }thendispatchMain()), anInfo.plistwithNetworkExtension → NEProviderClassesandNEMachServiceName, and the app must install it withOSSystemExtensionManagerand the user approves it in System Settings → General → Login Items & Extensions → Network Extensions. macOS only installs system extensions of apps running from/Applications. - CocoaPods does not embed frameworks into a System Extension: add a Run Script
phase executing
"${PODS_ROOT}/Target Support Files/Pods-VPNExtension/Pods-VPNExtension-frameworks.sh"as the extension's last build phase, or use Swift Package Manager for OpenVPNAdapter. - Sign inside out: the framework, the extension with its entitlements, then the
app (no
--deep). - A System Extension runs as root and may not share the app's App Group
container: status still works (it is a message to the provider), but logs and
lastError()can then only come from the system (lastError()on macOS 13+).
Usage #
Initialize #
import 'package:openvpn_flutter/openvpn_flutter.dart';
late final OpenVPN openvpn;
@override
void initState() {
super.initState();
openvpn = OpenVPN(
onVpnStageChanged: (stage, raw) => setState(() => this.stage = stage),
onVpnStatusChanged: (status) => setState(() => this.status = status),
);
openvpn.initialize(
// iOS and macOS:
groupIdentifier: 'group.com.example.vpn',
providerBundleIdentifier: 'com.example.app.VPNExtension',
localizedDescription: 'Example VPN', // name in the system VPN settings
// State of a VPN that was already running (app reopened while connected):
lastStage: (stage) => setState(() => this.stage = stage),
lastStatus: (status) => setState(() => this.status = status),
);
}
stageStream, statusStream and logStream report the same as the callbacks;
currentStage and currentStatus hold the latest values. Call dispose() when
you no longer need the instance (the VPN keeps running).
Connect #
try {
final started = await openvpn.connect(
ovpnFileContent, // the content of the .ovpn file
'Japan #1', // shown in the Android notification
username: username, // for auth-user-pass configurations
password: password,
);
if (!started) {
// The user refused the VPN permission (stage: VPNStage.denied).
}
} on PlatformException catch (e) {
// Invalid configuration, missing engine, iOS entitlements, ...
print('${e.code}: ${e.message}');
}
connect() completes once the connection is starting; the stages tell how it
goes. To wait for the outcome:
await openvpn.connect(config, 'Japan #1');
final stage = await openvpn.stageStream
.firstWhere((stage) => stage.isConnected || stage.isDisconnected)
.timeout(const Duration(seconds: 30), onTimeout: () => openvpn.currentStage);
if (!stage.isConnected) {
print('Failed: ${await openvpn.lastError()}');
await openvpn.disconnect(); // stop retrying
}
Other options:
await openvpn.connect(
config,
'Japan #1',
// Encrypted <key>:
privateKeyPassword: 'key passphrase',
// Disconnect automatically, even with the app closed (Android enforces it in
// the VPN service, iOS/macOS in the packet tunnel provider):
expireAt: DateTime.now().add(const Duration(hours: 1)),
// Android: only these apps use the VPN...
allowedPackages: ['com.android.chrome'],
// ...or every app except these (not both):
// bypassPackages: ['com.example.banking'],
// Whether the configuration uses a client certificate; detected when null.
certIsRequired: null,
);
Calling connect() while connected switches to the new server.
Disconnect #
await openvpn.disconnect(); // completes once the tunnel is down
Status #
final status = await openvpn.status(); // or currentStatus / statusStream
status.connectedOn; // DateTime?, null while not connected
status.duration; // '01:02:03'
status.byteIn; // bytes received, as a string
status.byteOut; // bytes sent
status.speedIn; // bytes per second
status.speedOut;
Stages #
| Stage | Meaning |
|---|---|
prepare |
Waiting for the Android VPN permission dialog |
vpn_generate_config, resolve, tcp_connect, wait_connection, authenticating, get_config, assign_ip, add_routes |
Connection progress (Android) |
connecting |
Connecting |
connected |
The tunnel is up |
reconnect |
The connection dropped and is coming back |
no_network / paused |
Waiting for a network / paused while the screen is off (Android) |
auth_pending |
Waiting for extra authentication (Android) |
disconnecting, exiting |
Stopping |
disconnected |
No tunnel |
denied |
The user refused the VPN permission |
auth_failed |
The server rejected the credentials or certificate |
error |
The connection failed or stopped on its own; see lastError() |
unknown |
A stage this version does not know |
stage.isConnected, stage.isConnecting and stage.isDisconnected group them.
Logs and errors #
final logs = await openvpn.getLogs(); // the engine log so far
openvpn.logStream.listen((log) => print('${log.level.name}: ${log.message}'));
final reason = await openvpn.lastError(); // why the last connection failed
On iOS and macOS the log and errors come from the packet tunnel provider above.
Kill switch and always-on (Android) #
Android has these as system settings: "Always-on VPN" and "Block connections
without VPN". openvpn.openVpnSettings() opens that screen.
Disconnect when the app is swiped away (Android) #
openvpn.initialize(disconnectOnTaskRemoved: true);
Without an activity (Android) #
initialize, connect, disconnect, status and the streams also work from
background isolates and services (no activity). The VPN permission has to be
granted once from the UI first (requestPermissionAndroid() or a connect()).
Configurations with many remotes #
final config = await OpenVPN.filteredConfig(ovpnFileContent); // keeps one random remote
Migrating from 1.x #
- Android: add
useLegacyPackaging = true(see Android setup). TheonActivityResult/connectWhileGrantedcode inMainActivityis no longer needed. connect()returnsFuture<bool>(falsewhen the permission is refused) and throwsPlatformExceptionfor invalid configurations: wrap it intry/catch. On Android it used to never complete.disconnect()returns aFuturethat completes once the tunnel is down.- New stages:
add_routes,reconnect,auth_pending,auth_failed,no_network,paused. Exhaustiveswitches overVPNStageneed them; stages that used to beunknown(likereconnect) now have their own value. certIsRequiredis detected from the configuration when omitted. On Android it no longer appendsclient-cert-not-requiredto the configuration.- iOS: replace
PacketTunnelProvider.swiftwith the new one, use OpenVPNAdapter0.8.0, and give the extension the same deployment target as the app. VpnStatus.packetsIn/packetsOutarenullon Android (they repeated the byte counts);VpnStatus.toJson()encodesconnected_onas an ISO 8601 string.bypassPackagesonly excludes those apps; it no longer lets every app bypass the VPN on its own.- The Android engine is OpenVPN 2.7 with OpenSSL 3.5. Servers that cannot
negotiate ciphers keep working when the configuration names its
cipher; see Troubleshooting for BF-CBC and weak certificates.
Troubleshooting #
await openvpn.lastError() and await openvpn.getLogs() usually tell what went
wrong; the issues below are the frequent ones.
Android: "OpenVPN process exited", "no process running", connects then
disconnects, works in debug but not from Google Play. The native libraries are
not extracted: add useLegacyPackaging = true
(Android setup).
Android: "You must select a User certificate". The configuration has neither
a client certificate (<cert>/<key>) nor auth-user-pass. Pass a username and
password, or fix the configuration.
"No usable data channel cipher", VPN Gate or OpenVPN 2.3 servers stuck
reconnecting. OpenVPN 2.6+ only falls back to the configuration's cipher when
told to; the plugin adds data-ciphers-fallback for you on Android. For servers
that rely on the old BF-CBC default without a cipher line, add to the
configuration:
data-ciphers AES-256-GCM:AES-128-GCM:BF-CBC
data-ciphers-fallback BF-CBC
providers legacy default
"FATAL: could not read Private Key", "private key password verification
failed". The <key> is encrypted: pass privateKeyPassword.
auth_failed. Wrong credentials, or the server refused the session (some
public servers limit sessions per IP address).
"MissingForegroundServiceTypeException". Fixed since 1.3.4; update.
16 KB page size warnings in Google Play. Fixed in 2.0.0.
iOS: connects and disconnects immediately. Check lastError() and the log,
then: the extension's deployment target (see below), the App Group (same in
both targets and in initialize()), credentials for auth-user-pass
configurations, and options OpenVPN 3 does not support.
iOS: "Update required" in the VPN settings. The extension's deployment target is newer than the device's iOS: lower it to match Runner.
iOS: "permission denied" (NEConfigurationErrorDomain 10). The app lacks the Network Extensions capability or provisioning profile, or runs in the simulator.
iOS: "Cycle inside Runner". Move Embed Foundation Extensions above Thin Binary (iOS setup).
iOS: "Module 'openvpn_flutter' not found". Open Runner.xcworkspace, not
Runner.xcodeproj, and run pod install in ios/. With Swift Package Manager
on Flutter versions before 3.44, use CocoaPods instead:
flutter config --no-enable-swift-package-manager.
iOS: "Undefined symbol" errors (MarketplaceKit, __swift_FORCE_LOAD_...).
The extension's Podfile target is nested inside target 'Runner'; make it a
top-level target.
WireGuard, IKEv2, Windows, Linux. Not supported; this plugin is for OpenVPN on Android, iOS and macOS.
Licenses #
- openvpn_flutter (GPLv3)
- ics-openvpn, through openvpn_library, for the Android engine
- OpenVPNAdapter for the iOS and macOS engine
Support #
If you appreciate this work, give it a thumbs up or support it with a cup of coffee.