diff --git a/android/app/build.gradle b/android/app/build.gradle
index 05a9e916..fbbd4f3e 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -16,6 +16,13 @@ android {
versionCode 1
versionName "2.8.9"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ // The arti-mobile AAR bundles large native Rust libraries for every
+ // ABI (~45 MB total). Restrict to the ABIs we actually ship/test:
+ // arm64-v8a + armeabi-v7a (real devices) and x86_64 (emulators).
+ // Drop x86_64 here if you only ever test on physical devices.
+ ndk {
+ abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
+ }
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
@@ -56,6 +63,13 @@ dependencies {
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
+ // Tor in Rust (arti) — prebuilt AAR from Guardian Project's gpmaven repo.
+ // Provides org.torproject.arti.ArtiProxy used by TorController.
+ implementation 'org.torproject:arti-mobile:1.7.0.1'
+ // arti pulls androidx.webkit in transitively but only at runtime; we
+ // compile against ProxyController/WebViewFeature in TorController, so
+ // declare it explicitly on the app's compile classpath.
+ implementation "androidx.webkit:webkit:$androidxWebkitVersion"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index ac1720e0..b9bbf2f0 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -27,6 +27,10 @@
-keepattributes *Annotation*
-keep class com.outsystems.plugins.barcode.** { *; }
+# Keep arti (Tor) classes — ArtiJNI declares native methods invoked from the
+# Rust .so via JNI, so its names must not be obfuscated/stripped.
+-keep class org.torproject.arti.** { *; }
+
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
diff --git a/android/app/src/main/java/spot/agora/app/MainActivity.java b/android/app/src/main/java/spot/agora/app/MainActivity.java
index bd6678a8..8af7135d 100644
--- a/android/app/src/main/java/spot/agora/app/MainActivity.java
+++ b/android/app/src/main/java/spot/agora/app/MainActivity.java
@@ -25,6 +25,14 @@ public class MainActivity extends BridgeActivity {
protected void onCreate(Bundle savedInstanceState) {
// Register native plugins before super.onCreate.
registerPlugin(DittoNotificationPlugin.class);
+ registerPlugin(TorPlugin.class);
+
+ // If the user enabled Tor (apply on relaunch), start arti BEFORE
+ // super.onCreate so the WebView SOCKS proxy override is installed
+ // before the WebView issues any network request — no leak window.
+ if (TorController.isEnabled(this)) {
+ TorController.getInstance().start(getApplicationContext());
+ }
super.onCreate(savedInstanceState);
diff --git a/android/app/src/main/java/spot/agora/app/TorController.java b/android/app/src/main/java/spot/agora/app/TorController.java
new file mode 100644
index 00000000..e5f4173d
--- /dev/null
+++ b/android/app/src/main/java/spot/agora/app/TorController.java
@@ -0,0 +1,375 @@
+package spot.agora.app;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.webkit.ProxyConfig;
+import androidx.webkit.ProxyController;
+import androidx.webkit.WebViewFeature;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.torproject.arti.ArtiProxy;
+
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+/**
+ * Process-wide controller for the optional Tor (arti) mode on Android.
+ *
+ *
When enabled, this starts a local SOCKS5 proxy backed by arti (Tor in
+ * Rust) and — via {@link ArtiProxy.ArtiProxyBuilder#setWrapWebView(boolean)} —
+ * installs an Android {@code ProxyController} override so that all
+ * Capacitor WebView traffic (every {@code fetch} and relay {@code WebSocket})
+ * is routed through Tor. No changes to the TypeScript HTTP layer are needed.
+ *
+ *
Activation is "apply on relaunch": the enabled flag is persisted to
+ * {@link SharedPreferences} by {@link TorPlugin} and read here at startup from
+ * {@link MainActivity}. arti is started before the WebView loads so
+ * there is no pre-bootstrap leak window.
+ *
+ *
Pluggable transports (obfs4 via IPtProxy) are intentionally not wired up
+ * yet — the builder already exposes {@code setObfs4Port}/{@code setBridgeLines}
+ * for a future censorship-resistance layer.
+ */
+public class TorController {
+
+ private static final String TAG = "TorController";
+
+ /** Local SOCKS5 port arti listens on (arti's own default). */
+ public static final int SOCKS_PORT = 9150;
+
+ static final String PREFS_NAME = "tor_config";
+ static final String KEY_ENABLED = "enabled";
+
+ /** Endpoint used to confirm a working Tor circuit (small JSON response). */
+ private static final String PROBE_URL = "https://check.torproject.org/api/ip";
+ // Re-verify continuously (gently) so the status reflects current reality.
+ private static final long PROBE_INTERVAL_SECONDS = 10;
+ /** After this long without a successful probe, surface a soft "failed". */
+ private static final long SOFT_TIMEOUT_SECONDS = 120;
+
+ // Status values mirrored to JS (see src/lib/tor.ts TorStatus).
+ public static final String STATUS_DISABLED = "disabled";
+ public static final String STATUS_CONNECTING = "connecting";
+ public static final String STATUS_CONNECTED = "connected";
+ public static final String STATUS_FAILED = "failed";
+
+ /** Receives status changes so the Capacitor plugin can forward them to JS. */
+ public interface StatusListener {
+ void onTorStatus(String status, int bootstrapPercent, @Nullable String error, @Nullable String exitIp);
+ }
+
+ private static volatile TorController instance;
+
+ private final Object lock = new Object();
+ private final AtomicBoolean started = new AtomicBoolean(false);
+
+ private ArtiProxy artiProxy;
+ private ScheduledExecutorService scheduler;
+
+ private volatile String status = STATUS_DISABLED;
+ private volatile int bootstrapPercent = 0;
+ @Nullable private volatile String error = null;
+ /** Tor exit-node IP from the last successful check (for verification UI). */
+ @Nullable private volatile String exitIp = null;
+ /** Consecutive failed probes; used to debounce CONNECTED -> reconnecting. */
+ private int consecutiveFailures = 0;
+ @Nullable private volatile StatusListener listener;
+ private volatile long startedAtMs = 0;
+
+ private TorController() {}
+
+ public static TorController getInstance() {
+ if (instance == null) {
+ synchronized (TorController.class) {
+ if (instance == null) {
+ instance = new TorController();
+ }
+ }
+ }
+ return instance;
+ }
+
+ /** Whether Tor is enabled in persisted preferences (read at next launch). */
+ public static boolean isEnabled(Context context) {
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ return prefs.getBoolean(KEY_ENABLED, false);
+ }
+
+ /** Persist the enabled flag. Takes effect on the next app launch. */
+ public static void setEnabled(Context context, boolean enabled) {
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putBoolean(KEY_ENABLED, enabled)
+ .apply();
+ }
+
+ public void setListener(@Nullable StatusListener listener) {
+ this.listener = listener;
+ // Replay the current status so a freshly-attached listener is in sync.
+ if (listener != null) {
+ listener.onTorStatus(status, bootstrapPercent, error, exitIp);
+ }
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public int getBootstrapPercent() {
+ return bootstrapPercent;
+ }
+
+ @Nullable
+ public String getError() {
+ return error;
+ }
+
+ @Nullable
+ public String getExitIp() {
+ return exitIp;
+ }
+
+ /**
+ * Start arti and install the WebView proxy override. Idempotent: a second
+ * call while already running is a no-op. Heavy work runs off the caller's
+ * thread so this is safe to invoke from {@code MainActivity.onCreate}.
+ */
+ public void start(Context context) {
+ if (!started.compareAndSet(false, true)) {
+ return;
+ }
+ final Context appContext = context.getApplicationContext();
+ exitIp = null;
+ consecutiveFailures = 0;
+ // Install the fail-closed WebView proxy override synchronously, BEFORE
+ // the WebView loads (start() is called from MainActivity.onCreate ahead
+ // of super.onCreate). With no direct fallback, any request that arti
+ // can't carry fails instead of leaking out directly — even during the
+ // bootstrap window when arti isn't connected yet.
+ applyWebViewProxy();
+ updateStatus(STATUS_CONNECTING, 0, null);
+ startedAtMs = System.currentTimeMillis();
+
+ Thread t = new Thread(() -> {
+ try {
+ synchronized (lock) {
+ // NB: we do NOT use setWrapWebView(true) — arti's helper
+ // appends a DIRECT fallback (fail-open). We set our own
+ // fail-closed override in applyWebViewProxy() instead.
+ artiProxy = ArtiProxy.Builder(appContext)
+ .setSocksPort(SOCKS_PORT)
+ .setLogListener(this::onArtiLog)
+ .build();
+ artiProxy.start();
+ }
+ Log.d(TAG, "arti started on socks://127.0.0.1:" + SOCKS_PORT);
+ beginConnectivityProbe();
+ } catch (Throwable e) {
+ Log.e(TAG, "Failed to start arti", e);
+ updateStatus(STATUS_FAILED, bootstrapPercent, String.valueOf(e.getMessage()));
+ }
+ }, "arti-start");
+ t.setDaemon(true);
+ t.start();
+ }
+
+ /** Stop arti and route the WebView back to a direct connection. Safe to
+ * call live (toggle off) — clears the SOCKS proxy override so traffic
+ * doesn't get stranded on the now-stopped proxy. */
+ public void stop() {
+ // Remove the WebView SOCKS override first so new requests go direct.
+ clearWebViewProxy();
+ synchronized (lock) {
+ if (scheduler != null) {
+ scheduler.shutdownNow();
+ scheduler = null;
+ }
+ if (artiProxy != null) {
+ try {
+ artiProxy.stop();
+ } catch (Throwable e) {
+ Log.w(TAG, "Error stopping arti", e);
+ }
+ artiProxy = null;
+ }
+ }
+ started.set(false);
+ exitIp = null;
+ updateStatus(STATUS_DISABLED, 0, null);
+ }
+
+ /** Re-run the connectivity probe (used by a "Retry" action in the gate). */
+ public void retry() {
+ if (!started.get()) {
+ return;
+ }
+ consecutiveFailures = 0;
+ startedAtMs = System.currentTimeMillis();
+ if (!STATUS_CONNECTED.equals(status)) {
+ updateStatus(STATUS_CONNECTING, bootstrapPercent, null);
+ }
+ beginConnectivityProbe();
+ }
+
+ // --- internals -------------------------------------------------------
+
+ /**
+ * Route the WebView through arti's SOCKS proxy, FAIL-CLOSED. There is no
+ * {@code addDirect()} fallback, so when Tor can't carry a request it fails
+ * rather than leaking to a direct connection. localhost is bypassed (it's
+ * the local Capacitor asset server, never remote traffic).
+ */
+ private void applyWebViewProxy() {
+ try {
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
+ ProxyConfig config = new ProxyConfig.Builder()
+ .addProxyRule("socks://127.0.0.1:" + SOCKS_PORT)
+ // No addDirect() — fail closed.
+ .addBypassRule("localhost")
+ .addBypassRule("127.0.0.1")
+ .build();
+ ProxyController.getInstance().setProxyOverride(config, Runnable::run, () -> {});
+ }
+ } catch (Throwable e) {
+ Log.w(TAG, "Error applying WebView proxy override", e);
+ }
+ }
+
+ /** Remove the app-wide WebView SOCKS proxy override so the WebView reverts
+ * to a direct connection. */
+ private void clearWebViewProxy() {
+ try {
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
+ ProxyController.getInstance().clearProxyOverride(Runnable::run, () -> {});
+ }
+ } catch (Throwable e) {
+ Log.w(TAG, "Error clearing WebView proxy override", e);
+ }
+ }
+
+ private static final Pattern PERCENT = Pattern.compile("(\\d{1,3})\\s*%");
+
+ private void onArtiLog(String line) {
+ if (line == null) return;
+ Log.d("artilog", line);
+ // Best-effort bootstrap progress for the UI. arti's log format isn't a
+ // stable API, so the connectivity probe (below) remains authoritative
+ // for the definitive "connected" signal.
+ Matcher m = PERCENT.matcher(line);
+ if (m.find()) {
+ try {
+ int pct = Integer.parseInt(m.group(1));
+ if (pct >= 0 && pct <= 100 && pct >= bootstrapPercent
+ && !STATUS_CONNECTED.equals(status)) {
+ updateStatus(STATUS_CONNECTING, pct, null);
+ }
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ }
+
+ private void beginConnectivityProbe() {
+ synchronized (lock) {
+ if (scheduler != null) {
+ scheduler.shutdownNow();
+ }
+ scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread th = new Thread(r, "tor-probe");
+ th.setDaemon(true);
+ return th;
+ });
+ final ScheduledExecutorService s = scheduler;
+ final OkHttpClient client = new OkHttpClient.Builder()
+ .proxy(new Proxy(Proxy.Type.SOCKS, new InetSocketAddress("127.0.0.1", SOCKS_PORT)))
+ .connectTimeout(20, TimeUnit.SECONDS)
+ .readTimeout(20, TimeUnit.SECONDS)
+ .build();
+
+ // Probe continuously (no shutdown on success). check.torproject.org
+ // reports whether the request actually exited via Tor, so we only
+ // report CONNECTED when IsTor is true — and we keep re-verifying so a
+ // dropped circuit downgrades the status instead of lying.
+ s.scheduleWithFixedDelay(() -> {
+ Request req = new Request.Builder()
+ .url(PROBE_URL)
+ .header("Accept", "application/json")
+ .build();
+ try (Response resp = client.newCall(req).execute()) {
+ String body = resp.body() != null ? resp.body().string() : "";
+ boolean isTor = false;
+ String ip = null;
+ try {
+ JSONObject json = new JSONObject(body);
+ isTor = json.optBoolean("IsTor", false);
+ ip = json.has("IP") ? json.optString("IP", null) : null;
+ } catch (JSONException ignored) {
+ // Non-JSON response — treat as not-via-Tor below.
+ }
+
+ if (resp.isSuccessful() && isTor) {
+ consecutiveFailures = 0;
+ exitIp = ip;
+ updateStatus(STATUS_CONNECTED, 100, null);
+ } else if (resp.isSuccessful()) {
+ // Reached the internet but NOT through Tor — a leak/bypass.
+ // This should not happen with the SOCKS proxy, but report
+ // it honestly rather than claiming a Tor connection.
+ consecutiveFailures = 0;
+ exitIp = ip;
+ updateStatus(STATUS_FAILED, bootstrapPercent,
+ "Connected to the internet, but not through Tor.");
+ } else {
+ handleProbeFailure();
+ }
+ } catch (Exception e) {
+ handleProbeFailure();
+ }
+ }, 0, PROBE_INTERVAL_SECONDS, TimeUnit.SECONDS);
+ }
+ }
+
+ /** A probe couldn't reach Tor. Debounce CONNECTED, surface FAILED after the
+ * soft timeout while still connecting. */
+ private void handleProbeFailure() {
+ consecutiveFailures++;
+ if (STATUS_CONNECTED.equals(status)) {
+ // Tolerate a couple of transient blips before downgrading.
+ if (consecutiveFailures >= 3) {
+ exitIp = null;
+ updateStatus(STATUS_CONNECTING, bootstrapPercent,
+ "Lost the Tor circuit; reconnecting…");
+ }
+ return;
+ }
+ long elapsed = (System.currentTimeMillis() - startedAtMs) / 1000;
+ if (elapsed >= SOFT_TIMEOUT_SECONDS && !STATUS_FAILED.equals(status)) {
+ updateStatus(STATUS_FAILED, bootstrapPercent,
+ "Couldn't reach the Tor network. Your network may be blocking Tor.");
+ }
+ }
+
+ private void updateStatus(String newStatus, int percent, @Nullable String err) {
+ this.status = newStatus;
+ this.bootstrapPercent = percent;
+ this.error = err;
+ StatusListener l = this.listener;
+ if (l != null) {
+ l.onTorStatus(newStatus, percent, err, exitIp);
+ }
+ }
+}
diff --git a/android/app/src/main/java/spot/agora/app/TorPlugin.java b/android/app/src/main/java/spot/agora/app/TorPlugin.java
new file mode 100644
index 00000000..124b3d2d
--- /dev/null
+++ b/android/app/src/main/java/spot/agora/app/TorPlugin.java
@@ -0,0 +1,95 @@
+package spot.agora.app;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.annotation.CapacitorPlugin;
+
+/**
+ * Capacitor bridge for the Tor (arti) mode.
+ *
+ *
Mirrors {@link DittoNotificationPlugin}'s pattern: JS configures native
+ * state, native owns the work. The enabled flag is persisted only — arti is
+ * actually started at launch from {@link MainActivity} (apply on relaunch).
+ * Live bootstrap status is pushed to JS via the {@code torStatus} event.
+ *
+ *
JS interface: see {@code src/lib/tor.ts}.
+ */
+@CapacitorPlugin(name = "Tor")
+public class TorPlugin extends Plugin {
+
+ private static final String EVENT_STATUS = "torStatus";
+
+ @Override
+ public void load() {
+ // Forward native status changes to JS listeners. Attaching also replays
+ // the current status, keeping a newly-mounted JS gate in sync.
+ TorController.getInstance().setListener((status, bootstrapPercent, error, exitIp) -> {
+ JSObject data = new JSObject();
+ data.put("status", status);
+ data.put("bootstrapPercent", bootstrapPercent);
+ data.put("error", error);
+ data.put("exitIp", exitIp);
+ notifyListeners(EVENT_STATUS, data);
+ });
+ }
+
+ /** Whether Tor is enabled in persisted preferences. */
+ @PluginMethod
+ public void isEnabled(PluginCall call) {
+ JSObject ret = new JSObject();
+ ret.put("enabled", TorController.isEnabled(getContext()));
+ call.resolve(ret);
+ }
+
+ /** Persist the enabled flag. Applied on the next app launch. */
+ @PluginMethod
+ public void setEnabled(PluginCall call) {
+ Boolean enabled = call.getBoolean("enabled");
+ if (enabled == null) {
+ call.reject("Missing 'enabled' boolean");
+ return;
+ }
+ TorController.setEnabled(getContext(), enabled);
+ call.resolve();
+ }
+
+ /** Start arti now (live activation). Also persists enabled=true so it
+ * auto-starts on the next cold launch. */
+ @PluginMethod
+ public void start(PluginCall call) {
+ TorController.setEnabled(getContext(), true);
+ TorController.getInstance().start(getContext());
+ call.resolve();
+ }
+
+ /** Stop arti now (live deactivation) and clear the WebView proxy. Also
+ * persists enabled=false. */
+ @PluginMethod
+ public void stop(PluginCall call) {
+ TorController.setEnabled(getContext(), false);
+ TorController.getInstance().stop();
+ call.resolve();
+ }
+
+ /** Current connection status (synchronous snapshot). */
+ @PluginMethod
+ public void getStatus(PluginCall call) {
+ TorController controller = TorController.getInstance();
+ JSObject ret = new JSObject();
+ ret.put("enabled", TorController.isEnabled(getContext()));
+ ret.put("status", controller.getStatus());
+ ret.put("bootstrapPercent", controller.getBootstrapPercent());
+ ret.put("error", controller.getError());
+ ret.put("exitIp", controller.getExitIp());
+ call.resolve(ret);
+ }
+
+ /** Re-run the connectivity probe (for a "Retry" action in the gate). */
+ @PluginMethod
+ public void retry(PluginCall call) {
+ TorController.getInstance().retry();
+ call.resolve();
+ }
+}
diff --git a/android/build.gradle b/android/build.gradle
index f8f0e43b..ef049aaf 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -21,6 +21,9 @@ allprojects {
repositories {
google()
mavenCentral()
+ // Guardian Project's experimental Maven repo, hosting the prebuilt
+ // org.torproject:arti-mobile AAR (Tor in Rust) used for the optional Tor mode.
+ maven { url "https://raw.githubusercontent.com/guardianproject/gpmaven/master" }
}
}
diff --git a/src/App.tsx b/src/App.tsx
index f3fa84c3..19c6a548 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -15,7 +15,9 @@ import { SentryProvider } from "@/components/SentryProvider";
import { TooltipProvider } from "@/components/ui/tooltip";
+import { useAppContext } from "@/hooks/useAppContext";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
+import { useTor } from "@/hooks/useTor";
import type { AppConfig } from "@/contexts/AppContext";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { NWCProvider } from "@/contexts/NWCContext";
@@ -147,6 +149,7 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
imageProxy: 'https://wsrv.nl',
lowBandwidthMode: false,
+ torEnabled: false,
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
esploraApis: [
'https://mempool.emzy.de/api',
@@ -194,6 +197,24 @@ const defaultConfig: AppConfig = {
feedSettings: { ...hardcodedConfig.feedSettings, ...buildConfig.feedSettings },
};
+/**
+ * Wraps NostrProvider with a key that changes when Tor routing changes, so the
+ * relay layer remounts: existing connections close and reopen under the new
+ * routing (direct ⇄ fail-closed Tor), and reconnect immediately once Tor is up
+ * rather than waiting out the relay reconnect backoff. No-op off Android (the
+ * key is always "direct").
+ */
+function RelayProvider({ children }: { children: React.ReactNode }) {
+ const { config } = useAppContext();
+ const { status } = useTor();
+ const key = !config.torEnabled
+ ? "direct"
+ : status === "connected"
+ ? "tor-connected"
+ : "tor-pending";
+ return {children};
+}
+
export function App() {
useNsecPasteGuard();
@@ -205,7 +226,7 @@ export function App() {
-
+
@@ -221,7 +242,7 @@ export function App() {
-
+
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index d80627f4..ffa5ca27 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -9,6 +9,7 @@ import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { MinimizedAudioBar } from "./components/MinimizedAudioBar";
import { AudioNavigationGuard } from "./components/AudioNavigationGuard";
+import { TorStatusBanner } from "./components/TorStatusBanner";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { cn } from "@/lib/utils";
@@ -139,6 +140,9 @@ export function AppRouter() {
+ {/* App-wide Tor status banner. Must live inside BrowserRouter — it
+ renders a to the Tor settings, which needs Router context. */}
+
{/* Narrow layout — `max-w-3xl` center column. The default for
diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx
index 95debd90..bbb13914 100644
--- a/src/components/TopNav.tsx
+++ b/src/components/TopNav.tsx
@@ -297,7 +297,15 @@ function getProfileMenuItems({
userPubkey?: string;
showDashboard: boolean;
}): MobileLinkItem[] {
- if (!userPubkey) return [];
+ // Logged-out users still get Settings (appearance, language, network, etc.)
+ // and About in the menu — the account-specific items are added below.
+ if (!userPubkey) {
+ return [
+ { labelKey: 'nav.search', to: '/search', icon: Search },
+ { labelKey: 'nav.settings', to: '/settings', icon: Settings },
+ { labelKey: 'nav.about', to: '/about', icon: Info },
+ ];
+ }
return [
...(showDashboard ? [{ labelKey: 'nav.dashboard', to: '/dashboard', icon: Activity }] : []),
diff --git a/src/components/TorStatusBanner.tsx b/src/components/TorStatusBanner.tsx
new file mode 100644
index 00000000..12d82a6f
--- /dev/null
+++ b/src/components/TorStatusBanner.tsx
@@ -0,0 +1,63 @@
+import { useTranslation } from 'react-i18next';
+import { Loader2, ShieldAlert } from 'lucide-react';
+import { useAppContext } from '@/hooks/useAppContext';
+import { useTor } from '@/hooks/useTor';
+import { retryTor } from '@/lib/tor';
+
+/**
+ * Slim, non-blocking, app-wide banner shown while Tor is enabled but not yet
+ * connected (Android only).
+ *
+ * Routing is fail-closed, so external content can't load — and can't leak —
+ * until Tor connects. This tells the user that wherever they are in the app
+ * (so switching away from Settings still surfaces the state). It replaces the
+ * old full-screen gate.
+ */
+export function TorStatusBanner() {
+ const { config } = useAppContext();
+ const { supported, status, bootstrapPercent, error } = useTor();
+ const { t } = useTranslation();
+
+ if (!supported || !config.torEnabled || status === 'connected') {
+ return null;
+ }
+
+ const failed = status === 'failed';
+
+ return (
+
+ );
+}
+
+export default TorStatusBanner;
diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts
index 4b241c29..97d5f90e 100644
--- a/src/contexts/AppContext.ts
+++ b/src/contexts/AppContext.ts
@@ -288,6 +288,15 @@ export interface AppConfig {
* Default: false.
*/
lowBandwidthMode: boolean;
+ /**
+ * Route all app traffic through the Tor network (arti). **Android only** —
+ * ignored on web and iOS. The actual proxy is installed natively at app
+ * startup, so changes take effect on the next launch (see `src/lib/tor.ts`
+ * and the native `TorController`).
+ *
+ * Default: false.
+ */
+ torEnabled: boolean;
/** Hex pubkey of the curator whose follow list defines the curated feed. */
curatorPubkey?: string;
/**
diff --git a/src/hooks/useTor.ts b/src/hooks/useTor.ts
new file mode 100644
index 00000000..de4e837b
--- /dev/null
+++ b/src/hooks/useTor.ts
@@ -0,0 +1,91 @@
+import { useEffect, useState } from 'react';
+import type { PluginListenerHandle } from '@capacitor/core';
+import {
+ Tor,
+ isTorSupported,
+ getTorStatus,
+ startTor,
+ stopTor,
+ type TorStatus,
+} from '@/lib/tor';
+
+export interface UseTor {
+ /** Whether Tor mode is available on this platform (Android only). */
+ supported: boolean;
+ /** Whether Tor is enabled in native preferences. */
+ enabled: boolean;
+ /** False until the first native status has been read (used to avoid a UI flash). */
+ loaded: boolean;
+ status: TorStatus;
+ bootstrapPercent: number;
+ error: string | null;
+ /** Tor exit-node IP from the last successful check, when connected. */
+ exitIp: string | null;
+ /** Persist the enabled flag natively. Takes effect on next app launch. */
+ setEnabled: (enabled: boolean) => Promise;
+}
+
+/**
+ * Subscribes to native Tor (arti) status and exposes the enable toggle.
+ * Safe to call on any platform — it simply reports `supported: false` and a
+ * `disabled` status off Android.
+ */
+export function useTor(): UseTor {
+ const supported = isTorSupported();
+ const [enabled, setEnabledState] = useState(false);
+ const [loaded, setLoaded] = useState(!isTorSupported());
+ const [status, setStatus] = useState('disabled');
+ const [bootstrapPercent, setBootstrapPercent] = useState(0);
+ const [error, setError] = useState(null);
+ const [exitIp, setExitIp] = useState(null);
+
+ useEffect(() => {
+ if (!supported) return;
+
+ let active = true;
+ let handle: PluginListenerHandle | undefined;
+
+ getTorStatus().then((s) => {
+ if (!active) return;
+ if (s) {
+ setEnabledState(s.enabled);
+ setStatus(s.status);
+ setBootstrapPercent(s.bootstrapPercent);
+ setError(s.error);
+ setExitIp(s.exitIp ?? null);
+ }
+ setLoaded(true);
+ });
+
+ Tor.addListener('torStatus', (e) => {
+ setStatus(e.status);
+ setBootstrapPercent(e.bootstrapPercent);
+ setError(e.error);
+ setExitIp(e.exitIp ?? null);
+ }).then((h) => {
+ if (active) {
+ handle = h;
+ } else {
+ h.remove();
+ }
+ });
+
+ return () => {
+ active = false;
+ handle?.remove();
+ };
+ }, [supported]);
+
+ // Live activation: starting/stopping arti also persists the enabled flag
+ // natively, so it auto-starts again on the next cold launch.
+ const setEnabled = async (next: boolean) => {
+ if (next) {
+ await startTor();
+ } else {
+ await stopTor();
+ }
+ setEnabledState(next);
+ };
+
+ return { supported, enabled, loaded, status, bootstrapPercent, error, exitIp, setEnabled };
+}
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index 7d7c705a..855e6cd8 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -150,6 +150,7 @@ export const AppConfigSchema = z.object({
imageQuality: z.enum(['compressed', 'original']),
imageProxy: z.string(),
lowBandwidthMode: z.boolean(),
+ torEnabled: z.boolean(),
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
/**
* Ordered list of Esplora REST roots tried in failover order. Accepts the
diff --git a/src/lib/tor.ts b/src/lib/tor.ts
new file mode 100644
index 00000000..f2198f8f
--- /dev/null
+++ b/src/lib/tor.ts
@@ -0,0 +1,95 @@
+import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
+
+/** Native Tor connection status (mirrors the values in TorController.java). */
+export type TorStatus = 'disabled' | 'connecting' | 'connected' | 'failed';
+
+export interface TorStatusEvent {
+ status: TorStatus;
+ /** Best-effort bootstrap progress (0–100) parsed from arti logs. */
+ bootstrapPercent: number;
+ error: string | null;
+ /** Tor exit-node IP from the last successful check (verification readout). */
+ exitIp?: string | null;
+}
+
+export interface TorPlugin {
+ /** Whether Tor is enabled in persisted native preferences. */
+ isEnabled(): Promise<{ enabled: boolean }>;
+ /** Persist the enabled flag. Applied on the next app launch. */
+ setEnabled(options: { enabled: boolean }): Promise;
+ /** Start arti now (live) and persist enabled=true. */
+ start(): Promise;
+ /** Stop arti now (live), clear the WebView proxy, and persist enabled=false. */
+ stop(): Promise;
+ /** Synchronous snapshot of the current status. */
+ getStatus(): Promise<{ enabled: boolean } & TorStatusEvent>;
+ /** Re-run the connectivity probe (used by the gate's "Retry"). */
+ retry(): Promise;
+ addListener(
+ eventName: 'torStatus',
+ listener: (event: TorStatusEvent) => void,
+ ): Promise;
+}
+
+/**
+ * The native Tor (arti) plugin. Implemented on Android only — see
+ * `android/app/src/main/java/spot/agora/app/TorPlugin.java`. On web/iOS the
+ * registered proxy exists but its methods reject; always guard with
+ * {@link isTorSupported} (the helpers below do).
+ */
+export const Tor = registerPlugin('Tor');
+
+/** Tor mode is only available on Android. */
+export function isTorSupported(): boolean {
+ return Capacitor.getPlatform() === 'android';
+}
+
+/** Persist the Tor enabled flag natively (no-op on unsupported platforms). */
+export async function setTorEnabled(enabled: boolean): Promise {
+ if (!isTorSupported()) return;
+ try {
+ await Tor.setEnabled({ enabled });
+ } catch {
+ // Native plugin unavailable (e.g. older build) — ignore.
+ }
+}
+
+/** Read the current native status, or `null` when Tor isn't supported. */
+export async function getTorStatus(): Promise<({ enabled: boolean } & TorStatusEvent) | null> {
+ if (!isTorSupported()) return null;
+ try {
+ return await Tor.getStatus();
+ } catch {
+ return null;
+ }
+}
+
+/** Ask arti to re-check connectivity (no-op on unsupported platforms). */
+export async function retryTor(): Promise {
+ if (!isTorSupported()) return;
+ try {
+ await Tor.retry();
+ } catch {
+ // ignore
+ }
+}
+
+/** Start arti now (live activation). No-op on unsupported platforms. */
+export async function startTor(): Promise {
+ if (!isTorSupported()) return;
+ try {
+ await Tor.start();
+ } catch {
+ // ignore
+ }
+}
+
+/** Stop arti now (live deactivation). No-op on unsupported platforms. */
+export async function stopTor(): Promise {
+ if (!isTorSupported()) return;
+ try {
+ await Tor.stop();
+ } catch {
+ // ignore
+ }
+}
diff --git a/src/locales/ar.json b/src/locales/ar.json
index 42efdd9e..fcb225cf 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "متوقف",
+ "connecting": "جارٍ الاتصال…",
+ "connected": "متصل",
+ "failed": "فشل الاتصال"
+ },
+ "banner": {
+ "connecting": "جارٍ الاتصال بـ Tor — لن يتم تحميل المحتوى حتى يصبح جاهزاً.",
+ "failed": "تعذّر الاتصال بـ Tor — المحتوى محجوب حتى يتم الاتصال.",
+ "retry": "حاول مرة أخرى"
+ }
+ },
"common": {
"loading": "جارٍ التحميل…",
"error": "خطأ",
@@ -972,7 +985,14 @@
"title": "متقدم",
"subtitle": "اتصالات المحفظة وتكوين النظام وخيارات أخرى للمستخدمين المتقدمين.",
"intro": "اتصالات المحفظة وتكوين النظام وخيارات متقدمة أخرى.",
- "wallet": "المحفظة"
+ "wallet": "المحفظة",
+ "torHeading": "Tor",
+ "torToggle": "توجيه حركة المرور عبر Tor",
+ "torToggleDesc": "يتصل بالمرحّلات ويحمّل الوسائط عبر شبكة Tor لحماية أقوى للخصوصية. توقّع أداءً أبطأ وتأخيراً قصيراً ريثما يتصل Tor.",
+ "torApplyNote": "يؤدي تفعيل هذا الخيار إلى توجيه كل حركة المرور عبر Tor. أثناء الاتصال، لن يتم تحميل المحتوى الآخر (يُعرض بانر يوضّح الحالة) — ولا يُرسَل أي شيء خارج Tor. يؤدي إيقافه إلى العودة إلى الاتصال المباشر.",
+ "torStatusLabel": "الحالة:",
+ "torExitIp": "عنوان IP للخروج من Tor:",
+ "torCheckAgain": "التحقق مجدداً"
},
"wallet": {
"title": "المحفظة",
@@ -2902,4 +2922,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/en.json b/src/locales/en.json
index 24bd39c6..bb289f4a 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "Off",
+ "connecting": "Connecting…",
+ "connected": "Connected",
+ "failed": "Connection failed"
+ },
+ "banner": {
+ "connecting": "Connecting to Tor — content won't load until it's ready.",
+ "failed": "Couldn't connect to Tor — content is blocked until it connects.",
+ "retry": "Try again"
+ }
+ },
"common": {
"loading": "Loading…",
"error": "Error",
@@ -1435,7 +1448,14 @@
"title": "Advanced",
"subtitle": "Wallet connections, system configuration, and other advanced options for power users.",
"intro": "Wallet connections, system configuration, and other advanced options.",
- "wallet": "Wallet"
+ "wallet": "Wallet",
+ "torHeading": "Tor",
+ "torToggle": "Route traffic through Tor",
+ "torToggleDesc": "Connects to relays and loads media over the Tor network for stronger privacy. Expect slower performance and a short delay while Tor connects.",
+ "torApplyNote": "Turning this on routes all traffic through Tor. While it connects, other content won't load (a banner shows the status) — nothing is sent outside Tor. Turning it off switches back to a direct connection.",
+ "torStatusLabel": "Status:",
+ "torExitIp": "Tor exit IP:",
+ "torCheckAgain": "Check again"
},
"wallet": {
"title": "Wallet",
diff --git a/src/locales/es.json b/src/locales/es.json
index 461a9fa7..1b1e05eb 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "Desactivado",
+ "connecting": "Conectando…",
+ "connected": "Conectado",
+ "failed": "Error de conexión"
+ },
+ "banner": {
+ "connecting": "Conectando a Tor — el contenido no cargará hasta que esté listo.",
+ "failed": "No se pudo conectar a Tor — el contenido está bloqueado hasta que se conecte.",
+ "retry": "Reintentar"
+ }
+ },
"common": {
"loading": "Cargando…",
"error": "Error",
@@ -982,7 +995,14 @@
"title": "Avanzado",
"subtitle": "Conexiones de cartera, configuración del sistema y otras opciones para usuarios avanzados.",
"intro": "Conexiones de cartera, configuración del sistema y otras opciones avanzadas.",
- "wallet": "Cartera"
+ "wallet": "Cartera",
+ "torHeading": "Tor",
+ "torToggle": "Enrutar el tráfico a través de Tor",
+ "torToggleDesc": "Se conecta a relés y carga los medios a través de la red Tor para mayor privacidad. El rendimiento será más lento y habrá un breve retraso mientras Tor se conecta.",
+ "torApplyNote": "Al activar esta opción, todo el tráfico se enruta a través de Tor. Mientras se conecta, el resto del contenido no cargará (un banner muestra el estado) — nada se envía fuera de Tor. Al desactivarla, se vuelve a una conexión directa.",
+ "torStatusLabel": "Estado:",
+ "torExitIp": "IP de salida de Tor:",
+ "torCheckAgain": "Comprobar de nuevo"
},
"wallet": {
"title": "Cartera",
@@ -2921,4 +2941,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/fa.json b/src/locales/fa.json
index e2a7994d..d3a8fd12 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "خاموش",
+ "connecting": "در حال اتصال…",
+ "connected": "متصل",
+ "failed": "اتصال ناموفق بود"
+ },
+ "banner": {
+ "connecting": "در حال اتصال به Tor — تا زمانی که آماده نشود، محتوا بارگذاری نمیشود.",
+ "failed": "اتصال به Tor برقرار نشد — تا زمان اتصال، محتوا مسدود است.",
+ "retry": "دوباره تلاش کنید"
+ }
+ },
"common": {
"loading": "در حال بارگذاری…",
"error": "خطا",
@@ -982,7 +995,14 @@
"title": "پیشرفته",
"subtitle": "اتصالات کیف پول، پیکربندی سیستم و سایر گزینههای پیشرفته برای کاربران حرفهای.",
"intro": "اتصالات کیف پول، پیکربندی سیستم و سایر گزینههای پیشرفته.",
- "wallet": "کیف پول"
+ "wallet": "کیف پول",
+ "torHeading": "Tor",
+ "torToggle": "مسیردهی ترافیک از طریق Tor",
+ "torToggleDesc": "به رلهها متصل میشود و رسانه را از طریق شبکهٔ Tor بارگذاری میکند تا حریم خصوصی بیشتری داشته باشید. انتظار کارایی کندتر و تأخیر کوتاهی هنگام اتصال Tor را داشته باشید.",
+ "torApplyNote": "روشن کردن این گزینه تمام ترافیک را از طریق Tor مسیر میدهد. در زمان اتصال، سایر محتواها بارگذاری نمیشوند (یک بنر وضعیت را نشان میدهد) — هیچ دادهای خارج از Tor ارسال نمیشود. خاموش کردن آن به اتصال مستقیم بازمیگردد.",
+ "torStatusLabel": "وضعیت:",
+ "torExitIp": "IP خروجی Tor:",
+ "torCheckAgain": "دوباره بررسی کنید"
},
"wallet": {
"title": "کیف پول",
@@ -2912,4 +2932,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/fr.json b/src/locales/fr.json
index b3741a21..cd41da88 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "Désactivé",
+ "connecting": "Connexion…",
+ "connected": "Connecté",
+ "failed": "Échec de la connexion"
+ },
+ "banner": {
+ "connecting": "Connexion à Tor en cours — le contenu ne se chargera pas avant qu'il soit prêt.",
+ "failed": "Impossible de se connecter à Tor — le contenu est bloqué jusqu'à ce qu'il se connecte.",
+ "retry": "Réessayer"
+ }
+ },
"common": {
"loading": "Chargement…",
"error": "Erreur",
@@ -1421,7 +1434,14 @@
"title": "Avancé",
"subtitle": "Connexions de portefeuille, configuration système et autres options avancées pour les utilisateurs expérimentés.",
"intro": "Connexions de portefeuille, configuration système et autres options avancées.",
- "wallet": "Portefeuille"
+ "wallet": "Portefeuille",
+ "torHeading": "Tor",
+ "torToggle": "Acheminer le trafic via Tor",
+ "torToggleDesc": "Se connecte à des relais et charge les médias via le réseau Tor pour une meilleure confidentialité. Attendez-vous à des performances réduites et un court délai le temps que Tor se connecte.",
+ "torApplyNote": "Activer cette option achemine tout le trafic via Tor. Pendant la connexion, les autres contenus ne se chargeront pas (une bannière indique l'état) — rien n'est envoyé en dehors de Tor. La désactiver rétablit une connexion directe.",
+ "torStatusLabel": "État :",
+ "torExitIp": "IP de sortie Tor :",
+ "torCheckAgain": "Vérifier à nouveau"
},
"wallet": {
"title": "Portefeuille",
@@ -2918,4 +2938,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/hi.json b/src/locales/hi.json
index 102df945..30bb5ffb 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "बंद",
+ "connecting": "जुड़ा जा रहा है…",
+ "connected": "जुड़ गया",
+ "failed": "कनेक्शन विफल"
+ },
+ "banner": {
+ "connecting": "Tor से जुड़ा जा रहा है — तैयार होने तक कंटेंट लोड नहीं होगा।",
+ "failed": "Tor से नहीं जुड़ सके — जुड़ने तक कंटेंट ब्लॉक रहेगा।",
+ "retry": "फिर से कोशिश करें"
+ }
+ },
"common": {
"loading": "लोड हो रहा है…",
"error": "त्रुटि",
@@ -1424,7 +1437,14 @@
"title": "उन्नत",
"subtitle": "वॉलेट कनेक्शन, सिस्टम कॉन्फ़िगरेशन, और पावर यूज़र के लिए अन्य उन्नत विकल्प।",
"intro": "वॉलेट कनेक्शन, सिस्टम कॉन्फ़िगरेशन, और अन्य उन्नत विकल्प।",
- "wallet": "वॉलेट"
+ "wallet": "वॉलेट",
+ "torHeading": "Tor",
+ "torToggle": "Tor के ज़रिए ट्रैफ़िक भेजें",
+ "torToggleDesc": "Tor नेटवर्क पर रिले से जुड़ता है और मीडिया लोड करता है, जिससे प्राइवेसी मज़बूत होती है। धीमी परफ़ॉर्मेंस और Tor के जुड़ने में थोड़ी देरी की उम्मीद रखें।",
+ "torApplyNote": "इसे चालू करने पर सारा ट्रैफ़िक Tor से होकर जाता है। जुड़ते समय दूसरा कंटेंट लोड नहीं होगा (स्टेटस एक बैनर में दिखता है) — Tor के बाहर कुछ नहीं भेजा जाता। बंद करने पर सीधे कनेक्शन पर वापस आ जाता है।",
+ "torStatusLabel": "स्टेटस:",
+ "torExitIp": "Tor exit IP:",
+ "torCheckAgain": "फिर से जाँचें"
},
"wallet": {
"title": "वॉलेट",
@@ -2836,4 +2856,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/id.json b/src/locales/id.json
index 374dc184..8f7bcccb 100644
--- a/src/locales/id.json
+++ b/src/locales/id.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "Mati",
+ "connecting": "Menghubungkan…",
+ "connected": "Terhubung",
+ "failed": "Koneksi gagal"
+ },
+ "banner": {
+ "connecting": "Menghubungkan ke Tor — konten tidak akan dimuat hingga siap.",
+ "failed": "Tidak dapat terhubung ke Tor — konten diblokir hingga berhasil terhubung.",
+ "retry": "Coba lagi"
+ }
+ },
"common": {
"loading": "Memuat…",
"error": "Kesalahan",
@@ -1424,7 +1437,14 @@
"title": "Lanjutan",
"subtitle": "Koneksi dompet, konfigurasi sistem, dan opsi lanjutan lainnya untuk pengguna mahir.",
"intro": "Koneksi dompet, konfigurasi sistem, dan opsi lanjutan lainnya.",
- "wallet": "Dompet"
+ "wallet": "Dompet",
+ "torHeading": "Tor",
+ "torToggle": "Arahkan lalu lintas melalui Tor",
+ "torToggleDesc": "Terhubung ke relay dan memuat media melalui jaringan Tor untuk privasi yang lebih kuat. Performa lebih lambat dan ada jeda singkat saat Tor terhubung.",
+ "torApplyNote": "Mengaktifkan ini mengarahkan semua lalu lintas melalui Tor. Saat terhubung, konten lain tidak akan dimuat (banner menampilkan status) — tidak ada yang dikirim di luar Tor. Menonaktifkan ini beralih kembali ke koneksi langsung.",
+ "torStatusLabel": "Status:",
+ "torExitIp": "IP keluar Tor:",
+ "torCheckAgain": "Periksa lagi"
},
"wallet": {
"title": "Dompet",
@@ -2836,4 +2856,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/km.json b/src/locales/km.json
index 0c7759e5..5c25e038 100644
--- a/src/locales/km.json
+++ b/src/locales/km.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "បិទ",
+ "connecting": "កំពុងភ្ជាប់…",
+ "connected": "បានភ្ជាប់",
+ "failed": "ការភ្ជាប់បានបរាជ័យ"
+ },
+ "banner": {
+ "connecting": "កំពុងភ្ជាប់ទៅ Tor — មាតិកានឹងមិនផ្ទុករហូតដល់វារួចរាល់។",
+ "failed": "មិនអាចភ្ជាប់ទៅ Tor បានទេ — មាតិកាត្រូវបានស្ទះរហូតដល់វាភ្ជាប់។",
+ "retry": "សូមព្យាយាមម្តងទៀត"
+ }
+ },
"common": {
"loading": "កំពុងផ្ទុក…",
"error": "កំហុស",
@@ -982,7 +995,14 @@
"title": "កម្រិតខ្ពស់",
"subtitle": "ការតភ្ជាប់កាបូប ការកំណត់ប្រព័ន្ធ និងជម្រើសផ្សេងទៀតសម្រាប់អ្នកប្រើជាន់ខ្ពស់។",
"intro": "ការតភ្ជាប់កាបូប ការកំណត់ប្រព័ន្ធ និងជម្រើសកម្រិតខ្ពស់ផ្សេងទៀត។",
- "wallet": "កាបូប"
+ "wallet": "កាបូប",
+ "torHeading": "Tor",
+ "torToggle": "បញ្ជូនចរាចរណ៍តាម Tor",
+ "torToggleDesc": "ភ្ជាប់ទៅ relay និងផ្ទុកមេឌៀតាមបណ្តាញ Tor ដើម្បីការពារភាពឯកជនកាន់តែខ្លាំង។ រំពឹងថានឹងដំណើរការយឺតជាងមុន និងមានការពន្យារពេលបន្តិចខណៈ Tor កំពុងភ្ជាប់។",
+ "torApplyNote": "ការបើកនេះនឹងបញ្ជូនចរាចរណ៍ទាំងអស់តាម Tor។ ខណៈពេលភ្ជាប់ មាតិកាផ្សេងទៀតនឹងមិនផ្ទុក (បដាបង្ហាញស្ថានភាព) — គ្មានអ្វីត្រូវបានផ្ញើក្រៅ Tor ឡើយ។ ការបិទនឹងប្តូរត្រឡប់ទៅការភ្ជាប់ផ្ទាល់វិញ។",
+ "torStatusLabel": "ស្ថានភាព៖",
+ "torExitIp": "IP ចេញរបស់ Tor៖",
+ "torCheckAgain": "ពិនិត្យម្តងទៀត"
},
"wallet": {
"title": "កាបូប",
@@ -2912,4 +2932,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/ps.json b/src/locales/ps.json
index 862c42b3..07131c02 100644
--- a/src/locales/ps.json
+++ b/src/locales/ps.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "بند",
+ "connecting": "نښلول کېږي…",
+ "connected": "نښلیدلی",
+ "failed": "اتصال ناکام شو"
+ },
+ "banner": {
+ "connecting": "Tor سره نښلول کېږي — تر چمتو کېدو پورې به مینځپانګه نه بارېږي.",
+ "failed": "Tor سره اتصال ونشو — تر نښلیدو پورې مینځپانګه بنده ده.",
+ "retry": "بیا هڅه وکړئ"
+ }
+ },
"common": {
"loading": "د بارولو په حال کې…",
"error": "تېروتنه",
@@ -984,7 +997,14 @@
"title": "پرمختللي",
"subtitle": "د بټوې اړیکې، د سیسټم تنظیمات، او د پرمختللو کاروونکو لپاره نور انتخابونه.",
"intro": "د بټوې اړیکې، د سیسټم تنظیمات، او نور پرمختللي انتخابونه.",
- "wallet": "بټوه"
+ "wallet": "بټوه",
+ "torHeading": "Tor",
+ "torToggle": "ټرافیک د Tor له لارې لیږئ",
+ "torToggleDesc": "د Tor شبکې له لارې ریلې سره نښلي او مېډیا بارو ي، چې قوي محرمیت چمتو کوي. د ورو کار او د Tor د نښلیدو لپاره لنډ ځنډ تمه کړئ.",
+ "torApplyNote": "د دې فعالولو سره ټول ټرافیک د Tor له لارې لیږل کېږي. د نښلیدو پر مهال، نوره مینځپانګه نه بارېږي (یو بنر حالت ښیي) — د Tor بهر هیڅ نه لیږل کیږي. د بندولو سره مستقیم اتصال ته بیرته راګرځي.",
+ "torStatusLabel": "حالت:",
+ "torExitIp": "د Tor د وتلو IP:",
+ "torCheckAgain": "بیا وګورئ"
},
"wallet": {
"title": "بټوه",
@@ -2914,4 +2934,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 7c6b0c19..6f2880f2 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "Desligado",
+ "connecting": "Conectando…",
+ "connected": "Conectado",
+ "failed": "Falha na conexão"
+ },
+ "banner": {
+ "connecting": "Conectando ao Tor — o conteúdo não carregará até que esteja pronto.",
+ "failed": "Não foi possível conectar ao Tor — o conteúdo está bloqueado até que a conexão seja estabelecida.",
+ "retry": "Tentar novamente"
+ }
+ },
"common": {
"loading": "Carregando…",
"error": "Erro",
@@ -1426,7 +1439,14 @@
"title": "Avançado",
"subtitle": "Conexões de carteira, configuração de sistema e outras opções avançadas para usuários experientes.",
"intro": "Conexões de carteira, configuração de sistema e outras opções avançadas.",
- "wallet": "Carteira"
+ "wallet": "Carteira",
+ "torHeading": "Tor",
+ "torToggle": "Rotear tráfego pelo Tor",
+ "torToggleDesc": "Conecta-se a relays e carrega mídia pela rede Tor para maior privacidade. Espere desempenho mais lento e uma breve espera enquanto o Tor se conecta.",
+ "torApplyNote": "Ativar isso roteia todo o tráfego pelo Tor. Enquanto conecta, outros conteúdos não carregarão (uma faixa exibe o status) — nada é enviado fora do Tor. Desativar volta para uma conexão direta.",
+ "torStatusLabel": "Status:",
+ "torExitIp": "IP de saída do Tor:",
+ "torCheckAgain": "Verificar novamente"
},
"wallet": {
"title": "Carteira",
@@ -2923,4 +2943,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/ru.json b/src/locales/ru.json
index c03acba2..541d6687 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "Выключено",
+ "connecting": "Подключение…",
+ "connected": "Подключено",
+ "failed": "Ошибка подключения"
+ },
+ "banner": {
+ "connecting": "Подключение к Tor — контент не загрузится, пока соединение не установлено.",
+ "failed": "Не удалось подключиться к Tor — контент заблокирован до установления соединения.",
+ "retry": "Попробовать снова"
+ }
+ },
"common": {
"loading": "Загрузка…",
"error": "Ошибка",
@@ -1426,7 +1439,14 @@
"title": "Расширенные",
"subtitle": "Подключения кошельков, конфигурация системы и другие расширенные опции для опытных пользователей.",
"intro": "Подключения кошельков, конфигурация системы и другие расширенные опции.",
- "wallet": "Кошелёк"
+ "wallet": "Кошелёк",
+ "torHeading": "Tor",
+ "torToggle": "Направлять трафик через Tor",
+ "torToggleDesc": "Подключается к ретрансляторам и загружает медиа через сеть Tor для повышенной конфиденциальности. Ожидайте более низкую скорость и небольшую задержку при подключении Tor.",
+ "torApplyNote": "Включение этой опции направляет весь трафик через Tor. Пока соединение устанавливается, другой контент не загружается (статус отображается в баннере) — ничего не отправляется за пределы Tor. Отключение переключает обратно на прямое соединение.",
+ "torStatusLabel": "Статус:",
+ "torExitIp": "Выходной IP Tor:",
+ "torCheckAgain": "Проверить снова"
},
"wallet": {
"title": "Кошелёк",
@@ -2923,4 +2943,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/sn.json b/src/locales/sn.json
index b54f1975..62895fd1 100644
--- a/src/locales/sn.json
+++ b/src/locales/sn.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "Dzimwa",
+ "connecting": "Kubatanidza…",
+ "connected": "Yakabatanidza",
+ "failed": "Kubatanidza kwakundikana"
+ },
+ "banner": {
+ "connecting": "Kubatanidza kuTor — zvichakara zvichirodhwa kusvikira zvasvika.",
+ "failed": "Hatina kukwanisa kubatanidza kuTor — zvichakara zvakavharwa kusvikira zvabatanidza.",
+ "retry": "Edza zvakare"
+ }
+ },
"common": {
"loading": "Inotakura…",
"error": "Kukanganisa",
@@ -984,7 +997,14 @@
"title": "Zvepamusoro",
"subtitle": "Mibatanidzwa yechikwama, marongero esystem, nezvimwe zvinotenderwa kuvashandisi venyanzvi.",
"intro": "Mibatanidzwa yechikwama, marongero esystem, nezvimwe zvepamusoro.",
- "wallet": "Chikwama"
+ "wallet": "Chikwama",
+ "torHeading": "Tor",
+ "torToggle": "Fambisa traffic kuburikidza neTor",
+ "torToggleDesc": "Inobatanidza kune marelay uye inorodha media pamusoro peNetwork yeTor kuti uwane kuchengetedzeka kwakasimba. Tarisira kushanda kwakarefu uye kugumira kadiki Tor ichiri kubatanidza.",
+ "torApplyNote": "Kuvhura izvi kunofambisa traffic yose kuburikidza neTor. Ichiri kubatanidza, zvimwe zvichakara hazvizorohwi (bhana inoratidzwa ichiratidza mamiriro) — hapana chinhumwa kunze kweTor. Kudzima kunodzizoresa kubatanidza kwakanga kwakagara.",
+ "torStatusLabel": "Mamiriro:",
+ "torExitIp": "IP yokubuda kweTor:",
+ "torCheckAgain": "Tarisa zvakare"
},
"wallet": {
"title": "Chikwama",
@@ -2914,4 +2934,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/sw.json b/src/locales/sw.json
index 7ec81a67..e5cba24e 100644
--- a/src/locales/sw.json
+++ b/src/locales/sw.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "Imezimwa",
+ "connecting": "Inaunganisha…",
+ "connected": "Imeunganishwa",
+ "failed": "Muunganisho umeshindikana"
+ },
+ "banner": {
+ "connecting": "Inaunganisha kwenye Tor — maudhui hayatapakia hadi itakapokuwa tayari.",
+ "failed": "Haikuweza kuunganisha kwenye Tor — maudhui yamezuiwa hadi itakapoungana.",
+ "retry": "Jaribu tena"
+ }
+ },
"common": {
"loading": "Inapakia…",
"error": "Hitilafu",
@@ -1423,7 +1436,14 @@
"title": "Ya kina",
"subtitle": "Muunganisho wa pochi, usanidi wa mfumo, na chaguzi zingine za kina kwa watumiaji wenye uwezo.",
"intro": "Muunganisho wa pochi, usanidi wa mfumo, na chaguzi zingine za kina.",
- "wallet": "Pochi"
+ "wallet": "Pochi",
+ "torHeading": "Tor",
+ "torToggle": "Peleka trafiki kupitia Tor",
+ "torToggleDesc": "Inaunganisha kwenye relei na kupakia midia kupitia mtandao wa Tor kwa faragha zaidi. Tarajia utendaji polepole na kuchelewa kidogo wakati Tor inaunganisha.",
+ "torApplyNote": "Kuiwasha hupeleka trafiki yote kupitia Tor. Wakati inaunganisha, maudhui mengine hayatapakia (bango linaonyesha hali) — hakuna kinachotumwa nje ya Tor. Kuizima kurudi kwenye muunganisho wa moja kwa moja.",
+ "torStatusLabel": "Hali:",
+ "torExitIp": "IP ya kutoka ya Tor:",
+ "torCheckAgain": "Angalia tena"
},
"wallet": {
"title": "Pochi",
@@ -2795,4 +2815,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/tr.json b/src/locales/tr.json
index 74d3c3a2..6b85d68a 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "Kapalı",
+ "connecting": "Bağlanıyor…",
+ "connected": "Bağlı",
+ "failed": "Bağlantı başarısız"
+ },
+ "banner": {
+ "connecting": "Tor'a bağlanılıyor — hazır olana kadar içerik yüklenmeyecek.",
+ "failed": "Tor'a bağlanılamadı — bağlantı kurulana kadar içerik engellendi.",
+ "retry": "Tekrar dene"
+ }
+ },
"common": {
"loading": "Yükleniyor…",
"error": "Hata",
@@ -1425,7 +1438,14 @@
"title": "Gelişmiş",
"subtitle": "Cüzdan bağlantıları, sistem yapılandırması ve ileri kullanıcılar için diğer gelişmiş seçenekler.",
"intro": "Cüzdan bağlantıları, sistem yapılandırması ve diğer gelişmiş seçenekler.",
- "wallet": "Cüzdan"
+ "wallet": "Cüzdan",
+ "torHeading": "Tor",
+ "torToggle": "Trafiği Tor üzerinden yönlendir",
+ "torToggleDesc": "Daha güçlü gizlilik için Tor ağı üzerinden rölelere bağlanır ve medya yükler. Daha yavaş bir performans ve Tor bağlanırken kısa bir bekleme süresi bekleyin.",
+ "torApplyNote": "Bunu açmak tüm trafiği Tor üzerinden yönlendirir. Bağlanırken diğer içerikler yüklenmez (durum bir banner ile gösterilir) — Tor dışına hiçbir şey gönderilmez. Kapatmak doğrudan bağlantıya geri döner.",
+ "torStatusLabel": "Durum:",
+ "torExitIp": "Tor çıkış IP:",
+ "torCheckAgain": "Tekrar kontrol et"
},
"wallet": {
"title": "Cüzdan",
@@ -2837,4 +2857,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/zh-Hant.json b/src/locales/zh-Hant.json
index 7c14cfea..99ad14e0 100644
--- a/src/locales/zh-Hant.json
+++ b/src/locales/zh-Hant.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "關閉",
+ "connecting": "連線中…",
+ "connected": "已連線",
+ "failed": "連線失敗"
+ },
+ "banner": {
+ "connecting": "正在連線至 Tor — 連線就緒前內容將無法載入。",
+ "failed": "無法連線至 Tor — 連線成功前內容將被封鎖。",
+ "retry": "重試"
+ }
+ },
"common": {
"loading": "載入中…",
"error": "錯誤",
@@ -984,7 +997,14 @@
"title": "高階",
"subtitle": "錢包連線、系統配置和其他面向高階使用者的選項。",
"intro": "錢包連線、系統配置和其他高階選項。",
- "wallet": "錢包"
+ "wallet": "錢包",
+ "torHeading": "Tor",
+ "torToggle": "透過 Tor 路由流量",
+ "torToggleDesc": "連線至中繼節點並透過 Tor 網路載入媒體,以獲得更強的隱私保護。預期速度會較慢,且 Tor 連線期間會有短暫延遲。",
+ "torApplyNote": "開啟此功能會將所有流量透過 Tor 路由。連線期間,其他內容將無法載入(橫幅會顯示連線狀態)— 不會有任何資料在 Tor 以外傳送。關閉後將切換回直接連線。",
+ "torStatusLabel": "狀態:",
+ "torExitIp": "Tor 出口 IP:",
+ "torCheckAgain": "重新檢查"
},
"wallet": {
"title": "錢包",
@@ -2838,4 +2858,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 2c1683a4..35d2fedc 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -1,4 +1,17 @@
{
+ "tor": {
+ "status": {
+ "disabled": "已关闭",
+ "connecting": "连接中…",
+ "connected": "已连接",
+ "failed": "连接失败"
+ },
+ "banner": {
+ "connecting": "正在连接 Tor — 连接完成前内容无法加载。",
+ "failed": "无法连接到 Tor — 内容已被屏蔽,等待连接成功后恢复。",
+ "retry": "重试"
+ }
+ },
"common": {
"loading": "加载中…",
"error": "错误",
@@ -984,7 +997,14 @@
"title": "高级",
"subtitle": "钱包连接、系统配置和其他面向高级用户的选项。",
"intro": "钱包连接、系统配置和其他高级选项。",
- "wallet": "钱包"
+ "wallet": "钱包",
+ "torHeading": "Tor",
+ "torToggle": "通过 Tor 路由流量",
+ "torToggleDesc": "连接到中继并通过 Tor 网络加载媒体内容,以获得更强的隐私保护。预计性能会有所下降,且 Tor 连接时会有短暂延迟。",
+ "torApplyNote": "开启后,所有流量将通过 Tor 路由。连接期间,其他内容将无法加载(横幅会显示状态)——不会有任何数据在 Tor 之外发送。关闭后将切换回直连。",
+ "torStatusLabel": "状态:",
+ "torExitIp": "Tor 出口 IP:",
+ "torCheckAgain": "重新检查"
},
"wallet": {
"title": "钱包",
@@ -2914,4 +2934,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/pages/AdvancedSettingsPage.tsx b/src/pages/AdvancedSettingsPage.tsx
index 5797de76..9db1aa21 100644
--- a/src/pages/AdvancedSettingsPage.tsx
+++ b/src/pages/AdvancedSettingsPage.tsx
@@ -5,15 +5,20 @@ import { AdvancedSettings } from '@/components/AdvancedSettings';
import { WalletSettings } from '@/components/WalletSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
+import { useTor } from '@/hooks/useTor';
+import { retryTor } from '@/lib/tor';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useState } from 'react';
export function AdvancedSettingsPage() {
const { t } = useTranslation();
const { user } = useCurrentUser();
- const { config } = useAppContext();
+ const { config, updateConfig } = useAppContext();
+ const tor = useTor();
const [walletOpen, setWalletOpen] = useState(false);
useSeoMeta({
@@ -37,6 +42,73 @@ export function AdvancedSettingsPage() {
{t('settings.advanced.intro')}
+ {/* Tor (Android only). Lives here rather than under Network so it's
+ reachable without logging in. The proxy is wired up natively at
+ launch, so changes apply on the next app restart. */}
+ {tor.supported && (
+
+
+
{t('settings.advanced.torHeading')}
+
+
+
+
+
+
+
+ {t('settings.advanced.torToggleDesc')}
+
+
+ {
+ updateConfig((prev) => ({ ...prev, torEnabled: checked }));
+ // Start/stop arti now (live) — no page reload, no modal.
+ // Routing is fail-closed, so while connecting, external
+ // content simply won't load (a bottom banner explains why);
+ // the relay layer remounts to reconnect through Tor once
+ // it's up. On disable, arti stops and the proxy is cleared.
+ tor.setEnabled(checked);
+ }}
+ />
+