psiphon_mobileproxy 0.0.1
psiphon_mobileproxy: ^0.0.1 copied to clipboard
Flutter plugin that runs a Psiphon tunnel on-device behind a local HTTP proxy, so an app can route chosen traffic through Psiphon on Android and iOS without any VPN permission.
psiphon_mobileproxy #
Not affiliated with or endorsed by Psiphon Inc. or the Outline Foundation. This is an independent, third-party wrapper around their open-source code.
A Flutter plugin that runs a Psiphon tunnel on-device and puts a local HTTP CONNECT proxy in front of it, for Android and iOS.
Point your app's HTTP client, gRPC channel, or WebView at the local proxy
address this plugin returns, and that traffic is carried over the Psiphon
tunnel — no VPN permission, no VpnService, no NEPacketTunnelProvider.
Nothing else on the device is affected: you opt individual clients in, one at a
time.
This package implements no circumvention logic of its own. It is a thin wrapper
around psiphon-tunnel-core
via the Outline SDK's Psiphon StreamDialer
and local proxy server,
compiled to a real Android AAR and iOS XCFramework — see
How the native binaries are built.
Looking for Outline / Shadowsocks instead? Use
outline_mobileproxy, the sibling of this package. It is Apache-2.0 and carries no GPL obligations. This package exists as a separate, GPL-licensed package precisely so that the Outline one doesn't have to.
Contents #
- Licensing: read this first
- Getting a Psiphon config
- Installation
- Usage
- API reference
- Platform notes
- Example app
- How the native binaries are built
- Limitations
- FAQ
- License
Licensing: read this first #
psiphon-tunnel-core is licensed GPL-3.0, and this package ships binaries
that link it. That makes this package, and anything you link it into,
GPL-3.0-or-later. Shipping a closed-source app that depends on this package
is not something the GPL permits.
That is not a technicality you can route around by putting the plugin behind a method channel: linking is linking.
If you want to use Psiphon in an app you don't intend to open-source, that is a conversation to have with the Psiphon team — sponsor@psiphon.ca — who can discuss terms. You will be talking to them anyway to get a config.
None of the above is legal advice. If the licensing matters to your product, get advice from someone qualified to give it.
Getting a Psiphon config #
Psiphon does not publish a config you can copy from a README, and this package deliberately does not bundle one. Each integrator gets their own, tied to a propagation channel and sponsor ID, from the Psiphon team:
What you receive is a JSON document that looks roughly like:
{
"PropagationChannelId": "...",
"SponsorId": "...",
"...": "..."
}
Pass that whole document to start as a string. Treat it as
a credential: don't commit it, and load it the way you would any other secret
your app ships with.
Installation #
dependencies:
psiphon_mobileproxy: ^0.0.1
flutter pub get
Platform requirements #
| Platform | Minimum version |
|---|---|
| Android | API 21 (Android 5.0) |
| iOS | 13.0 |
No further native setup is required — the plugin bundles the compiled library
for both platforms, and contributes the Android INTERNET permission itself
(see Platform notes).
Usage #
import 'package:psiphon_mobileproxy/psiphon_mobileproxy.dart';
final psiphon = PsiphonMobileproxy();
final proxy = await psiphon.start(psiphonConfig: myPsiphonConfigJson);
print('Local proxy listening at ${proxy.address}'); // e.g. 127.0.0.1:54321
// ... route your networking library through it, see below ...
await psiphon.stop();
start returns once the tunnel is actually connected and ready to carry
traffic. That can legitimately take tens of seconds on a censored network, so
show a progress indicator rather than a spinner you expect to flash by. The
default timeout is 60 seconds; raise it, or pass Duration.zero to wait
indefinitely:
final proxy = await psiphon.start(
psiphonConfig: myPsiphonConfigJson,
timeout: const Duration(minutes: 3),
);
Routing your networking library through the proxy #
proxy.address is a plain host:port HTTP proxy, so anything that speaks
"HTTP proxy" can use it.
dart:io HttpClient:
final httpClient = HttpClient();
httpClient.findProxy = (uri) => 'PROXY ${proxy.address}';
final response = await httpClient.getUrl(Uri.parse('https://example.com'));
Dio:
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
final dio = Dio();
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
client.findProxy = (uri) => 'PROXY ${proxy.address}';
return client;
};
gRPC (package:grpc v3.2.4+):
final channel = ClientChannel(
'grpc.example.com',
port: 443,
options: ChannelOptions(
proxy: Proxy(host: proxy.host, port: proxy.port),
),
);
Android WebView (androidx.webkit, native code):
ProxyController.getInstance().setProxyOverride(
ProxyConfig.Builder().addProxyRule(proxyAddress).build(),
{},
{},
)
iOS WKWebView (iOS 17+, native code):
let endpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host(proxyHost),
port: NWEndpoint.Port(integerLiteral: UInt16(proxyPort))
)
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore.proxyConfigurations = [
.init(httpCONNECTProxy: endpoint)
]
Error handling #
try {
await psiphon.start(psiphonConfig: config);
} on InvalidConfigException {
// The config isn't valid JSON, or Psiphon rejected it. Not worth retrying.
} on TunnelTimeoutException {
// Didn't connect in time. Usually worth retrying with a longer timeout.
} on TunnelStartException {
// The tunnel failed for another reason — no network at all, for instance.
} on ProxyStartException {
// The tunnel came up but the local port couldn't be bound.
} on StorageException {
// The data directory couldn't be created.
}
All of these extend PsiphonMobileproxyException, which is
sealed — so a switch over
it is checked for exhaustiveness at compile time, and adding a new case in a
future version is a breaking change you'll be told about rather than one you
discover at runtime.
Lifecycle #
Only one tunnel can exist per process. Calling start while one is running
stops the old one first, which is what you want for a "switch server" or
"reconnect" button. stop() is a no-op if nothing is running.
Always call stop() when you're done. Unlike a plain local proxy, an idle
Psiphon tunnel is not free: it holds a connection open and keeps using battery
and data until it's shut down.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.detached) {
unawaited(psiphon.stop());
}
}
The plugin also tears the tunnel down when the Flutter engine detaches, as a backstop — but that fires late and on a best-effort basis, so don't rely on it as your only cleanup.
API reference #
| Method | Description |
|---|---|
start({psiphonConfig, localAddress, timeout, dataRootDirectory}) |
Connects the tunnel and starts the local proxy. Returns a ProxyInfo. |
stop({gracePeriod}) |
Stops the local proxy and disconnects the tunnel. |
isRunning() |
Whether a proxy is currently running. |
currentProxy() |
The ProxyInfo of the running proxy, or null. |
getPlatformVersion() |
The host OS name and version, for diagnostics. |
localAddress defaults to 127.0.0.1:0, letting the OS pick a free loopback
port — read ProxyInfo.port to find out which one.
dataRootDirectory defaults to an app-private directory chosen by the plugin
(filesDir/psiphon_mobileproxy on Android, Application Support on iOS,
excluded from iCloud backup). Psiphon persists its datastore and server list
there; wiping it costs the next connection its fast path, so it deliberately
isn't a cache directory the OS may evict.
Platform notes #
Android — INTERNET permission. The plugin's manifest declares
android.permission.INTERNET, which merges into your app. This is intentional:
Flutter's app template grants INTERNET only in the debug and profile
manifests, so without this a release build would fail to connect for a reason
nothing in the logs explains. It is a normal install-time permission, and it is
the only one this plugin contributes — in particular, nothing here requests VPN
access.
Android — cleartext traffic. dart:io's HttpClient implements HTTP
itself and is unaffected. But Android's own HTTP stacks (WebView, OkHttp,
HttpURLConnection) enforce the network security config, and talking to a
local HTTP proxy counts as cleartext. If you route those through the proxy, you
need to permit it for loopback:
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>
iOS — privacy manifest. The plugin ships a PrivacyInfo.xcprivacy
declaring the one required-reason API it uses (file timestamps, for Psiphon's
own datastore). It declares no collected data, because this plugin collects
none. It says nothing about what the Psiphon network does with traffic you send
through it — that's between you and the Psiphon team, and your app's own
privacy manifest and store disclosures need to account for it.
App size. These binaries are large: roughly 18 MB per Android ABI and 22 MB for the iOS device slice. Ship an Android App Bundle (or split APKs) so each device downloads only its own ABI, rather than a universal APK carrying all four.
Example app #
The example/ app lets you paste a config, start and stop the
tunnel, and fire a test request through the proxy:
cd example
flutter run
The plugin's integration tests exercise the real native binaries end-to-end. Most of them run without a Psiphon config; the ones that need a live tunnel are skipped unless you supply one:
cd example
flutter test integration_test/plugin_integration_test.dart -d <device-id>
How the native binaries are built #
This plugin bundles prebuilt binaries:
android/libs/psiphonproxy-classes.jar+android/src/main/jniLibs/*/libgojni.soios/Frameworks/Psiphonproxy.xcframework
They're built by tool/build_native.sh with
Go Mobile from
native/psiphonproxy — a small, deliberately readable
Go package in this repository — using the psiphon build tag, which is what
links psiphon-tunnel-core in. Every dependency version, including the Go
toolchain itself, is pinned by
native/psiphonproxy/go.mod and its go.sum.
NATIVE_PROVENANCE.md records exactly what produced
what's currently checked in.
The Android artifacts are the AAR from gomobile bind, unpacked into a plain
jar plus jniLibs, because the Android Gradle Plugin doesn't allow a library
module to declare a local .aar dependency.
Why a Go shim rather than upstream's bindings #
The Outline SDK already exposes Psiphon to Go Mobile, but only as a fallback parser on its Smart Dialer, which starts the tunnel with a background context and never exposes a way to shut it down. Under those bindings, stopping the proxy would close the listener and leave the tunnel — and its battery and data use — running for the life of the process, and there'd be no way to bound how long a connection attempt takes.
native/psiphonproxy exists to close that gap. It keeps
the tunnel and the listener as one unit, so stop() really disconnects, and
start() can take a timeout. It adds no protocol logic of its own.
Verifying the binaries #
.github/workflows/build-native.yml
rebuilds both artifacts from those same pins on every push and PR, in the open,
and:
- recomputes the source digest recorded in
NATIVE_PROVENANCE.md, confirming the checked-in binaries correspond to the checked-in Go sources; - diffs the generated iOS Objective-C header byte-for-byte — deterministic
given the same sources, so any mismatch fails the build — and compares the
Android API surface with
javap(public member signatures) rather than a raw jar diff, since the jar's bytes depend on the compiling JDK, not just the Go sources; - builds the example app against the freshly built libraries and runs the
integration tests on a real Android emulator and iOS Simulator, to
functionally verify the compiled
.so/Mach-O binaries — these embed a Go build ID even with-trimpath, so they aren't byte-identical across separate builds, and functional verification is the honest bar here rather than a raw binary diff; - uploads the freshly built artifacts so anyone can download and compare them independently, rather than trusting the checked-in copies by inspection alone.
To rebuild locally:
tool/build_native.sh all
Limitations #
- No connection progress.
start()is all-or-nothing: there's no stream of Psiphon's connection notices, so you can't show "connecting to server 3 of 12". The Outline SDK's Psiphon dialer doesn't surface them. start()can't be cancelled once it's underway. Thetimeoutis the only way to bound it.- One tunnel per process. This is a constraint of psiphon-tunnel-core itself, not of this wrapper.
- Not a VPN. Only traffic you explicitly point at the proxy goes through it. For device-wide tunneling you'd need a VPN service, which is out of scope here.
FAQ #
Does this need VPN permissions?
No. It runs a local HTTP proxy, not a VPN. Nothing is added to your
entitlements, and the only Android permission it contributes is INTERNET.
Does it tunnel all my app's traffic automatically?
No — only whatever you explicitly point at proxy.address. That's a feature:
you can tunnel one API client and leave everything else on the direct path.
Why doesn't start() throw if I call it twice?
By design — calling it again stops the previous tunnel and connects a new one,
which matches the usual "reconnect" UX. Check isRunning() first if you want
stricter semantics.
Can I use this with outline_mobileproxy in the same app?
Technically yes, but think about the licensing before you do: adding this
package makes the combined work GPL-3.0, which is exactly what keeping them
separate is meant to let you avoid.
Why is my app so much bigger? psiphon-tunnel-core is a large dependency — it roughly doubles the size of the equivalent Outline-only binary. See Platform notes for how to keep the shipped download down.
License #
GPL-3.0-or-later — see LICENSE. This is not a choice: it follows from linking psiphon-tunnel-core (GPL-3.0) by Psiphon Inc. The wrapper also uses the Outline SDK (Apache-2.0) by the Outline Foundation / Jigsaw. Not affiliated with or endorsed by either.
See Licensing: read this first for what this means for your app.