psiphon_mobileproxy 0.0.1 copy "psiphon_mobileproxy: ^0.0.1" to clipboard
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 #

pub package Build native binaries license

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 #

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:

sponsor@psiphon.ca

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.so
  • ios/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. The timeout is 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.

0
likes
150
points
63
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#psiphon #proxy #networking #privacy #censorship

License

GPL-3.0 (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on psiphon_mobileproxy

Packages that implement psiphon_mobileproxy