feat(proxy): route Android WebView and native traffic through a user-supplied HTTP/SOCKS5 proxy via an authed loopback relay

This commit is contained in:
heaven 2026-06-08 02:18:44 +03:00
parent f41ea049cc
commit 717928154b
33 changed files with 2905 additions and 11 deletions

View file

@ -1,2 +1,3 @@
npx tsc -p tsconfig.json --noEmit
node scripts/check-no-tls-weakening.mjs
npx lint-staged

View file

@ -92,6 +92,16 @@ dependencies {
// (mtalk.google.com:5228) is blocked. Library self-registers its scheduler
// in the merged manifest; we declare no permission for it.
implementation "androidx.work:work-runtime:2.10.0"
// androidx.webkit ProxyController.setProxyOverride the single chokepoint
// for routing WebView (matrix-js-sdk fetch + media Service Worker) traffic
// through a user-supplied proxy. 1.14.0 carries PROXY_OVERRIDE +
// PROXY_OVERRIDE_REVERSE_BYPASS; every call is runtime-gated on
// WebViewFeature.isFeatureSupported. See docs/plans/proxy_android_impl.md.
implementation "androidx.webkit:webkit:$androidxWebkitVersion"
// Tink AEAD (AES-256-GCM, KEK in AndroidKeyStore) the ONLY store for the
// proxy credentials (proxy_support.md §15 #4). Not the deprecated
// androidx.security EncryptedSharedPreferences.
implementation "com.google.crypto.tink:tink-android:1.15.0"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View file

@ -151,7 +151,7 @@ final class AvatarLoader {
final String capturedToken = token;
EXECUTOR.execute(() -> {
try {
Bitmap bmp = fetchAndDecode(capturedMxc, capturedHomeserver, capturedToken);
Bitmap bmp = fetchAndDecode(ctx, capturedMxc, capturedHomeserver, capturedToken);
if (bmp != null) AvatarBitmapCache.put(capturedMxc, bmp);
} catch (Throwable t) {
Log.w(TAG, "fetch threw mxc=" + capturedMxc, t);
@ -200,7 +200,7 @@ final class AvatarLoader {
* Bitmap. Returns null on any non-2xx, decode failure, or oversized
* payload (see {@link #MAX_DECODED_BYTES}).
*/
private static Bitmap fetchAndDecode(String mxc, String homeserver, String token)
private static Bitmap fetchAndDecode(Context ctx, String mxc, String homeserver, String token)
throws IOException {
Parsed parsed = parseMxc(mxc);
if (parsed == null) {
@ -224,7 +224,8 @@ final class AvatarLoader {
.append("&height=").append(AVATAR_SIZE_PX)
.append("&method=crop");
HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection();
// Route through the loopback relay when a proxy is enabled (§15 #3).
HttpURLConnection conn = ProxyRouting.openConnection(ctx, new URL(url.toString()));
try {
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Bearer " + token);

View file

@ -159,7 +159,7 @@ public class CallDeclineReceiver extends BroadcastReceiver {
final String txnId = UUID.randomUUID().toString();
EXECUTOR.execute(() -> {
try {
int status = sendDecline(baseUrl, accessToken, roomId, notifEventId, txnId);
int status = sendDecline(context, baseUrl, accessToken, roomId, notifEventId, txnId);
if (status >= 200 && status < 300) {
prefs.edit().remove(PENDING_DECLINES_PREFIX + notifEventId).apply();
Log.d(TAG, "decline PUT ok status=" + status + " room=" + roomId);
@ -175,6 +175,7 @@ public class CallDeclineReceiver extends BroadcastReceiver {
}
private int sendDecline(
Context context,
String baseUrl,
String accessToken,
String roomId,
@ -196,7 +197,9 @@ public class CallDeclineReceiver extends BroadcastReceiver {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(url).openConnection();
// Route through the loopback relay when a proxy is enabled (closes
// the §15 #3 native-path leak); identical to before when off.
conn = ProxyRouting.openConnection(context, new URL(url));
conn.setRequestMethod("PUT");
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);

View file

@ -0,0 +1,493 @@
package chat.vojo.app;
import android.util.Log;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import chat.vojo.app.ProxyConfigStore.ProxyCreds;
/**
* Authenticated HTTP-CONNECT relay on 127.0.0.1:&lt;ephemeral&gt; that exists
* ONLY while the proxy is enabled (proxy_support.md §15 #1/#2/#3).
*
* Why it exists: Chromium's WebView proxy can't present SOCKS5 user/pass auth,
* and the five native HttpURLConnection paths bypass the WebView proxy
* entirely. Both are pointed at this loopback relay; the relay holds the
* upstream credentials and dials the user's real proxy (HTTP-CONNECT or SOCKS5,
* with or without auth).
*
* Inbound is authenticated with a high-entropy per-session token via standard
* proxy Basic auth (407 challenge) answered by the WebView's
* onReceivedHttpAuthRequest and, for native paths, a loopback-scoped
* Authenticator. Without the token any co-resident app could use the loopback
* listener as an open relay (the §15 #1 HIGH threat). Peer-UID filtering is
* NOT relied on (unreliable on modern Android).
*
* Pure L4 forwarder after CONNECT: no TLS inspection, no CA the WebView's
* TLS to the homeserver is end-to-end through the tunnel.
*/
final class LocalProxyRelay {
private static final String TAG = "LocalProxyRelay";
private static final String AUTH_USER = "vojo";
// The Basic realm sent in our 407. The WebView's onReceivedHttpAuthRequest
// is matched on THIS (not the host) so the proxy-auth answer works whatever
// host Chromium reports for the relay's 407 (proxy_android_impl.md §1).
private static final String AUTH_REALM = "vojo";
private static final int ACCEPT_BACKLOG = 32;
private static final int UPSTREAM_CONNECT_TIMEOUT_MS = 15000;
private static final int HANDSHAKE_TIMEOUT_MS = 15000;
private static final int MAX_HEADER_BYTES = 16 * 1024;
private final ProxyCreds creds;
private final String token;
private final byte[] expectedAuthBytes;
private final AtomicBoolean running = new AtomicBoolean(false);
// Every established client+upstream socket, so disable()/logout can
// force-close in-flight tunnels (e.g. the /sync long-poll) instead of
// letting them ride the user's proxy until the far end FINs.
private final java.util.Set<Socket> live =
java.util.Collections.synchronizedSet(new java.util.HashSet<>());
private ServerSocket serverSocket;
private Thread acceptThread;
private ExecutorService workers;
private int port = -1;
LocalProxyRelay(ProxyCreds creds, String token) {
this.creds = creds;
this.token = token;
this.expectedAuthBytes = ("Basic " + base64(AUTH_USER + ":" + token))
.getBytes(StandardCharsets.US_ASCII);
}
int getPort() {
return port;
}
String getToken() {
return token;
}
static String getAuthUser() {
return AUTH_USER;
}
static String getAuthRealm() {
return AUTH_REALM;
}
boolean isRunning() {
return running.get();
}
/** Bind the loopback listener and start accepting. Idempotent-ish: throws
* if already started. Must be called off the main thread. */
synchronized void start() throws IOException {
if (running.get()) return;
serverSocket = new ServerSocket(0, ACCEPT_BACKLOG, InetAddress.getByName("127.0.0.1"));
port = serverSocket.getLocalPort();
workers = Executors.newCachedThreadPool();
running.set(true);
acceptThread = new Thread(this::acceptLoop, "vojo-proxy-relay");
acceptThread.setDaemon(true);
acceptThread.start();
Log.i(TAG, "relay up on 127.0.0.1:" + port);
}
synchronized void stop() {
if (!running.getAndSet(false)) return;
try {
if (serverSocket != null) serverSocket.close();
} catch (IOException ignored) {
// closing the listener is best-effort
}
if (workers != null) workers.shutdownNow();
if (acceptThread != null) acceptThread.interrupt();
// Force-close every in-flight tunnel so no traffic keeps riding the
// user's upstream after the proxy is turned off / the user logs out.
Socket[] snapshot;
synchronized (live) {
snapshot = live.toArray(new Socket[0]);
}
for (Socket s : snapshot) {
closeQuietly(s);
}
live.clear();
serverSocket = null;
port = -1;
Log.i(TAG, "relay stopped");
}
private void acceptLoop() {
while (running.get()) {
final Socket client;
// Capture the listener locally so a concurrent stop() that nulls
// serverSocket can't NPE us between the running check and accept().
final ServerSocket ss = serverSocket;
if (ss == null) break;
try {
client = ss.accept();
} catch (IOException e) {
if (running.get()) Log.w(TAG, "accept failed");
break;
}
try {
workers.execute(() -> handle(client));
} catch (RuntimeException rejected) {
closeQuietly(client);
}
}
}
private void handle(Socket client) {
Socket upstream = null;
try {
client.setSoTimeout(HANDSHAKE_TIMEOUT_MS);
track(client);
InputStream cin = client.getInputStream();
OutputStream cout = client.getOutputStream();
String[] headerLines = readHeaders(cin);
if (headerLines == null) {
closeQuietly(client);
return;
}
// Authenticate FIRST so an unauthenticated probe gets only a 407
// never a fingerprintable 405/400 that confirms what's listening.
if (!authOk(headerLines)) {
// 407 challenge the client (WebView / native Authenticator)
// retries with the token.
cout.write(("HTTP/1.1 407 Proxy Authentication Required\r\n"
+ "Proxy-Authenticate: Basic realm=\"vojo\"\r\n"
+ "Content-Length: 0\r\n"
+ "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
cout.flush();
closeQuietly(client);
return;
}
String requestLine = headerLines.length > 0 ? headerLines[0] : "";
String[] parts = requestLine.split(" ");
if (parts.length < 2 || !"CONNECT".equalsIgnoreCase(parts[0])) {
writeStatus(cout, "405 Method Not Allowed");
closeQuietly(client);
return;
}
String[] hostPort = splitHostPort(parts[1]);
if (hostPort == null) {
writeStatus(cout, "400 Bad Request");
closeQuietly(client);
return;
}
String targetHost = hostPort[0];
int targetPort;
try {
targetPort = Integer.parseInt(hostPort[1]);
} catch (NumberFormatException nfe) {
writeStatus(cout, "400 Bad Request");
closeQuietly(client);
return;
}
upstream = dialUpstream(targetHost, targetPort);
if (upstream == null) {
writeStatus(cout, "502 Bad Gateway");
closeQuietly(client);
return;
}
track(upstream);
cout.write("HTTP/1.1 200 Connection Established\r\n\r\n"
.getBytes(StandardCharsets.US_ASCII));
cout.flush();
// Tunnel established pure byte pipe. Drop the handshake timeout so
// a long-poll /sync isn't torn down mid-stream.
client.setSoTimeout(0);
upstream.setSoTimeout(0);
pipe(client, upstream);
} catch (IOException e) {
closeQuietly(client);
closeQuietly(upstream);
}
}
// Upstream dialers
private Socket dialUpstream(String targetHost, int targetPort) {
try {
if ("http".equalsIgnoreCase(creds.type)) {
return dialHttpUpstream(targetHost, targetPort);
}
return dialSocks5Upstream(targetHost, targetPort);
} catch (IOException e) {
Log.w(TAG, "upstream dial failed");
return null;
}
}
private Socket dialHttpUpstream(String targetHost, int targetPort) throws IOException {
Socket s = new Socket();
s.connect(new InetSocketAddress(creds.host, creds.port), UPSTREAM_CONNECT_TIMEOUT_MS);
s.setSoTimeout(HANDSHAKE_TIMEOUT_MS);
StringBuilder req = new StringBuilder();
req.append("CONNECT ").append(targetHost).append(':').append(targetPort)
.append(" HTTP/1.1\r\n");
req.append("Host: ").append(targetHost).append(':').append(targetPort).append("\r\n");
if (creds.hasAuth()) {
req.append("Proxy-Authorization: Basic ")
.append(base64(creds.username + ":" + (creds.password == null ? "" : creds.password)))
.append("\r\n");
}
req.append("\r\n");
s.getOutputStream().write(req.toString().getBytes(StandardCharsets.US_ASCII));
s.getOutputStream().flush();
String[] respLines = readHeaders(s.getInputStream());
if (respLines == null || respLines.length == 0) {
closeQuietly(s);
return null;
}
// Expect "HTTP/1.x 200 ..." parse the numeric status token, not a
// substring (a reason phrase could otherwise contain "200").
String[] statusParts = respLines[0].split(" ");
if (statusParts.length < 2 || !"200".equals(statusParts[1])) {
closeQuietly(s);
return null;
}
return s;
}
private Socket dialSocks5Upstream(String targetHost, int targetPort) throws IOException {
Socket s = new Socket();
s.connect(new InetSocketAddress(creds.host, creds.port), UPSTREAM_CONNECT_TIMEOUT_MS);
s.setSoTimeout(HANDSHAKE_TIMEOUT_MS);
InputStream in = s.getInputStream();
OutputStream out = s.getOutputStream();
boolean auth = creds.hasAuth();
// Greeting: when we have creds advertise ONLY user/pass (0x02) so a
// hostile/misconfigured upstream can't downgrade us to no-auth and have
// the creds silently dropped. Otherwise advertise only no-auth.
if (auth) {
out.write(new byte[] {0x05, 0x01, 0x02});
} else {
out.write(new byte[] {0x05, 0x01, 0x00});
}
out.flush();
byte[] methodSel = readN(in, 2);
if (methodSel == null || methodSel[0] != 0x05) {
closeQuietly(s);
return null;
}
int method = methodSel[1] & 0xff;
if (auth) {
// We advertised ONLY user/pass reject any downgrade.
if (method != 0x02) { closeQuietly(s); return null; }
// RFC 1929 user/pass sub-negotiation.
byte[] u = creds.username.getBytes(StandardCharsets.UTF_8);
byte[] p = (creds.password == null ? "" : creds.password).getBytes(StandardCharsets.UTF_8);
if (u.length > 255 || p.length > 255) { closeQuietly(s); return null; }
ByteArrayOutputStream a = new ByteArrayOutputStream();
a.write(0x01);
a.write(u.length);
a.write(u, 0, u.length);
a.write(p.length);
a.write(p, 0, p.length);
out.write(a.toByteArray());
out.flush();
byte[] authResp = readN(in, 2);
if (authResp == null || authResp[1] != 0x00) {
closeQuietly(s);
return null;
}
} else if (method != 0x00) {
// We advertised only no-auth; anything else is unacceptable.
closeQuietly(s);
return null;
}
// CONNECT request with the target as a DOMAINNAME so the upstream does
// remote DNS (no on-device DNS leak).
byte[] host = targetHost.getBytes(StandardCharsets.UTF_8);
if (host.length > 255) { closeQuietly(s); return null; }
ByteArrayOutputStream req = new ByteArrayOutputStream();
req.write(0x05); // version
req.write(0x01); // CONNECT
req.write(0x00); // reserved
req.write(0x03); // ATYP = domain
req.write(host.length);
req.write(host, 0, host.length);
req.write((targetPort >> 8) & 0xff);
req.write(targetPort & 0xff);
out.write(req.toByteArray());
out.flush();
// Reply: VER REP RSV ATYP BND.ADDR BND.PORT read the fixed prefix
// then the variable address.
byte[] head = readN(in, 4);
if (head == null || head[0] != 0x05 || head[1] != 0x00) {
closeQuietly(s);
return null;
}
int atyp = head[3] & 0xff;
int addrLen;
if (atyp == 0x01) {
addrLen = 4;
} else if (atyp == 0x04) {
addrLen = 16;
} else if (atyp == 0x03) {
byte[] l = readN(in, 1);
if (l == null) { closeQuietly(s); return null; }
addrLen = l[0] & 0xff;
} else {
closeQuietly(s);
return null;
}
if (readN(in, addrLen + 2) == null) { // address + 2-byte port
closeQuietly(s);
return null;
}
return s;
}
// Helpers
private boolean authOk(String[] headerLines) {
for (String line : headerLines) {
int colon = line.indexOf(':');
if (colon <= 0) continue;
String name = line.substring(0, colon).trim();
if (!"Proxy-Authorization".equalsIgnoreCase(name)) continue;
String value = line.substring(colon + 1).trim();
byte[] got = value.getBytes(StandardCharsets.US_ASCII);
// Constant-time compare to avoid leaking the token via timing.
return MessageDigest.isEqual(got, expectedAuthBytes);
}
return false;
}
// Read CRLF-delimited header lines until the blank line. Returns the lines
// (request/status line first), or null on malformed / oversized input. Reads
// byte-by-byte so it never consumes tunnel body past the header terminator.
private static String[] readHeaders(InputStream in) throws IOException {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
int prev = -1;
int total = 0;
while (true) {
int b = in.read();
if (b == -1) {
if (buf.size() == 0) return null;
break;
}
total++;
if (total > MAX_HEADER_BYTES) return null;
buf.write(b);
if (prev == '\r' && b == '\n') {
int size = buf.size();
byte[] arr = buf.toByteArray();
// Header section ends with CRLFCRLF.
if (size >= 4 && arr[size - 3] == '\n' && arr[size - 4] == '\r') {
break;
}
}
prev = b;
}
String text = new String(buf.toByteArray(), StandardCharsets.US_ASCII);
String[] rawLines = text.split("\r\n");
return rawLines;
}
private static String[] splitHostPort(String authority) {
int idx = authority.lastIndexOf(':');
if (idx <= 0 || idx == authority.length() - 1) return null;
String host = authority.substring(0, idx);
// Strip IPv6 brackets if present.
if (host.startsWith("[") && host.endsWith("]")) {
host = host.substring(1, host.length() - 1);
}
return new String[] {host, authority.substring(idx + 1)};
}
private static byte[] readN(InputStream in, int n) throws IOException {
byte[] out = new byte[n];
int off = 0;
while (off < n) {
int r = in.read(out, off, n - off);
if (r == -1) return null;
off += r;
}
return out;
}
private static void writeStatus(OutputStream out, String status) {
try {
out.write(("HTTP/1.1 " + status + "\r\nContent-Length: 0\r\n"
+ "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
out.flush();
} catch (IOException ignored) {
// best-effort error reply
}
}
private void pipe(Socket a, Socket b) {
Thread t1 = new Thread(() -> copy(a, b), "vojo-relay-a2b");
Thread t2 = new Thread(() -> copy(b, a), "vojo-relay-b2a");
t1.setDaemon(true);
t2.setDaemon(true);
t1.start();
t2.start();
}
private void copy(Socket from, Socket to) {
byte[] buf = new byte[16 * 1024];
try {
InputStream in = from.getInputStream();
OutputStream out = to.getOutputStream();
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
out.flush();
}
} catch (IOException ignored) {
// peer closed / reset fall through to tear both sides down
} finally {
closeQuietly(from);
closeQuietly(to);
}
}
private static String base64(String s) {
return android.util.Base64.encodeToString(
s.getBytes(StandardCharsets.UTF_8), android.util.Base64.NO_WRAP);
}
private void track(Socket s) {
if (s != null) live.add(s);
}
private void closeQuietly(Socket s) {
if (s == null) return;
live.remove(s);
try {
s.close();
} catch (IOException ignored) {
// best-effort close
}
}
}

View file

@ -71,6 +71,7 @@ public class MainActivity extends BridgeActivity {
registerPlugin(LaunchSplashPlugin.class);
registerPlugin(ShareTargetPlugin.class);
registerPlugin(PollingPlugin.class);
registerPlugin(ProxyPlugin.class);
// AndroidX SplashScreen must be installed before super.onCreate().
// Keep it until the web splash confirms its first visible frame is

View file

@ -92,7 +92,7 @@ public class MarkAsReadReceiver extends BroadcastReceiver {
final PendingResult pendingResult = goAsync();
EXECUTOR.execute(() -> {
try {
int status = sendReceipt(homeserver, token, roomId, eventId);
int status = sendReceipt(appContext, homeserver, token, roomId, eventId);
if (status >= 200 && status < 300) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "receipt ok status=" + status + " room=" + roomId);
@ -109,6 +109,7 @@ public class MarkAsReadReceiver extends BroadcastReceiver {
}
private int sendReceipt(
Context context,
String baseUrl,
String accessToken,
String roomId,
@ -120,7 +121,8 @@ public class MarkAsReadReceiver extends BroadcastReceiver {
+ "/receipt/m.read/"
+ URLEncoder.encode(eventId, "UTF-8");
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
// Route through the loopback relay when a proxy is enabled (§15 #3).
HttpURLConnection conn = ProxyRouting.openConnection(context, new URL(url));
try {
conn.setRequestMethod("POST");
conn.setRequestProperty("Authorization", "Bearer " + accessToken);

View file

@ -0,0 +1,83 @@
package chat.vojo.app;
import android.content.Context;
import android.webkit.HttpAuthHandler;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient;
/**
* Extends Capacitor's {@link BridgeWebViewClient} (preserving the local-server
* shouldInterceptRequest / launchIntent / error-page wiring via super) and adds
* exactly two proxy hooks (proxy_android_impl.md §1, §3 F5):
*
* 1. onReceivedHttpAuthRequest answer the proxy/relay 407 with the stored
* creds / relay token. This is the (officially unsupported) Chromium
* mechanism for proxy auth, and is the Phase-0 linchpin: it must be
* validated on a real device. We only ever answer when the 407 comes from
* OUR active proxy/relay endpoint; everything else defers to super (cancel).
*
* 2. onReceivedError surface a genuinely dead/wrong proxy as a status, but
* ONLY for connection-level error codes (CONNECT / PROXY_AUTHENTICATION /
* HOST_LOOKUP). Per-resource media failures (avatars cancelled on scroll,
* slow-image timeouts) must NOT flag the proxy as down that flapped the
* status to "unavailable" while the proxy was healthy. HTTP error responses
* (incl. 5xx) are deliberately NOT treated as proxy failures: a status reply
* proves the request reached the server through the proxy. The JS /versions
* probe is the authoritative reachability oracle.
*
* We deliberately do NOT override onReceivedSslError TLS validation stays at
* the system default (no weakening, proxy_support.md §15).
*/
public class ProxyBridgeWebViewClient extends BridgeWebViewClient {
private final Context appContext;
public ProxyBridgeWebViewClient(Bridge bridge) {
super(bridge);
this.appContext = bridge.getActivity().getApplicationContext();
}
@Override
public void onReceivedHttpAuthRequest(
WebView view, HttpAuthHandler handler, String host, String realm) {
String[] auth = ProxyManager.get(appContext).getProxyAuth(host, realm);
if (auth != null) {
handler.proceed(auth[0], auth[1]);
return;
}
super.onReceivedHttpAuthRequest(view, handler, host, realm);
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
super.onReceivedError(view, request, error);
// Only flag the proxy as down for unambiguous connection-level failures.
// The WebView fires onReceivedError for every failed subresource, and the
// only homeserver-origin loads it makes are MEDIA (avatars/images), which
// fail routinely and benignly (cancelled on scroll, transient resets,
// slow-image timeouts) even when the proxy is perfectly healthy. Flagging
// any of those flapped the status to "unavailable" until a manual
// recheck. A hung/slow single resource (TIMEOUT/IO) is left to the
// authoritative JS /versions probe rather than latched here.
if (error == null) return;
int code = error.getErrorCode();
if (code == WebViewClient.ERROR_CONNECT
|| code == WebViewClient.ERROR_PROXY_AUTHENTICATION
|| code == WebViewClient.ERROR_HOST_LOOKUP) {
notifyProxyError(request);
}
}
private void notifyProxyError(WebResourceRequest request) {
if (request == null || request.getUrl() == null) return;
String host = request.getUrl().getHost();
// ProxyManager filters to the active (homeserver) host; external origins
// failing is not a proxy problem.
ProxyManager.get(appContext).onWebViewError(host);
}
}

View file

@ -0,0 +1,176 @@
package chat.vojo.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Base64;
import android.util.Log;
import com.google.crypto.tink.Aead;
import com.google.crypto.tink.KeyTemplates;
import com.google.crypto.tink.KeysetHandle;
import com.google.crypto.tink.aead.AeadConfig;
import com.google.crypto.tink.integration.android.AndroidKeysetManager;
import org.json.JSONException;
import org.json.JSONObject;
/**
* The ONLY store for the user's proxy credentials (proxy_support.md §15 #4).
*
* The host/port/username/password blob is encrypted with a Tink AEAD primitive
* (AES-256-GCM) whose key-encryption key lives in the AndroidKeyStore so the
* plaintext never touches disk, localStorage, settingsAtom, or any JS state.
* We deliberately do NOT use the deprecated
* androidx.security EncryptedSharedPreferences.
*
* allowBackup=false (AndroidManifest) already excludes this prefs file from
* Auto Backup / adb backup. Nothing here is ever logged.
*
* On logout the blob is wiped ({@link #wipe()}).
*/
final class ProxyConfigStore {
private static final String TAG = "ProxyConfigStore";
// Dedicated prefs file NOT the vojo_poll_state used by the push/polling
// path (proxy_android_impl.md §3: do not reuse the plaintext pattern).
static final String PREFS = "vojo_proxy_store";
private static final String KEYSET_NAME = "vojo_proxy_keyset";
private static final String MASTER_KEY_URI = "android-keystore://vojo_proxy_kek";
private static final byte[] AAD = "vojo-proxy-v1".getBytes();
private static final String KEY_BLOB = "creds_blob";
private static final String KEY_ENABLED = "enabled";
// Epoch-ms of the most recent background native send that was refused
// because the relay couldn't start (fail-closed). Non-secret, persisted so
// it survives the process death that's typical for a Worker/receiver wake
// the UI reads it to warn that background traffic was blocked (F17).
private static final String KEY_NATIVE_BLOCKED_AT = "native_blocked_at";
/** Plain value object for the decrypted config. Never logged. */
static final class ProxyCreds {
final String type; // "http" | "socks5"
final String host;
final int port;
final String username; // nullable / empty = no auth
final String password; // nullable / empty
ProxyCreds(String type, String host, int port, String username, String password) {
this.type = type;
this.host = host;
this.port = port;
this.username = username;
this.password = password;
}
boolean hasAuth() {
return username != null && !username.isEmpty();
}
}
private final Context appContext;
private volatile Aead aead;
ProxyConfigStore(Context context) {
this.appContext = context.getApplicationContext();
}
private SharedPreferences prefs() {
return appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
// Lazily build the AEAD primitive (Keystore I/O). Synchronized so a
// concurrent FCM/Worker/plugin first-touch can't race the keyset creation
// (a documented Tink corruption hazard on some devices).
private synchronized Aead aead() {
if (aead != null) return aead;
try {
AeadConfig.register();
KeysetHandle handle = new AndroidKeysetManager.Builder()
.withSharedPref(appContext, KEYSET_NAME, PREFS)
.withKeyTemplate(KeyTemplates.get("AES256_GCM"))
.withMasterKeyUri(MASTER_KEY_URI)
.build()
.getKeysetHandle();
aead = handle.getPrimitive(Aead.class);
} catch (Throwable t) {
// Don't log the cause's message verbatim be conservative around
// anything crypto. The store degrades to "unconfigured".
Log.w(TAG, "aead init failed");
aead = null;
}
return aead;
}
boolean isConfigured() {
return prefs().contains(KEY_BLOB);
}
boolean isEnabled() {
return prefs().getBoolean(KEY_ENABLED, false);
}
void setEnabled(boolean enabled) {
prefs().edit().putBoolean(KEY_ENABLED, enabled).apply();
}
long getNativeBlockedAt() {
return prefs().getLong(KEY_NATIVE_BLOCKED_AT, 0);
}
void setNativeBlockedAt(long epochMs) {
prefs().edit().putLong(KEY_NATIVE_BLOCKED_AT, epochMs).apply();
}
/** Encrypt + persist the credentials. Returns false on any crypto failure. */
boolean save(ProxyCreds creds) {
Aead a = aead();
if (a == null) return false;
try {
JSONObject o = new JSONObject();
o.put("type", creds.type);
o.put("host", creds.host);
o.put("port", creds.port);
if (creds.username != null) o.put("username", creds.username);
if (creds.password != null) o.put("password", creds.password);
byte[] cipher = a.encrypt(o.toString().getBytes("UTF-8"), AAD);
prefs().edit()
.putString(KEY_BLOB, Base64.encodeToString(cipher, Base64.NO_WRAP))
.apply();
return true;
} catch (Throwable t) {
Log.w(TAG, "save failed");
return false;
}
}
/** Decrypt the stored credentials, or null if absent / undecryptable. */
ProxyCreds load() {
String b64 = prefs().getString(KEY_BLOB, null);
if (b64 == null) return null;
Aead a = aead();
if (a == null) return null;
try {
byte[] plain = a.decrypt(Base64.decode(b64, Base64.NO_WRAP), AAD);
JSONObject o = new JSONObject(new String(plain, "UTF-8"));
return new ProxyCreds(
o.optString("type", "socks5"),
o.optString("host", ""),
o.optInt("port", 0),
o.has("username") ? o.optString("username", null) : null,
o.has("password") ? o.optString("password", null) : null
);
} catch (JSONException | RuntimeException e) {
Log.w(TAG, "load parse failed");
return null;
} catch (Throwable t) {
Log.w(TAG, "load decrypt failed");
return null;
}
}
/** Wipe the credentials + enabled flag (logout). */
void wipe() {
prefs().edit().remove(KEY_BLOB).remove(KEY_ENABLED).remove(KEY_NATIVE_BLOCKED_AT).apply();
}
}

View file

@ -0,0 +1,375 @@
package chat.vojo.app;
import android.content.Context;
import android.util.Log;
import androidx.webkit.ProxyConfig;
import androidx.webkit.ProxyController;
import androidx.webkit.WebViewFeature;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import chat.vojo.app.ProxyConfigStore.ProxyCreds;
/**
* Process-global owner of the WebView proxy override + the loopback relay
* (proxy_android_impl.md §3). Singleton because ProxyController is itself
* process-global and catches ALL WebView traffic so the reverse-bypass MUST
* be scoped to exactly the homeserver host or external URL-preview / widget
* origins would leak through the user's proxy.
*
* Lifecycle (single-session app, no account switch):
* - applyAtBoot() re-applies a stored+enabled override before login.
* - apply() / disable() are the runtime toggle.
* - On logout the plugin calls disable() + ProxyConfigStore.wipe().
*
* NEVER calls removeImplicitRules() (keeps localhost/link-local DIRECT the
* §15 SSRF-to-localhost guard) and never weakens TLS.
*/
final class ProxyManager {
private static final String TAG = "ProxyManager";
private static final long LISTENER_TIMEOUT_MS = 3000;
interface StatusListener {
void onStatus(String status, String detail);
}
private static volatile ProxyManager instance;
static ProxyManager get(Context context) {
ProxyManager local = instance;
if (local == null) {
synchronized (ProxyManager.class) {
local = instance;
if (local == null) {
local = new ProxyManager(context.getApplicationContext());
instance = local;
}
}
}
return local;
}
private final Context appContext;
private final ProxyConfigStore store;
private final ExecutorService callbackExec = Executors.newSingleThreadExecutor();
private volatile boolean enabled = false;
private volatile String status = "off";
private volatile long appliedAtMs = 0; // when the override was applied (uptime base)
private volatile String activeHost; // homeserver host the override is scoped to (null = route-all)
private volatile LocalProxyRelay relay;
private volatile StatusListener statusListener;
private ProxyManager(Context context) {
this.appContext = context;
this.store = new ProxyConfigStore(context);
}
ProxyConfigStore store() {
return store;
}
void setStatusListener(StatusListener listener) {
this.statusListener = listener;
}
static boolean isOverrideSupported() {
return WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE);
}
static boolean isReverseBypassSupported() {
return WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE_REVERSE_BYPASS);
}
boolean isEnabled() {
return enabled;
}
String getStatus() {
return status;
}
/** Epoch-ms when the current override was applied (0 if disabled) — uptime base. */
long getEnabledSince() {
return enabled ? appliedAtMs : 0;
}
/** The homeserver host the override is scoped to, or null when routing all. */
String getScopeHost() {
return activeHost;
}
/** True when traffic goes via the loopback relay (authed proxy) vs direct. */
boolean isRelayed() {
LocalProxyRelay r = relay;
return r != null && r.isRunning();
}
// Apply / disable
/** Apply the override for `creds`, scoped to `host` (null/routeAll = all
* traffic). Must run off the main thread. Returns true on success. */
synchronized boolean apply(ProxyCreds creds, String host, boolean routeAll) {
if (creds == null) return false;
if (!isOverrideSupported()) {
Log.w(TAG, "PROXY_OVERRIDE unsupported on this WebView");
return false;
}
// Always (re)start a relay bound to fresh creds: the WebView uses it for
// the SOCKS5-with-auth case, and the native HttpURLConnection paths
// always route through it (proxy_support.md §15 #3). Restart so a config
// edit takes a fresh token + upstream.
teardownRelay();
String relayTarget = startRelay(creds);
// Route the WebView through the loopback relay whenever the upstream
// needs auth SOCKS5-auth (Chromium can't present it) AND HTTP-auth, so
// the REAL upstream credential never transits the WebView's
// onReceivedHttpAuthRequest callback; the WebView only ever
// authenticates to the loopback relay with its per-session token, and
// the relay holds the upstream creds. No-auth proxies go direct.
String webViewTarget;
boolean socks5 = "socks5".equalsIgnoreCase(creds.type);
if (creds.hasAuth()) {
if (relayTarget == null) {
Log.w(TAG, "relay required for authed proxy but failed to start");
// teardownRelay() above already killed any previous relay, so a
// prior override now points at a dead port. Reset to a clean
// disabled state (clear the override + flags) rather than leave a
// half-dead proxy the UI would still report as ON (F12).
disable();
return false;
}
webViewTarget = relayTarget;
} else if (socks5) {
webViewTarget = "socks://" + creds.host + ":" + creds.port;
} else {
webViewTarget = "http://" + creds.host + ":" + creds.port;
}
ProxyConfig.Builder builder = new ProxyConfig.Builder().addProxyRule(webViewTarget);
boolean scoped = !routeAll && host != null && !host.isEmpty() && isReverseBypassSupported();
if (scoped) {
// Invert the bypass list into an allow-list: ONLY the homeserver host
// rides the proxy; everything else stays DIRECT. Exact host (a
// wildcard may not match the bare apex proxy_support.md §15).
builder.setReverseBypassEnabled(true);
builder.addBypassRule(host);
}
// NEVER removeImplicitRules(): keeps 127.0.0.1 / link-local DIRECT so the
// loopback relay hop doesn't loop and XSS can't SSRF localhost.
final CountDownLatch latch = new CountDownLatch(1);
try {
ProxyController.getInstance().setProxyOverride(builder.build(), callbackExec, latch::countDown);
if (!latch.await(LISTENER_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
// The request was accepted but Chromium's apply-callback didn't
// confirm in time. Proceed (it usually lands shortly after) but
// log it; the JS reachability probe is the authoritative
// ok/error signal, so we don't claim a false 'ok' here.
Log.w(TAG, "setProxyOverride listener timed out");
}
} catch (Throwable t) {
Log.w(TAG, "setProxyOverride failed");
// The previous relay is already torn down and the override may be
// stale/partial reset to a clean disabled state rather than leave
// a dead-but-"enabled" proxy with in-memory enabled=true (F12).
disable();
return false;
}
enabled = true;
appliedAtMs = System.currentTimeMillis();
activeHost = scoped ? host : null;
// The relay just (re)started, so any earlier "native send blocked"
// condition is resolved clear the flag (F17).
clearNativeBlocked();
setStatus("connecting", null);
Log.i(TAG, "override applied (scoped=" + scoped + ")");
return true;
}
/** Remove the override + tear down the relay. Must run off the main thread. */
synchronized void disable() {
if (isOverrideSupported()) {
final CountDownLatch latch = new CountDownLatch(1);
try {
ProxyController.getInstance().clearProxyOverride(callbackExec, latch::countDown);
latch.await(LISTENER_TIMEOUT_MS, TimeUnit.MILLISECONDS);
} catch (Throwable t) {
Log.w(TAG, "clearProxyOverride failed");
}
}
teardownRelay();
enabled = false;
appliedAtMs = 0;
activeHost = null;
// Proxy is off native paths go direct by design; no "blocked" meaning.
clearNativeBlocked();
setStatus("off", null);
}
/** Re-apply a stored+enabled override at boot. `host` = the resolved
* homeserver host (null pre-login route-all). Must run off-main. */
synchronized void applyAtBoot(String host) {
if (!store.isEnabled() || !store.isConfigured()) return;
ProxyCreds creds = store.load();
if (creds == null) return;
boolean routeAll = host == null || host.isEmpty();
boolean ok = apply(creds, host, routeAll);
// Persist the real outcome so the in-memory state, the persisted flag
// the native paths read (isProxyActiveForNative), and the UI can never
// disagree (F7). A boot-time apply failure persists disabled the
// logged-in narrow effect re-applies and re-enables if it then succeeds.
store.setEnabled(ok);
}
// Status (dead-proxy detection bridge)
void onWebViewError(String failingHost) {
if (!enabled) return;
// Only flag the homeserver origin as an error; external origins failing
// is not a proxy problem. Pre-login (route-all, activeHost==null) relies
// on the JS reachability probe / discovery UI instead.
if (activeHost != null && failingHost != null && activeHost.equalsIgnoreCase(failingHost)) {
setStatus("error", failingHost);
}
}
private void setStatus(String next, String detail) {
status = next;
StatusListener l = statusListener;
if (l != null) {
try {
l.onStatus(next, detail);
} catch (Throwable ignored) {
// listener teardown race drop
}
}
}
// WebView proxy auth (onReceivedHttpAuthRequest answer)
/** Returns {user, pass} for the relay's 407, else null. We match on the
* relay's Basic realm ("vojo") OR a loopback host matching the realm is
* robust to whatever host Chromium reports for the relay's 407 (the F2
* linchpin uncertainty), and is safe: the token only works against our
* 127.0.0.1 listener, so it's useless even if a site somehow saw it.
*
* The real upstream credential is NEVER returned here authed upstreams
* are reached through the relay, so the WebView only ever authenticates to
* the loopback relay with its per-session token. */
String[] getProxyAuth(String host, String realm) {
if (!enabled) return null;
LocalProxyRelay r = relay;
if (r == null || !r.isRunning()) return null;
boolean realmMatch = realm != null && LocalProxyRelay.getAuthRealm().equals(realm);
boolean loopback = host != null && isLoopback(normalizeHost(host));
if (realmMatch || loopback) {
return new String[] {LocalProxyRelay.getAuthUser(), r.getToken()};
}
return null;
}
// Native-path routing (Phase 2)
/** Reads the persisted flag (works in a cold Worker/receiver process where
* apply() never ran). */
boolean isProxyActiveForNative() {
return store.isEnabled() && store.isConfigured();
}
/** Ensure a relay is running for the native paths, starting it from the
* stored config if this process hasn't applied an override. Returns the
* port, or -1 on failure. */
synchronized int ensureRelayForNative() {
LocalProxyRelay r = relay;
if (r != null && r.isRunning()) return r.getPort();
ProxyCreds creds = store.load();
if (creds == null) return -1;
startRelay(creds);
return relay != null && relay.isRunning() ? relay.getPort() : -1;
}
/** A background native send was refused because the relay couldn't start
* (fail-closed). Records the moment so the UI can warn (F17). */
void onNativeBlocked() {
store.setNativeBlockedAt(System.currentTimeMillis());
}
/** A native send went through the relay — clear any stale blocked flag. */
void onNativeServed() {
clearNativeBlocked();
}
/** Epoch-ms of the most recent fail-closed native block (0 = none). */
long getNativeBlockedAt() {
return store.getNativeBlockedAt();
}
private void clearNativeBlocked() {
if (store.getNativeBlockedAt() != 0) store.setNativeBlockedAt(0);
}
String getRelayToken() {
LocalProxyRelay r = relay;
return r != null ? r.getToken() : null;
}
int getRelayPort() {
LocalProxyRelay r = relay;
return r != null && r.isRunning() ? r.getPort() : -1;
}
// Relay helpers
// Returns the WebView CONNECT-proxy target for the relay, or null on failure.
private String startRelay(ProxyCreds creds) {
try {
LocalProxyRelay r = new LocalProxyRelay(creds, newToken());
r.start();
relay = r;
return "http://127.0.0.1:" + r.getPort();
} catch (Throwable t) {
Log.w(TAG, "relay start failed");
relay = null;
return null;
}
}
private void teardownRelay() {
LocalProxyRelay r = relay;
if (r != null) {
r.stop();
relay = null;
}
ProxyRouting.clearRelay();
}
private static String newToken() {
byte[] b = new byte[24];
new java.security.SecureRandom().nextBytes(b);
return android.util.Base64.encodeToString(b, android.util.Base64.NO_WRAP | android.util.Base64.URL_SAFE);
}
private static String normalizeHost(String host) {
String h = host;
int slash = h.indexOf('/');
if (slash >= 0) h = h.substring(0, slash);
// strip :port
int colon = h.lastIndexOf(':');
if (colon > 0 && h.indexOf(':') == colon) h = h.substring(0, colon);
if (h.startsWith("[") && h.endsWith("]")) h = h.substring(1, h.length() - 1);
return h;
}
private static boolean isLoopback(String h) {
return "127.0.0.1".equals(h) || "localhost".equalsIgnoreCase(h) || "::1".equals(h);
}
}

View file

@ -0,0 +1,173 @@
package chat.vojo.app;
import android.util.Log;
import com.getcapacitor.Bridge;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import chat.vojo.app.ProxyConfigStore.ProxyCreds;
/**
* JS Android bridge for the "bring your own proxy" feature
* (proxy_android_impl.md §3). Mirrors PollingPlugin's shape.
*
* The password transits JS exactly once at entry ({@link #setConfig}) and is
* written straight into the Tink-encrypted store; it is NEVER returned to JS
* (getConfig / getState omit it) and never persisted in localStorage / JS state.
*
* Capacitor dispatches plugin methods off the main thread, so the blocking
* socket / setProxyOverride work in apply()/disable() is safe here.
*/
@CapacitorPlugin(name = "Proxy")
public class ProxyPlugin extends Plugin {
private static final String TAG = "ProxyPlugin";
@Override
public void load() {
ProxyManager pm = ProxyManager.get(getContext());
pm.setStatusListener((status, detail) -> {
JSObject o = new JSObject();
o.put("status", status);
if (detail != null) o.put("detail", detail);
notifyListeners("proxyStatus", o);
});
// Attach our WebViewClient subclass so onReceivedHttpAuthRequest can
// answer the proxy 407 and onReceivedError can surface a dead proxy.
// Must touch the WebView on the UI thread.
final Bridge bridge = getBridge();
try {
getActivity().runOnUiThread(() -> {
try {
// bridge.setWebViewClient (not webView.setWebViewClient) so
// Capacitor tracks it as THE client and won't silently
// overwrite our proxy-auth hook later.
bridge.setWebViewClient(new ProxyBridgeWebViewClient(bridge));
} catch (Throwable t) {
Log.w(TAG, "failed to attach proxy WebViewClient");
}
});
} catch (Throwable t) {
Log.w(TAG, "runOnUiThread unavailable");
}
}
@PluginMethod
public void setConfig(PluginCall call) {
String type = call.getString("type");
String host = call.getString("host");
Integer port = call.getInt("port");
if (!"http".equals(type) && !"socks5".equals(type)) {
call.reject("invalid_type");
return;
}
if (host == null || host.isEmpty() || host.contains("://") || host.contains(" ")) {
call.reject("invalid_host");
return;
}
if (port == null || port < 1 || port > 65535) {
call.reject("invalid_port");
return;
}
ProxyManager pm = ProxyManager.get(getContext());
String username = call.getString("username");
boolean hasUser = username != null && !username.isEmpty();
String password = call.getString("password");
String finalUsername = hasUser ? username : null;
String finalPassword = null;
if (hasUser) {
if (password != null && !password.isEmpty()) {
finalPassword = password;
} else {
// No new password supplied keep the existing one (lets the
// user edit host/port without re-typing the secret), but ONLY
// when the username is unchanged: reusing the old password for a
// NEW username would silently send user-B the password of
// user-A (F8). A changed username with no password = no-auth.
ProxyCreds existing = pm.store().load();
finalPassword = (existing != null
&& existing.password != null
&& username.equals(existing.username)) ? existing.password : "";
}
}
boolean ok = pm.store().save(new ProxyCreds(type, host, port, finalUsername, finalPassword));
if (!ok) {
call.reject("store_failed");
return;
}
call.resolve();
}
@PluginMethod
public void clearConfig(PluginCall call) {
ProxyManager pm = ProxyManager.get(getContext());
pm.disable();
pm.store().wipe();
call.resolve();
}
@PluginMethod
public void setEnabled(PluginCall call) {
boolean enabled = Boolean.TRUE.equals(call.getBoolean("enabled", false));
String host = call.getString("homeserverHost");
boolean routeAll = Boolean.TRUE.equals(call.getBoolean("routeAll", false));
ProxyManager pm = ProxyManager.get(getContext());
if (enabled) {
ProxyCreds creds = pm.store().load();
if (creds != null) {
boolean effectiveRouteAll = routeAll || host == null || host.isEmpty();
boolean ok = pm.apply(creds, host, effectiveRouteAll);
pm.store().setEnabled(ok);
}
} else {
pm.disable();
pm.store().setEnabled(false);
}
call.resolve(buildState(pm));
}
@PluginMethod
public void getState(PluginCall call) {
call.resolve(buildState(ProxyManager.get(getContext())));
}
@PluginMethod
public void applyAtBoot(PluginCall call) {
ProxyManager pm = ProxyManager.get(getContext());
pm.applyAtBoot(call.getString("homeserverHost"));
call.resolve(buildState(pm));
}
private JSObject buildState(ProxyManager pm) {
JSObject o = new JSObject();
o.put("enabled", pm.isEnabled());
o.put("status", pm.getStatus());
o.put("supported", ProxyManager.isOverrideSupported());
o.put("reverseBypassSupported", ProxyManager.isReverseBypassSupported());
o.put("enabledSince", pm.getEnabledSince());
o.put("relayed", pm.isRelayed());
o.put("nativeBlockedAt", pm.getNativeBlockedAt());
if (pm.getScopeHost() != null) o.put("scopeHost", pm.getScopeHost());
ProxyCreds c = pm.store().load();
if (c == null) {
o.put("configured", false);
} else {
o.put("configured", true);
o.put("type", c.type);
o.put("host", c.host);
o.put("port", c.port);
o.put("hasAuth", c.hasAuth());
if (c.username != null) o.put("username", c.username);
}
return o;
}
}

View file

@ -0,0 +1,130 @@
package chat.vojo.app;
import android.content.Context;
import android.util.Log;
import java.io.IOException;
import java.net.Authenticator;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.PasswordAuthentication;
import java.net.Proxy;
import java.net.URL;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Routes the five native HttpURLConnection paths (VojoPollWorker, AvatarLoader,
* CallDeclineReceiver, ReplyReceiver, MarkAsReadReceiver) through the local
* relay when a proxy is enabled, closing the §15 #3 native-path leak. These run
* outside the WebView so ProxyController never touches them.
*
* When the proxy is OFF (the default for ~all users) this is byte-for-byte the
* old behaviour {@code url.openConnection()} so the common path carries
* zero risk from the proxy feature.
*
* When the proxy is ON but the relay can't be started (e.g. AndroidKeyStore not
* yet usable before the first unlock after a reboot), the request is REFUSED
* (fail-closed), NOT sent direct: a direct send would leak the user's real IP +
* the homeserver SNI to the censor on the exact network the proxy exists to
* evade. The callers already degrade gracefully the poll worker skips the
* cycle, ReplyReceiver re-posts a send-error, the avatar falls back to initials.
*
* Auth to the relay: a process-wide {@link Authenticator} that returns the
* relay's loopback token ONLY for a PROXY request to 127.0.0.1:&lt;relayPort&gt;,
* and null for anything else. NOTE: this is a deliberate, scoped use of
* Authenticator.setDefault proxy_android_impl.md §3 flags the un-scoped
* pattern as unsafe because it can leak the *upstream* creds across unrelated
* connections. Here the secret handed out is the loopback relay token (not the
* upstream credential those live only inside the relay), it is scoped to the
* exact loopback host+port+PROXY requestor, and Vojo has no other native
* HttpURLConnection users. HttpURLConnection.setAuthenticator (the per-call
* setter) does not exist on Android, hence the default-Authenticator route.
*
* Validated on-device: NO (no device available this session) see the summary.
*/
final class ProxyRouting {
private static final String TAG = "ProxyRouting";
private static final AtomicBoolean AUTH_INSTALLED = new AtomicBoolean(false);
private static volatile int relayPort = -1;
private static volatile String relayUser = "vojo";
private static volatile String relayToken;
private ProxyRouting() {}
/**
* Open a connection for `url`, routed through the relay when the proxy is
* enabled. If the proxy is enabled but the relay can't be started, throws
* {@link RelayUnavailableException} (fail-closed) rather than leaking the
* request direct. When the proxy is off, a plain direct connection.
*/
static HttpURLConnection openConnection(Context ctx, URL url) throws IOException {
ProxyManager pm = ProxyManager.get(ctx);
if (!pm.isProxyActiveForNative()) {
return (HttpURLConnection) url.openConnection();
}
int port = pm.ensureRelayForNative();
String token = pm.getRelayToken();
if (port < 0 || token == null) {
// FAIL-CLOSED: refuse the send instead of going direct (would leak
// IP/SNI on the censored network). Record it so the UI can warn.
pm.onNativeBlocked();
Log.w(TAG, "relay unavailable; native request blocked (fail-closed)");
throw new RelayUnavailableException();
}
relayPort = port;
relayToken = token;
relayUser = LocalProxyRelay.getAuthUser();
installAuthenticator();
// Relay is up and serving clear any stale "background blocked" flag.
pm.onNativeServed();
Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", port));
return (HttpURLConnection) url.openConnection(proxy);
}
/** Forget the relay endpoint + token when the relay is torn down, so the
* scoped Authenticator stops handing anything out (defense-in-depth). */
static void clearRelay() {
relayPort = -1;
relayToken = null;
}
/** Thrown when the proxy is enabled but the loopback relay can't be started,
* so a native request is refused rather than sent in the clear (F17). */
static final class RelayUnavailableException extends IOException {
RelayUnavailableException() {
super("proxy relay unavailable");
}
}
private static void installAuthenticator() {
if (!AUTH_INSTALLED.compareAndSet(false, true)) return;
Authenticator.setDefault(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
// The real security boundary is requestor-type + the ephemeral
// relay port + a live token: the token only works against our
// own 127.0.0.1 listener. Accept any loopback *name* Android may
// report for the relay endpoint (a literal IP InetSocketAddress
// can surface as "localhost" once resolved), so an authed native
// request never silently fails the host check and falls back to
// a direct, un-proxied connection (F9).
if (getRequestorType() == RequestorType.PROXY
&& isLoopbackHost(getRequestingHost())
&& getRequestingPort() == relayPort
&& relayToken != null) {
return new PasswordAuthentication(relayUser, relayToken.toCharArray());
}
// Not our loopback relay never hand out credentials.
return null;
}
});
}
private static boolean isLoopbackHost(String h) {
return "127.0.0.1".equals(h)
|| "::1".equals(h)
|| "localhost".equalsIgnoreCase(h);
}
}

View file

@ -130,7 +130,7 @@ public class ReplyReceiver extends BroadcastReceiver {
final String txnId = "vojo-reply-" + UUID.randomUUID();
EXECUTOR.execute(() -> {
try {
int status = sendReply(homeserver, token, roomId, txnId, text);
int status = sendReply(appContext, homeserver, token, roomId, txnId, text);
if (status >= 200 && status < 300) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "reply ok status=" + status + " room=" + roomId);
@ -149,6 +149,7 @@ public class ReplyReceiver extends BroadcastReceiver {
}
private int sendReply(
Context context,
String baseUrl,
String accessToken,
String roomId,
@ -173,7 +174,8 @@ public class ReplyReceiver extends BroadcastReceiver {
}
byte[] payload = body.toString().getBytes("UTF-8");
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
// Route through the loopback relay when a proxy is enabled (§15 #3).
HttpURLConnection conn = ProxyRouting.openConnection(context, new URL(url));
try {
conn.setRequestMethod("PUT");
conn.setRequestProperty("Authorization", "Bearer " + accessToken);

View file

@ -377,6 +377,14 @@ public class VojoPollWorker extends Worker {
.remove(KEY_ACCESS_TOKEN)
.apply();
return Result.success();
} catch (ProxyRouting.RelayUnavailableException e) {
// Proxy enabled but the relay couldn't start (e.g. Keystore not yet
// usable pre-unlock after a reboot). Fail-closed: skip this cycle
// rather than poll direct and leak IP/SNI. Result.success() (not
// retry) so we wait for the next scheduled fire instead of an
// accelerated retry-storm while the device is still locked.
Log.w(TAG, "poll: proxy relay unavailable — skipping cycle (fail-closed)");
return Result.success();
} catch (ForbiddenException e) {
// 403 from Synapse is usually rate-limit or a transient server
// policy reject, not a dead token. Don't clear credentials
@ -491,7 +499,10 @@ public class VojoPollWorker extends Worker {
url.append("&from=").append(java.net.URLEncoder.encode(fromCursor, "UTF-8"));
}
HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection();
// Route through the loopback relay when a proxy is enabled the
// /notifications poll is exactly the path a censored intranet user
// needs proxied (§15 #3). Identical to before when the proxy is off.
HttpURLConnection conn = ProxyRouting.openConnection(getApplicationContext(), new URL(url.toString()));
try {
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Bearer " + token);

View file

@ -16,6 +16,7 @@
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"check:tls-guard": "node scripts/check-no-tls-weakening.mjs",
"gen:push-strings": "node scripts/gen-push-strings.mjs",
"android:sync": "npx cap sync android",
"android:open": "npx cap open android",

View file

@ -113,6 +113,48 @@
"menu_devices": "Devices",
"menu_emojis_stickers": "Emojis & Stickers",
"menu_about": "About",
"network": {
"menu": "Connection",
"title": "Connection",
"section_proxy": "Proxy server",
"type_label": "Type",
"type_socks5": "SOCKS5",
"type_http": "HTTP",
"section_status": "Status",
"ping_label": "Ping",
"uptime_label": "Connected",
"checked_label": "Checked",
"scope_label": "Through proxy",
"scope_all": "All traffic",
"recheck": "Check now",
"host_label": "Host",
"host_placeholder": "proxy.example.com",
"port_label": "Port",
"port_placeholder": "1080",
"username_label_short": "Login",
"password_label_short": "Password",
"optional": "—",
"password_placeholder_set": "••••••••",
"toggle_password": "Show password",
"save": "Save",
"enabled_label": "Use proxy",
"remove": "Remove proxy",
"status_off": "Off",
"status_connecting": "Connecting…",
"status_ok": "Connected",
"status_error": "Can't reach the proxy",
"status_pending": "On — will verify at sign-in",
"native_blocked": "A background message couldn't go through the proxy and was held back. It will retry once the proxy is reachable.",
"unsupported": "Your system WebView is too old to route traffic through a proxy.",
"unavailable_platform": "A proxy is available only in the Vojo app for Android.",
"reverse_bypass_unsupported": "Your WebView can't limit the proxy to the homeserver, so all app traffic goes through it while it's on.",
"error_host_required": "Enter the proxy host.",
"error_host_scheme": "Enter only the host — no http:// or https://.",
"error_port_invalid": "Enter a port between 1 and 65535.",
"error_save_failed": "Couldn't save the proxy settings. Try again.",
"pre_login_entry": "Connection / proxy settings",
"pre_login_title": "Connection"
},
"drag_to_close": "Drag down to close",
"close": "Close",
"logout": "Logout",

View file

@ -113,6 +113,48 @@
"menu_devices": "Устройства",
"menu_emojis_stickers": "Эмодзи и стикеры",
"menu_about": "О приложении",
"network": {
"menu": "Соединение",
"title": "Соединение",
"section_proxy": "Сервер прокси",
"type_label": "Тип",
"type_socks5": "SOCKS5",
"type_http": "HTTP",
"section_status": "Состояние",
"ping_label": "Пинг",
"uptime_label": "Подключён",
"checked_label": "Проверка",
"scope_label": "Через прокси",
"scope_all": "Весь трафик",
"recheck": "Проверить",
"host_label": "Хост",
"host_placeholder": "proxy.example.com",
"port_label": "Порт",
"port_placeholder": "1080",
"username_label_short": "Логин",
"password_label_short": "Пароль",
"optional": "—",
"password_placeholder_set": "••••••••",
"toggle_password": "Показать пароль",
"save": "Сохранить",
"enabled_label": "Использовать прокси",
"remove": "Удалить прокси",
"status_off": "Выключен",
"status_connecting": "Подключение…",
"status_ok": "Подключено",
"status_error": "Прокси недоступен",
"status_pending": "Включён — проверим при входе",
"native_blocked": "Фоновое сообщение не удалось отправить через прокси, оно отложено. Повтор произойдёт, когда прокси станет доступен.",
"unsupported": "Системный WebView слишком старый, чтобы направлять трафик через прокси.",
"unavailable_platform": "Прокси доступен только в приложении Vojo для Android.",
"reverse_bypass_unsupported": "Ваш WebView не может ограничить прокси только домашним сервером, поэтому пока прокси включён, через него идёт весь трафик приложения.",
"error_host_required": "Укажите хост прокси.",
"error_host_scheme": "Укажите только хост — без http:// или https://.",
"error_port_invalid": "Укажите порт от 1 до 65535.",
"error_save_failed": "Не удалось сохранить настройки прокси. Попробуйте ещё раз.",
"pre_login_entry": "Соединение / прокси",
"pre_login_title": "Соединение"
},
"drag_to_close": "Потянуть вниз чтобы закрыть",
"close": "Закрыть",
"logout": "Выйти",

View file

@ -0,0 +1,64 @@
#!/usr/bin/env node
/* eslint-disable no-console, no-restricted-syntax */
// This is a standalone Node CLI guard (not app code): console output is its
// whole purpose, and directory-walk loops are clearer than array gymnastics.
//
// Binding guard for proxy_support.md §15 #6: the native tree must never weaken
// TLS. The bring-your-own-proxy relay is a pure L4 forwarder — the WebView's
// TLS to the homeserver stays end-to-end validated against the system store,
// even through an untrusted upstream proxy. This guard fails the build if a
// future change introduces an SSL-error bypass, a custom TrustManager /
// HostnameVerifier, or cleartext traffic. Wired into .husky/pre-commit.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = 'android/app/src/main';
const FORBIDDEN = [
{
re: /onReceivedSslError/,
msg: 'onReceivedSslError override (would let a proxy MITM WebView TLS)',
},
{
re: /X509TrustManager|X509ExtendedTrustManager|implements\s+TrustManager/,
msg: 'custom TrustManager (TLS trust bypass)',
},
{ re: /HostnameVerifier|ALLOW_ALL_HOSTNAME_VERIFIER/, msg: 'custom/permissive HostnameVerifier' },
{ re: /usesCleartextTraffic\s*=\s*"true"/, msg: 'usesCleartextTraffic="true"' },
{ re: /setHostnameVerifier|setDefaultHostnameVerifier/, msg: 'setHostnameVerifier' },
];
function walk(dir) {
let out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const s = statSync(p);
if (s.isDirectory()) out = out.concat(walk(p));
else if (/\.(java|kt|xml)$/.test(name)) out.push(p);
}
return out;
}
const violations = [];
for (const file of walk(ROOT)) {
const lines = readFileSync(file, 'utf8').split('\n');
lines.forEach((line, i) => {
const trimmed = line.trimStart();
// Skip comments (our own code documents that it does NOT do these things).
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
for (const { re, msg } of FORBIDDEN) {
if (re.test(line)) violations.push(` ${file}:${i + 1} ${msg}\n ${line.trim()}`);
}
});
}
if (violations.length) {
console.error('\n✗ TLS-weakening guard failed (proxy_support.md §15 #6):\n');
console.error(violations.join('\n'));
console.error(
'\nThe proxy is a pure L4 forwarder; WebView TLS must stay end-to-end validated.\n'
);
process.exit(1);
}
console.log('✓ TLS-weakening guard: clean');

View file

@ -24,6 +24,7 @@ import { Notifications } from './notifications';
import { Devices } from './devices';
import { EmojisStickers } from './emojis-stickers';
import { About } from './about';
import { Network } from './network';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
@ -32,6 +33,7 @@ export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
NetworkPage,
DevicesPage,
EmojisStickersPage,
AboutPage,
@ -52,6 +54,7 @@ export const SETTINGS_PAGE_PARAM = {
general: SettingsPages.GeneralPage,
account: SettingsPages.AccountPage,
notifications: SettingsPages.NotificationPage,
network: SettingsPages.NetworkPage,
devices: SettingsPages.DevicesPage,
emojis: SettingsPages.EmojisStickersPage,
about: SettingsPages.AboutPage,
@ -78,6 +81,7 @@ const SETTINGS_MENU_ITEMS: SettingsMenuItem[] = [
nameKey: 'Settings.menu_notifications',
icon: Icons.Bell,
},
{ page: SettingsPages.NetworkPage, nameKey: 'Settings.network.menu', icon: Icons.Server },
{ page: SettingsPages.DevicesPage, nameKey: 'Settings.menu_devices', icon: Icons.Monitor },
{
page: SettingsPages.EmojisStickersPage,
@ -285,6 +289,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{activePage === SettingsPages.NotificationPage && (
<Notifications requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.NetworkPage && (
<Network requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DevicesPage && (
<Devices requestClose={handlePageRequestClose} />
)}

View file

@ -0,0 +1,19 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { SettingsPage } from '../SettingsPage';
import { ProxyForm } from './ProxyForm';
type NetworkProps = {
requestClose: () => void;
};
export function Network({ requestClose }: NetworkProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
return (
<SettingsPage title={t('Settings.network.title')} requestClose={requestClose}>
<ProxyForm homeserverBaseUrl={mx.baseUrl} />
</SettingsPage>
);
}

View file

@ -0,0 +1,527 @@
import React, { FormEventHandler, useEffect, useId, useRef, useState } from 'react';
import {
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Switch,
Text,
color,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { SectionLabel } from '../styles.css';
import { useProxy } from '../../../hooks/useProxy';
import { isAndroidPlatform } from '../../../utils/capacitor';
import { stopPropagation } from '../../../utils/keyboard';
import type { ProxyStatus, ProxyType } from '../../../plugins/proxy';
import * as css from './network.css';
const TYPE_OPTIONS: ProxyType[] = ['socks5', 'http'];
// A bare host is `letters/digits/dots/hyphens/colon-for-ipv6` with no scheme.
// We explicitly forbid `http://` / `https://` prefixes: the WebView proxy-auth
// mechanism only works for a plaintext-HTTP proxy inbound, an HTTPS proxy
// hard-fails ERR_PROXY_AUTH_UNSUPPORTED (proxy_support.md §15 #2). So the form
// accepts only a bare host and a scheme derived from the type selector.
const SCHEME_RE = /:\/\//;
type ProxyFormProps = {
// The live homeserver base URL (`https://matrix.vojo.chat`). Used to scope
// the override to that host (reverse-bypass). Omit pre-login when the
// homeserver isn't resolved yet — the override then routes all traffic.
homeserverBaseUrl?: string;
// Compact = the pre-login overlay (which has its own header), so the section
// label is dropped.
compact?: boolean;
};
export function ProxyForm({ homeserverBaseUrl, compact }: ProxyFormProps) {
const { t, i18n } = useTranslation();
const { state, saveConfig, setEnabled, clearConfig, probe } = useProxy();
const fid = useId();
const locale = i18n.language || 'en';
// Tick once a second while enabled so the uptime / "checked …" rows stay live.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!state.enabled) return undefined;
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [state.enabled]);
const [type, setType] = useState<ProxyType>('socks5');
const [typeMenu, setTypeMenu] = useState<RectCords>();
const [host, setHost] = useState('');
const [port, setPort] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [passwordTouched, setPasswordTouched] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);
// Prefill the non-secret fields once the native state has hydrated. The
// password is never read back — it stays blank with an "unchanged" hint.
const initializedRef = useRef(false);
useEffect(() => {
if (initializedRef.current) return;
if (!state.configured) return;
initializedRef.current = true;
if (state.type) setType(state.type);
if (state.host) setHost(state.host);
if (typeof state.port === 'number') setPort(String(state.port));
if (state.username) setUsername(state.username);
}, [state]);
const validate = (): { host: string; port: number } | undefined => {
const trimmedHost = host.trim();
if (!trimmedHost) {
setError(t('Settings.network.error_host_required'));
return undefined;
}
if (SCHEME_RE.test(trimmedHost) || /\s/.test(trimmedHost)) {
setError(t('Settings.network.error_host_scheme'));
return undefined;
}
const portNum = Number.parseInt(port, 10);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
setError(t('Settings.network.error_port_invalid'));
return undefined;
}
setError(undefined);
return { host: trimmedHost, port: portNum };
};
const persist = async (): Promise<boolean> => {
const valid = validate();
if (!valid) return false;
try {
await saveConfig({
type,
host: valid.host,
port: valid.port,
username: username.trim() || undefined,
// Omit the password when the user didn't touch it: the native store
// keeps the existing encrypted value. An empty username clears auth.
password: passwordTouched ? password : undefined,
});
} catch {
// The native store rejected (e.g. Keystore failure). Surface it and KEEP
// the typed password — clearing it here (the success path below) would
// silently lose the secret the user just entered (F11).
setError(t('Settings.network.error_save_failed'));
return false;
}
setPasswordTouched(false);
setPassword('');
return true;
};
const handleSave: FormEventHandler<HTMLFormElement> = async (evt) => {
evt.preventDefault();
if (busy) return;
setBusy(true);
try {
const ok = await persist();
// Re-apply immediately if the proxy is already on, so edits take effect
// without a manual off/on cycle.
if (ok && state.enabled) {
await setEnabled(true, homeserverBaseUrl);
}
} finally {
setBusy(false);
}
};
const handleToggle = async (next: boolean) => {
if (busy) return;
setBusy(true);
try {
if (next) {
if (!(await persist())) return;
await setEnabled(true, homeserverBaseUrl);
} else {
await setEnabled(false, homeserverBaseUrl);
}
} finally {
setBusy(false);
}
};
const handleRemove = async () => {
if (busy) return;
setBusy(true);
try {
await clearConfig();
initializedRef.current = false;
setHost('');
setPort('');
setUsername('');
setPassword('');
setPasswordTouched(false);
setError(undefined);
} finally {
setBusy(false);
}
};
// Pre-login (no homeserverBaseUrl) we can't probe reachability, so a native
// 'connecting' would hang forever. Present it as a calm neutral "pending"
// (on, will verify at sign-in) instead of a perpetual amber "Connecting…"
// (F2/F18). Once logged in, the boot probe gives a real ok/error.
const canProbe = Boolean(homeserverBaseUrl);
const displayStatus: ProxyStatus | 'pending' =
state.enabled && !canProbe && state.status === 'connecting' ? 'pending' : state.status;
const statusLabel = ((): string => {
switch (displayStatus) {
case 'ok':
return t('Settings.network.status_ok');
case 'error':
return t('Settings.network.status_error');
case 'connecting':
return t('Settings.network.status_connecting');
case 'pending':
return t('Settings.network.status_pending');
default:
return t('Settings.network.status_off');
}
})();
const onPortChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setPort(e.target.value.replace(/[^\d]/g, '').slice(0, 5));
const typeLabel = (v: ProxyType): string =>
v === 'socks5' ? t('Settings.network.type_socks5') : t('Settings.network.type_http');
// Locale-aware metadata formatters (no per-unit i18n keys needed — Intl
// renders "123 ms" / "123 мс", "5 min" / "5 мин", "2 minutes ago" / …).
const unit = (value: number, u: string): string =>
new Intl.NumberFormat(locale, { style: 'unit', unit: u, unitDisplay: 'short' }).format(value);
const fmtPing = (ms: number): string => unit(ms, 'millisecond');
const fmtUptime = (sinceMs: number): string => {
const secs = Math.max(0, Math.floor((now - sinceMs) / 1000));
if (secs < 60) return unit(secs, 'second');
const mins = Math.floor(secs / 60);
if (mins < 60) return unit(mins, 'minute');
return `${unit(Math.floor(mins / 60), 'hour')} ${unit(mins % 60, 'minute')}`;
};
const fmtChecked = (atMs: number): string => {
const deltaS = Math.round((atMs - now) / 1000);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
return Math.abs(deltaS) < 60
? rtf.format(deltaS, 'second')
: rtf.format(Math.round(deltaS / 60), 'minute');
};
const handleRecheck = async () => {
if (!homeserverBaseUrl || busy) return;
setBusy(true);
try {
await probe(homeserverBaseUrl);
} finally {
setBusy(false);
}
};
// "Has anything actually changed since what's saved?" — drives the Save
// button's enabled state so it isn't perpetually lit with nothing to persist.
// The password counts as a change only once the user types into it
// (passwordTouched), since the saved secret never round-trips to JS.
const savedPort = typeof state.port === 'number' ? String(state.port) : '';
const isDirty =
passwordTouched ||
type !== (state.type ?? 'socks5') ||
host.trim() !== (state.host ?? '') ||
port !== savedPort ||
username.trim() !== (state.username ?? '');
return (
<Box direction="Column" gap="400">
{/* Hero: icon + title + live status + the big toggle. */}
<div className={css.HeroCard}>
<div className={css.HeroIcon}>
<Icon src={Icons.Server} size="300" />
</div>
<div className={css.HeroText}>
<Text size="T400" truncate>
{t('Settings.network.enabled_label')}
</Text>
<span className={css.StatusLine}>
<span className={`${css.StatusDot} ${css.statusDotColor[displayStatus]}`} />
<Text size="T200" priority="300" truncate>
{statusLabel}
</Text>
</span>
</div>
{busy && <Spinner size="100" variant="Secondary" />}
<Switch
variant="Primary"
value={state.enabled}
onChange={handleToggle}
disabled={!state.supported || busy}
/>
</div>
{!state.supported ? (
<div className={css.NoticeCard}>
<span className={css.InfoIcon}>
<Icon size="300" src={Icons.Info} />
</span>
<Text size="T200">
{isAndroidPlatform()
? t('Settings.network.unsupported')
: t('Settings.network.unavailable_platform')}
</Text>
</div>
) : (
<>
{state.enabled && (
<Box direction="Column" gap="200">
{!compact && (
<Text className={SectionLabel}>{t('Settings.network.section_status')}</Text>
)}
<div className={css.Panel}>
<div className={css.FieldRow}>
<span className={css.FieldLabel}>{t('Settings.network.ping_label')}</span>
<span className={css.MetaValue}>
{state.latencyMs != null ? fmtPing(state.latencyMs) : '—'}
</span>
</div>
<div className={css.FieldRow}>
<span className={css.FieldLabel}>{t('Settings.network.uptime_label')}</span>
<span className={css.MetaValue}>
{state.enabledSince ? fmtUptime(state.enabledSince) : '—'}
</span>
</div>
<div className={css.FieldRow}>
<span className={css.FieldLabel}>{t('Settings.network.checked_label')}</span>
<span className={css.MetaValue}>
{state.checkedAt ? fmtChecked(state.checkedAt) : '—'}
{homeserverBaseUrl && (
<IconButton
type="button"
size="300"
radii="300"
variant="SurfaceVariant"
onClick={handleRecheck}
disabled={busy}
aria-label={t('Settings.network.recheck')}
>
<Icon size="100" src={Icons.Reload} />
</IconButton>
)}
</span>
</div>
<div className={css.FieldRow}>
<span className={css.FieldLabel}>{t('Settings.network.scope_label')}</span>
<span className={css.MetaValue}>
{state.scopeHost ?? t('Settings.network.scope_all')}
</span>
</div>
</div>
{state.nativeBlockedAt ? (
<span className={css.WarnLine}>
<Icon size="100" src={Icons.Warning} style={{ flexShrink: 0 }} />
<Text size="T200">{t('Settings.network.native_blocked')}</Text>
</span>
) : null}
</Box>
)}
{/* Server section: type + address + optional auth, one grouped panel. */}
<form onSubmit={handleSave}>
<Box direction="Column" gap="200">
{!compact && (
<Text className={SectionLabel}>{t('Settings.network.section_proxy')}</Text>
)}
<div className={css.Panel}>
<div className={css.FieldRow}>
<span className={css.FieldLabel}>{t('Settings.network.type_label')}</span>
<button
type="button"
className={css.SelectValue}
onClick={(e) => setTypeMenu(e.currentTarget.getBoundingClientRect())}
aria-haspopup="menu"
>
{typeLabel(type)}
<span className={css.SelectChevron}>
<Icon size="100" src={Icons.ChevronBottom} />
</span>
</button>
<PopOut
anchor={typeMenu}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setTypeMenu(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{TYPE_OPTIONS.map((opt) => (
<MenuItem
key={opt}
size="300"
variant="Surface"
radii="300"
aria-selected={type === opt}
onClick={() => {
setType(opt);
setTypeMenu(undefined);
}}
>
<Box grow="Yes" alignItems="Center" gap="200">
<Text style={{ flexGrow: 1 }} size="T300">
{typeLabel(opt)}
</Text>
{type === opt && <Icon size="100" src={Icons.Check} />}
</Box>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</div>
<div className={css.FieldRow}>
<label className={css.FieldLabel} htmlFor={`${fid}-host`}>
{t('Settings.network.host_label')}
</label>
<input
id={`${fid}-host`}
className={css.FieldInput}
value={host}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setHost(e.target.value)}
placeholder={t('Settings.network.host_placeholder')}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
inputMode="url"
/>
</div>
<div className={css.FieldRow}>
<label className={css.FieldLabel} htmlFor={`${fid}-port`}>
{t('Settings.network.port_label')}
</label>
<input
id={`${fid}-port`}
className={css.FieldInput}
value={port}
onChange={onPortChange}
placeholder={t('Settings.network.port_placeholder')}
inputMode="numeric"
/>
</div>
<div className={css.FieldRow}>
<label className={css.FieldLabel} htmlFor={`${fid}-user`}>
{t('Settings.network.username_label_short')}
</label>
<input
id={`${fid}-user`}
className={css.FieldInput}
value={username}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setUsername(e.target.value)
}
placeholder={t('Settings.network.optional')}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</div>
<div className={css.FieldRow}>
<label className={css.FieldLabel} htmlFor={`${fid}-pass`}>
{t('Settings.network.password_label_short')}
</label>
<input
id={`${fid}-pass`}
className={css.FieldInput}
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
setPasswordTouched(true);
}}
placeholder={
state.hasAuth
? t('Settings.network.password_placeholder_set')
: t('Settings.network.optional')
}
autoComplete="off"
/>
<button
type="button"
className={css.EyeButton}
onClick={() => setShowPassword((v) => !v)}
aria-label={t('Settings.network.toggle_password')}
>
<Icon size="100" src={showPassword ? Icons.Eye : Icons.EyeBlind} />
</button>
</div>
</div>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
{!state.reverseBypassSupported && (
<Text size="T200" style={{ color: color.Warning.Main }}>
{t('Settings.network.reverse_bypass_unsupported')}
</Text>
)}
<Button
type="submit"
variant="Primary"
size="500"
radii="400"
fill="Solid"
disabled={busy || !isDirty}
>
<Text size="B400">{t('Settings.network.save')}</Text>
</Button>
{state.configured && (
<Button
type="button"
variant="Critical"
fill="None"
size="400"
radii="400"
onClick={handleRemove}
disabled={busy}
before={<Icon size="100" src={Icons.Delete} />}
>
<Text size="B400">{t('Settings.network.remove')}</Text>
</Button>
)}
</Box>
</form>
</>
)}
</Box>
);
}

View file

@ -0,0 +1,2 @@
export * from './Network';
export * from './ProxyForm';

View file

@ -0,0 +1,183 @@
import { style, styleVariants } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Hero status card — leading tinted icon, title + live status, big toggle.
export const HeroCard = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
backgroundColor: color.SurfaceVariant.Container,
borderRadius: toRem(16),
padding: config.space.S400,
});
export const HeroIcon = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(40),
height: toRem(40),
borderRadius: toRem(12),
flexShrink: 0,
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
});
export const HeroText = style({
display: 'flex',
flexDirection: 'column',
gap: toRem(2),
flexGrow: 1,
minWidth: 0,
});
export const StatusLine = style({
display: 'flex',
alignItems: 'center',
gap: toRem(6),
});
export const StatusDot = style({
width: toRem(8),
height: toRem(8),
borderRadius: '50%',
flexShrink: 0,
});
export const statusDotColor = styleVariants({
off: { backgroundColor: color.SurfaceVariant.OnContainer, opacity: 0.4 },
connecting: { backgroundColor: color.Warning.Main },
ok: { backgroundColor: color.Success.Main },
error: { backgroundColor: color.Critical.Main },
// Pre-login: enabled but unverifiable (no homeserver to probe yet) — a calm
// neutral dot, not the amber "connecting" that would never resolve.
pending: { backgroundColor: color.Primary.Main },
});
// A subtle inline warning (e.g. background native traffic was blocked).
export const WarnLine = style({
display: 'flex',
gap: toRem(6),
alignItems: 'flex-start',
color: color.Warning.Main,
});
// One softly-rounded inset panel; rows parted by hairlines (iOS grouped list).
export const Panel = style({
display: 'flex',
flexDirection: 'column',
backgroundColor: color.SurfaceVariant.Container,
borderRadius: toRem(16),
overflow: 'hidden',
});
// A field row: muted label on the left, borderless input filling the rest.
export const FieldRow = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
minHeight: toRem(48),
paddingLeft: config.space.S400,
paddingRight: config.space.S400,
paddingTop: config.space.S200,
paddingBottom: config.space.S200,
selectors: {
'&:not(:last-child)': {
borderBottom: `1px solid ${color.SurfaceVariant.ContainerLine}`,
},
},
});
export const FieldLabel = style({
width: toRem(96),
flexShrink: 0,
whiteSpace: 'nowrap',
fontSize: toRem(15),
color: color.SurfaceVariant.OnContainer,
});
// Read-only metadata value on the right of a status row.
export const MetaValue = style({
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
gap: toRem(6),
minWidth: 0,
fontSize: toRem(15),
color: color.SurfaceVariant.OnContainer,
fontVariantNumeric: 'tabular-nums',
});
// Borderless input that reads as part of the row (no box of its own).
export const FieldInput = style({
flexGrow: 1,
minWidth: 0,
border: 'none',
outline: 'none',
background: 'transparent',
color: color.SurfaceVariant.OnContainer,
fontSize: toRem(15),
fontFamily: 'inherit',
padding: 0,
textAlign: 'right',
'::placeholder': {
color: color.SurfaceVariant.OnContainer,
opacity: 0.35,
},
});
export const EyeButton = style({
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
background: 'transparent',
color: color.SurfaceVariant.OnContainer,
opacity: 0.6,
cursor: 'pointer',
padding: toRem(2),
selectors: { '&:hover': { opacity: 1 } },
});
// A tappable "value" on the right of a grouped row (the Type selector) — the
// current value + a chevron, borderless so it reads as part of the row, same
// as the iOS settings "Theme " pattern.
export const SelectValue = style({
display: 'flex',
alignItems: 'center',
gap: toRem(4),
marginLeft: 'auto',
border: 'none',
outline: 'none',
background: 'transparent',
color: color.SurfaceVariant.OnContainer,
fontSize: toRem(15),
fontWeight: config.fontWeight.W500,
fontFamily: 'inherit',
cursor: 'pointer',
padding: 0,
});
export const SelectChevron = style({
display: 'flex',
opacity: 0.5,
});
// A calm "feature unavailable here" notice (web / desktop / ancient WebView).
export const NoticeCard = style({
display: 'flex',
gap: config.space.S300,
alignItems: 'flex-start',
padding: config.space.S400,
borderRadius: toRem(16),
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
});
export const InfoIcon = style({
flexShrink: 0,
color: color.SurfaceVariant.OnContainer,
opacity: 0.7,
marginTop: toRem(1),
});

171
src/app/hooks/useProxy.ts Normal file
View file

@ -0,0 +1,171 @@
import { useAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import { proxy, type ProxyState, type ProxyType } from '../plugins/proxy';
import { proxyStateAtom } from '../state/proxy';
// Derive the bare apex host the override should be scoped to from a homeserver
// base URL (`https://matrix.vojo.chat` → `matrix.vojo.chat`). Returns
// undefined for an empty / unparseable URL so the caller falls back to
// route-all (pre-login discovery). Kept here so both the settings page and the
// pre-login entry scope identically.
export const hostFromBaseUrl = (baseUrl?: string): string | undefined => {
if (!baseUrl) return undefined;
try {
return new URL(baseUrl).hostname || undefined;
} catch {
return undefined;
}
};
export type SaveProxyInput = {
type: ProxyType;
host: string;
port: number;
username?: string;
password?: string;
};
export type UseProxyResult = {
state: ProxyState;
refresh: () => Promise<void>;
// Persist creds (does not enable). Returns the refreshed state.
saveConfig: (input: SaveProxyInput) => Promise<ProxyState>;
// Apply / remove the override, scoping to `homeserverBaseUrl` when given.
setEnabled: (
enabled: boolean,
homeserverBaseUrl?: string,
routeAll?: boolean
) => Promise<ProxyState>;
// Wipe creds + disable + tear down the relay.
clearConfig: () => Promise<ProxyState>;
// Re-run the reachability + latency probe against the homeserver (the
// "Check"/refresh action in the status card).
probe: (homeserverBaseUrl: string) => Promise<void>;
};
export function useProxy(): UseProxyResult {
const [state, setState] = useAtom(proxyStateAtom);
const refresh = useCallback(async () => {
const next = await proxy.getState();
setState((prev) => {
// Reachability ('ok'/'error') is owned by the JS probe — native can only
// ever report 'off' / 'connecting' / a latched WebView 'error'. On a
// getState hydrate, keep the probe's verdict while the proxy is enabled so
// a STALE native field can't regress it: neither native 'connecting'
// dropping a confirmed 'ok' (F3), nor a latched native 'error' re-sticking
// after a recheck already healed it to 'ok'. Only 'off' (disabled) wins,
// since that's authoritative. Live failures still arrive via the listener.
const keepProbeVerdict = next.enabled && (prev.status === 'ok' || prev.status === 'error');
return {
...next,
// latencyMs / checkedAt are client-only probe results — getState
// (native) never reports them. Preserve across a refresh (e.g. another
// consumer like DirectSelfRow mounting) instead of wiping the ping the
// user just measured (F3); drop only when disabled.
latencyMs: next.enabled ? prev.latencyMs : undefined,
checkedAt: next.enabled ? prev.checkedAt : undefined,
status: keepProbeVerdict ? prev.status : next.status,
};
});
}, [setState]);
// Hydrate once on mount, then keep `status` live via the native listener
// (dead-proxy detection fires a 'proxyStatus' event from the WebView error
// callbacks — see ProxyManager.onWebViewError).
useEffect(() => {
let active = true;
refresh();
const handlePromise = proxy.addListener((event) => {
if (!active) return;
setState((prev) => {
// Native re-emits 'connecting' on every (re)apply; once the JS probe
// has a verdict, a late 'connecting' event must not regress it (F4).
// 'error' (dead-proxy detected) and 'off' (disabled) are authoritative
// and always win.
if (event.status === 'connecting' && (prev.status === 'ok' || prev.status === 'error')) {
return prev;
}
return { ...prev, status: event.status };
});
});
return () => {
active = false;
handlePromise.then((h) => h.remove()).catch(() => undefined);
};
}, [refresh, setState]);
const saveConfig = useCallback(
async (input: SaveProxyInput) => {
await proxy.setConfig(input);
const next = await proxy.getState();
setState(next);
return next;
},
[setState]
);
// Confirm reachability through the (now-applied) proxy AND measure latency by
// fetching `/_matrix/client/versions` (a tiny unauthenticated CS-API endpoint)
// through the WebView, which rides the override. This is the authoritative
// "the proxy actually reaches the homeserver" + "ping" signal (F5 dead-proxy
// detection) — the native 'connecting'/'error' callbacks never flip to 'ok'.
const probe = useCallback(
async (homeserverBaseUrl: string) => {
setState((prev) => ({ ...prev, status: 'connecting' }));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
const t0 = performance.now();
try {
const res = await fetch(`${homeserverBaseUrl.replace(/\/$/, '')}/_matrix/client/versions`, {
signal: controller.signal,
cache: 'no-store',
});
const latencyMs = Math.round(performance.now() - t0);
setState((prev) => ({
...prev,
status: res.ok ? 'ok' : 'error',
latencyMs: res.ok ? latencyMs : undefined,
checkedAt: Date.now(),
}));
} catch {
setState((prev) => ({
...prev,
status: 'error',
latencyMs: undefined,
checkedAt: Date.now(),
}));
} finally {
clearTimeout(timer);
}
},
[setState]
);
const setEnabled = useCallback(
async (enabled: boolean, homeserverBaseUrl?: string, routeAll?: boolean) => {
const next = await proxy.setEnabled({
enabled,
homeserverHost: hostFromBaseUrl(homeserverBaseUrl),
routeAll,
});
setState(next);
// Skipped pre-login (no base URL → route-all); there the login flow is
// the real signal.
if (enabled && next.enabled && homeserverBaseUrl) {
await probe(homeserverBaseUrl);
}
return next;
},
[setState, probe]
);
const clearConfig = useCallback(async () => {
await proxy.clearConfig();
const next = await proxy.getState();
setState(next);
return next;
}, [setState]);
return { state, refresh, saveConfig, setEnabled, clearConfig, probe };
}

View file

@ -1,5 +1,6 @@
import React, { Ref } from 'react';
import * as css from './styles.css';
import { ProxySettingsEntry } from './ProxySettingsEntry';
interface AuthFooterProps {
footerRef?: Ref<HTMLElement>;
@ -9,6 +10,7 @@ export function AuthFooter({ footerRef }: AuthFooterProps) {
return (
<footer className={css.AuthFooter} role="contentinfo" ref={footerRef}>
<span>Powered by Matrix</span>
<ProxySettingsEntry />
</footer>
);
}

View file

@ -0,0 +1,88 @@
import React, { useState } from 'react';
import {
Box,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
color,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { isAndroidPlatform } from '../../utils/capacitor';
import { stopPropagation } from '../../utils/keyboard';
import { ProxyForm } from '../../features/settings/network';
import * as css from './styles.css';
/**
* Pre-login proxy entry (proxy_android_impl.md §2, F1). Because creds are
* wiped on logout, a blocked re-login must be able to configure a proxy
* BEFORE authenticating this is the headline censorship case. Rendered as
* a self-contained overlay so it never touches the bistable auth-layout CSS
* (bugs.md). Android-only; renders nothing elsewhere.
*
* No homeserver base URL is known yet pre-login, so the override routes all
* traffic during discovery; the authed boot path narrows it to the resolved
* homeserver host once logged in (applyAtBoot in index boot).
*/
export function ProxySettingsEntry() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
if (!isAndroidPlatform()) return null;
return (
<>
<button type="button" className={css.AuthProxyEntry} onClick={() => setOpen(true)}>
{t('Settings.network.pre_login_entry')}
</button>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Box
direction="Column"
style={{
maxWidth: '28rem',
width: '100%',
maxHeight: '85vh',
backgroundColor: color.SurfaceVariant.Container,
borderRadius: config.radii.R400,
}}
>
<Header
size="500"
style={{ paddingLeft: config.space.S400, paddingRight: config.space.S200 }}
>
<Box grow="Yes">
<Text size="H4">{t('Settings.network.pre_login_title')}</Text>
</Box>
<IconButton size="300" radii="300" onClick={() => setOpen(false)}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
grow="Yes"
direction="Column"
style={{ padding: config.space.S400, overflowY: 'auto' }}
>
<ProxyForm compact />
</Box>
</Box>
</FocusTrap>
</OverlayCenter>
</Overlay>
</>
);
}

View file

@ -277,8 +277,10 @@ export const AuthBottomLink = style({
/* ── Footer ── */
export const AuthFooter = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
width: '100%',
padding: '20px 24px 10px',
fontSize: '14px',
@ -286,6 +288,24 @@ export const AuthFooter = style({
letterSpacing: '0.02em',
});
/* Pre-login proxy entry a subtle text button under "Powered by Matrix".
Android-only (gated in ProxySettingsEntry); never affects the bistable
layout since AuthLayout measures footer.offsetHeight dynamically. */
export const AuthProxyEntry = style({
appearance: 'none',
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '13px',
color: 'rgba(232, 228, 223, 0.55)',
textDecoration: 'underline',
textUnderlineOffset: '2px',
padding: '2px 6px',
selectors: {
'&:hover': { color: '#ffffff' },
},
});
/* ── Theme overrides for folds components inside auth card ── */
/* Extract CSS custom property name from folds color token reference */
/* e.g. "var(--oq6d070)" → "--oq6d070" — tied to folds API, not hashed names */

View file

@ -18,6 +18,8 @@ import { AutoDiscovery } from './AutoDiscovery';
import { AuthSplashScreen } from '../auth/AuthSplashScreen';
import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridge';
import { polling } from '../../plugins/polling';
import { proxy } from '../../plugins/proxy';
import { useProxy } from '../../hooks/useProxy';
function ClientRootLoading() {
return <AuthSplashScreen />;
@ -30,6 +32,9 @@ const useLogoutListener = (mx?: MatrixClient) => {
// access_token lingers in shared_prefs and CallDeclineReceiver spends
// the next login cycle posting 401s until writeSessionBridge overwrites.
await clearSessionBridge();
// Wipe the Tink-encrypted proxy creds + clear the override + tear down
// the relay on a server-driven logout too (proxy_support.md §15, locked).
await proxy.clearConfig();
// Same applies to the WorkManager polling fallback: it has its own
// SharedPreferences-stored access_token that the React lifecycle
// cleanup wouldn't get a chance to clear before window.location.reload.
@ -77,6 +82,11 @@ export function ClientRoot({ children }: ClientRootProps) {
);
useLogoutListener(mx);
// The proxy override + status atom (Android-only; no-op on web). Used by the
// post-login narrow effect below to scope a pre-login route-all override AND
// to run the reachability probe so the status stops reading "Connecting…"
// once we have a homeserver to measure against (F1/F18).
const { setEnabled: setProxyEnabled, probe: proxyProbe } = useProxy();
// Mark the user as Online during foreground and Unavailable while the
// tab is hidden / app is backgrounded, so background long-poll /sync
// calls stop fraudulently bumping last_active_ago on the homeserver.
@ -105,6 +115,39 @@ export function ClientRoot({ children }: ClientRootProps) {
writeSessionBridge(mx);
}, [mx]);
// If an Android proxy was configured pre-login (route-all during discovery),
// narrow it to the now-resolved homeserver host so external URL-preview /
// widget origins stop riding the user's proxy (proxy_android_impl.md §2).
// Idempotent + no-op when the proxy is off or on web.
useEffect(() => {
if (!mx) return;
(async () => {
try {
const state = await proxy.getState();
if (!state.enabled) return;
const host = new URL(mx.baseUrl).hostname;
if (state.scopeHost === host) {
// Already scoped to this exact host — the common logged-in boot path,
// where applyAtBoot ran with the same host. Don't re-apply: apply()
// tears down and restarts the relay, dropping the /sync tunnel that
// just started and rotating the relay token on every boot (F6). But
// DO probe — applyAtBoot was native-only, so the status is still
// 'connecting'; this flips it to ok/error + measures the ping (F1).
await proxyProbe(mx.baseUrl);
return;
}
// Coming from a pre-login route-all override (scopeHost null or another
// host) — narrow it to the resolved homeserver host so external
// URL-preview / widget origins stop riding the user's proxy. setEnabled
// (the hook) also probes once applied, so the status becomes honest the
// moment a homeserver exists to measure against (F18).
await setProxyEnabled(true, mx.baseUrl);
} catch {
// ignore — the boot-time apply already covers the logged-in path.
}
})();
}, [mx, setProxyEnabled, proxyProbe]);
// When the OS reports the network is back, prod the sync loop instead of
// waiting for matrix-js-sdk's internal keep-alive jitter (510s backoff
// per `sync.js`). Only acts when the SDK is genuinely paused on a failed

View file

@ -11,6 +11,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useOpenSettingsSheet } from '../../../state/hooks/settingsSheet';
import { useProxy } from '../../../hooks/useProxy';
import { SETTINGS_SHEET_DRAG_ORIGIN_ATTR } from '../../../features/settings/MobileSettingsHorseshoe';
import { SETTINGS_FROM_IN_APP_STATE } from '../../../features/settings/SettingsScreen';
import { getSettingsPath } from '../../pathUtils';
@ -27,6 +28,9 @@ export function DirectSelfRow() {
const openSheet = useOpenSettingsSheet();
const userId = mx.getSafeUserId();
const profile = useUserProfile(userId);
// Live "proxy is on" indicator on the Settings & profile row (Android only;
// off on web). Hydrates + subscribes the proxy atom from the main screen.
const { state: proxyState } = useProxy();
// Mobile: open the atom-driven bottom-up sheet so the DM list stays
// visible above with a void gap (the )|( horseshoe). The
@ -120,6 +124,14 @@ export function DirectSelfRow() {
{t('Direct.self_row_preview')}
</Text>
</Box>
{proxyState.enabled && (
<Icon
src={Icons.Shield}
size="100"
filled
style={{ color: color.Success.Main, flexShrink: 0 }}
/>
)}
<Icon
src={Icons.Setting}
size="100"

163
src/app/plugins/proxy.ts Normal file
View file

@ -0,0 +1,163 @@
// Bridge to the native ProxyPlugin (see
// android/app/src/main/java/chat/vojo/app/ProxyPlugin.java).
//
// "Bring your own proxy" on Android: the user enters their own HTTP /
// SOCKS5 proxy (host/port + optional user/pass) and Vojo routes its own
// WebView traffic through it via androidx.webkit ProxyController, so the
// homeserver is reachable on networks that block it. No VpnService, no
// Vojo-operated servers.
//
// Secrets (the proxy password) transit JS only at entry — they are written
// straight into a native Tink-encrypted store and NEVER read back, never
// persisted in localStorage / settingsAtom / any JS state. getState
// deliberately omits the password.
//
// Web / Electron have no analogue here (web cannot client-proxy at all; the
// Chromium net stack owns proxy selection). Every call is a no-op off
// Android — mirrors plugins/polling.ts.
import { registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import { isAndroidPlatform } from '../utils/capacitor';
export type ProxyType = 'http' | 'socks5';
// Live status of the override. 'off' = no override applied; 'connecting' =
// applied but not yet confirmed reachable; 'ok' = a homeserver request went
// through; 'error' = the WebView reported a proxy/connection error on the
// homeserver origin (dead / wrong proxy — see ProxyManager.onWebViewError).
export type ProxyStatus = 'off' | 'connecting' | 'ok' | 'error';
// Non-secret view of the stored config. The password is intentionally
// absent — the native side never hands it back.
export type ProxyConfigView = {
configured: boolean;
type?: ProxyType;
host?: string;
port?: number;
hasAuth?: boolean;
username?: string;
};
export type ProxyState = ProxyConfigView & {
enabled: boolean;
status: ProxyStatus;
// WebViewFeature.isFeatureSupported(PROXY_OVERRIDE) — false on ancient
// WebView APKs (< 72). When false the feature is unavailable and the UI
// must say so instead of silently doing nothing.
supported: boolean;
// PROXY_OVERRIDE_REVERSE_BYPASS — when false we cannot host-scope the
// override and fall back to routing all WebView traffic through the proxy.
reverseBypassSupported: boolean;
// Epoch-ms when the override was applied (0/undefined when off) — uptime base.
enabledSince?: number;
// The homeserver host the override is scoped to (undefined = route-all).
scopeHost?: string;
// Whether traffic rides the loopback relay (authed proxy) vs direct.
relayed?: boolean;
// Client-measured reachability ping (NOT from native): set by the JS probe
// that fetches `/_matrix/client/versions` through the proxy.
latencyMs?: number;
// Epoch-ms of the last reachability probe.
checkedAt?: number;
// Epoch-ms of the most recent background native send that was refused because
// the relay couldn't start (fail-closed). 0/undefined = none. Lets the UI
// warn that background traffic was blocked (proxy_support.md §15 #3, F17).
nativeBlockedAt?: number;
};
export type ProxyStatusEvent = { status: ProxyStatus; detail?: string };
interface ProxyPluginIface {
// Persist creds (Tink). Does NOT enable the override. Password is consumed
// here and never returned.
setConfig(opts: {
type: ProxyType;
host: string;
port: number;
username?: string;
password?: string;
}): Promise<void>;
// Wipe creds + disable the override + tear down the relay.
clearConfig(): Promise<void>;
// Apply or remove the override. `homeserverHost` scopes the reverse-bypass
// (only that host is proxied); omit it pre-login to route everything during
// discovery. `routeAll` forces full coverage even when a host is known.
setEnabled(opts: {
enabled: boolean;
homeserverHost?: string;
routeAll?: boolean;
}): Promise<ProxyState>;
// Full state for hydrating the UI.
getState(): Promise<ProxyState>;
// Re-apply the stored (enabled) override at app boot, before login.
applyAtBoot(opts: { homeserverHost?: string }): Promise<ProxyState>;
addListener(
eventName: 'proxyStatus',
listenerFunc: (event: ProxyStatusEvent) => void
): Promise<PluginListenerHandle>;
}
const DEFAULT_STATE: ProxyState = {
configured: false,
enabled: false,
status: 'off',
supported: false,
reverseBypassSupported: false,
};
const noopPlugin: ProxyPluginIface = {
setConfig: async () => undefined,
clearConfig: async () => undefined,
setEnabled: async () => DEFAULT_STATE,
getState: async () => DEFAULT_STATE,
applyAtBoot: async () => DEFAULT_STATE,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
addListener: async (_e, _f) => ({ remove: async () => undefined }),
};
const plugin = registerPlugin<ProxyPluginIface>('Proxy', {
web: noopPlugin,
});
const guard = async <T>(fn: () => Promise<T>, fallback: T): Promise<T> => {
if (!isAndroidPlatform()) return fallback;
try {
return await fn();
} catch (err) {
// Old APK installed before the plugin shipped, or a transient bridge
// error. Swallow — the proxy is an opt-in connectivity aid, not a hard
// boot dependency. The UI shows the fallback state (off / unsupported).
// eslint-disable-next-line no-console
console.warn('[proxy] native call failed:', err);
return fallback;
}
};
export const proxy = {
// setConfig is the ONE call that must NOT swallow failures: it persists the
// user's creds, and a silent reject would let the form report success, wipe
// the typed password, and leave nothing stored (F11). Let it reject so the
// form can show an error and keep the password. No-op (resolve) off Android.
setConfig: (opts: {
type: ProxyType;
host: string;
port: number;
username?: string;
password?: string;
}): Promise<void> => {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.setConfig(opts);
},
clearConfig: () => guard(() => plugin.clearConfig(), undefined),
setEnabled: (opts: { enabled: boolean; homeserverHost?: string; routeAll?: boolean }) =>
guard(() => plugin.setEnabled(opts), DEFAULT_STATE),
getState: () => guard(() => plugin.getState(), DEFAULT_STATE),
applyAtBoot: (homeserverHost?: string) =>
guard(() => plugin.applyAtBoot({ homeserverHost }), DEFAULT_STATE),
addListener: (listener: (event: ProxyStatusEvent) => void): Promise<PluginListenerHandle> => {
if (!isAndroidPlatform()) {
return Promise.resolve({ remove: async () => undefined });
}
return plugin.addListener('proxyStatus', listener);
},
};

14
src/app/state/proxy.ts Normal file
View file

@ -0,0 +1,14 @@
import { atom } from 'jotai';
import type { ProxyState } from '../plugins/proxy';
// Non-secret UI mirror of the native proxy state. The password is NEVER
// stored here (or anywhere in JS / localStorage) — only the native
// Tink-encrypted store holds it. This atom is in-memory only and is
// re-hydrated from the native plugin on mount (see hooks/useProxy.ts).
export const proxyStateAtom = atom<ProxyState>({
configured: false,
enabled: false,
status: 'off',
supported: false,
reverseBypassSupported: false,
});

View file

@ -7,6 +7,7 @@ import { clearPusherIds, loadPusherIds, setPushEnabled, unregisterPusher } from
import { isNativePlatform } from '../app/utils/capacitor';
import { clearSessionBridge } from '../app/utils/sessionBridge';
import { polling } from '../app/plugins/polling';
import { proxy } from '../app/plugins/proxy';
type Session = {
baseUrl: string;
@ -112,6 +113,12 @@ export const logoutClient = async (mx: MatrixClient) => {
await polling.cancel();
await polling.clearSession();
// Wipe the Tink-encrypted proxy creds + clear the WebView override + tear
// down the relay (proxy_support.md §15: creds are wiped on logout, locked).
// A blocked re-login re-enters the proxy on the auth screen (pre-login
// entry, F1). No-op on web.
await proxy.clearConfig();
// Wipe the native session bridge so a re-login with a different user
// can't resurrect the old access_token via CallDeclineReceiver.
await clearSessionBridge();
@ -166,6 +173,9 @@ export const clearLoginData = async () => {
// will time out the session naturally.
export const clearLocalSessionAndReload = async () => {
await clearSessionBridge();
// Wipe the Tink-encrypted proxy creds + clear the override + tear down the
// relay on this local-only logout too (proxy_support.md §15, locked).
await proxy.clearConfig();
// Same reasoning as the normal logoutClient: kill the WorkManager
// polling fallback before the reload so it can't fire one more time
// with the old access_token and surface notifications from the

View file

@ -21,6 +21,7 @@ import { pushSessionToSW } from './sw-session';
import { getFallbackSession } from './app/state/sessions';
import { setupExternalLinkHandler } from './app/utils/capacitor';
import { setupExternalLinkHandler as setupElectronExternalLinkHandler } from './app/utils/electron';
import { proxy } from './app/plugins/proxy';
document.body.classList.add(configClass, varsClass);
setupExternalLinkHandler();
@ -100,4 +101,26 @@ const mountApp = () => {
root.render(<App />);
};
mountApp();
// Apply a stored (enabled) Android proxy override BEFORE React boots, so the
// very first homeserver fetch (auto-discovery / login / sync) already rides
// the proxy on a blocked network (proxy_android_impl.md §2, F1). On a
// logged-in boot we narrow the override to the homeserver host immediately;
// pre-login it routes everything until the host is resolved. No-op on web /
// Electron, and time-bounded so a misbehaving bridge can never hang boot.
const applyProxyAtBoot = async (): Promise<void> => {
let host: string | undefined;
try {
const session = getFallbackSession();
host = session?.baseUrl ? new URL(session.baseUrl).hostname : undefined;
} catch {
host = undefined;
}
await Promise.race([
proxy.applyAtBoot(host),
new Promise<void>((resolve) => {
setTimeout(resolve, 2500);
}),
]).catch(() => undefined);
};
applyProxyAtBoot().finally(mountApp);