Add optional Tor (arti) routing on Android

Adds an opt-in Tor mode that routes all app traffic through a local
SOCKS5 proxy backed by arti (Tor in Rust), bundled via the
org.torproject:arti-mobile:1.7.0.1 AAR.

- TorController starts/stops arti and installs a fail-closed WebView
  proxy override (no direct fallback) so traffic can't leak while Tor is
  connecting or down. Connectivity is verified against
  check.torproject.org (IsTor) and re-checked continuously; the exit IP
  is surfaced for verification, and the status isn't latched so a dropped
  circuit downgrades honestly.
- TorPlugin bridges enable/disable/status to the Capacitor/JS layer.
  Toggling applies live, in place, with no app restart.
- UI: a slim fail-closed status banner (replacing the old full-screen
  gate), the Tor toggle in Advanced settings reachable while logged out,
  and Settings/Search/About added to the logged-out menu.
- R8 keep rules for org.torproject.arti.** so the JNI native-method
  classes aren't stripped/renamed; androidx.webkit on the compile
  classpath for the WebView proxy APIs.
This commit is contained in:
Barrett O.
2026-06-02 14:01:42 +00:00
committed by micah
parent 7ffaccb304
commit 886d3ece18
32 changed files with 1219 additions and 35 deletions
+14
View File
@@ -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"
+4
View File
@@ -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
@@ -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);
@@ -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.
*
* <p>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 <em>all</em>
* Capacitor WebView traffic (every {@code fetch} and relay {@code WebSocket})
* is routed through Tor. No changes to the TypeScript HTTP layer are needed.
*
* <p>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 <em>before</em> the WebView loads so
* there is no pre-bootstrap leak window.
*
* <p>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);
}
}
}
@@ -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.
*
* <p>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.
*
* <p>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();
}
}
+3
View File
@@ -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" }
}
}
+23 -2
View File
@@ -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 <NostrProvider key={key}>{children}</NostrProvider>;
}
export function App() {
useNsecPasteGuard();
@@ -205,7 +226,7 @@ export function App() {
<PlausibleProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<NostrProvider>
<RelayProvider>
<NostrSync />
<InitialSyncRunner />
<NativeNotifications />
@@ -221,7 +242,7 @@ export function App() {
</TooltipProvider>
</OnboardingProvider>
</NWCProvider>
</NostrProvider>
</RelayProvider>
</NostrLoginProvider>
</QueryClientProvider>
</PlausibleProvider>
+4
View File
@@ -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() {
<ScrollToTop />
<AudioNavigationGuard />
<MinimizedAudioBar />
{/* App-wide Tor status banner. Must live inside BrowserRouter — it
renders a <Link> to the Tor settings, which needs Router context. */}
<TorStatusBanner />
<OnboardingGate>
<Routes>
{/* Narrow layout — `max-w-3xl` center column. The default for
+9 -1
View File
@@ -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 }] : []),
+63
View File
@@ -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 (
<div
role="status"
aria-live="polite"
className={`fixed inset-x-0 bottom-0 z-50 safe-area-bottom border-t px-4 py-2 ${
failed
? 'border-destructive bg-destructive text-destructive-foreground'
: 'border-amber-600 bg-amber-500 text-black'
}`}
>
<div className="mx-auto flex max-w-3xl items-center gap-3 text-xs">
{failed ? (
<ShieldAlert className="size-4 shrink-0" />
) : (
<Loader2 className="size-4 shrink-0 animate-spin" />
)}
<span className="flex-1 leading-snug">
{failed
? error || t('tor.banner.failed')
: t('tor.banner.connecting') +
(bootstrapPercent > 0 ? ` (${bootstrapPercent}%)` : '')}
</span>
{failed && (
<button
type="button"
onClick={() => retryTor()}
className="shrink-0 font-semibold underline underline-offset-2"
>
{t('tor.banner.retry')}
</button>
)}
</div>
</div>
);
}
export default TorStatusBanner;
+9
View File
@@ -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;
/**
+91
View File
@@ -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<void>;
}
/**
* 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<TorStatus>('disabled');
const [bootstrapPercent, setBootstrapPercent] = useState(0);
const [error, setError] = useState<string | null>(null);
const [exitIp, setExitIp] = useState<string | null>(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 };
}
+1
View File
@@ -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
+95
View File
@@ -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 (0100) 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<void>;
/** Start arti now (live) and persist enabled=true. */
start(): Promise<void>;
/** Stop arti now (live), clear the WebView proxy, and persist enabled=false. */
stop(): Promise<void>;
/** Synchronous snapshot of the current status. */
getStatus(): Promise<{ enabled: boolean } & TorStatusEvent>;
/** Re-run the connectivity probe (used by the gate's "Retry"). */
retry(): Promise<void>;
addListener(
eventName: 'torStatus',
listener: (event: TorStatusEvent) => void,
): Promise<PluginListenerHandle>;
}
/**
* 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<TorPlugin>('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<void> {
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<void> {
if (!isTorSupported()) return;
try {
await Tor.retry();
} catch {
// ignore
}
}
/** Start arti now (live activation). No-op on unsupported platforms. */
export async function startTor(): Promise<void> {
if (!isTorSupported()) return;
try {
await Tor.start();
} catch {
// ignore
}
}
/** Stop arti now (live deactivation). No-op on unsupported platforms. */
export async function stopTor(): Promise<void> {
if (!isTorSupported()) return;
try {
await Tor.stop();
} catch {
// ignore
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+21 -1
View File
@@ -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",
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+22 -2
View File
@@ -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 @@
}
}
}
}
}
+73 -1
View File
@@ -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')}
</p>
{/* 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 && (
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold">{t('settings.advanced.torHeading')}</h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pt-4 pb-4 px-3 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<Label htmlFor="tor-enabled" className="text-sm font-medium">
{t('settings.advanced.torToggle')}
</Label>
<p className="text-xs text-muted-foreground leading-relaxed">
{t('settings.advanced.torToggleDesc')}
</p>
</div>
<Switch
id="tor-enabled"
checked={config.torEnabled}
onCheckedChange={(checked) => {
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);
}}
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{t('settings.advanced.torApplyNote')}
</p>
{config.torEnabled && (
<div className="space-y-2 text-xs">
<p className="text-muted-foreground">
{t('settings.advanced.torStatusLabel')}{' '}
<span className={`font-medium ${tor.status === 'failed' ? 'text-destructive' : 'text-foreground'}`}>
{t(`tor.status.${tor.status}`)}
</span>
{tor.status === 'connecting' && tor.bootstrapPercent > 0
? ` (${tor.bootstrapPercent}%)`
: ''}
</p>
{tor.status === 'connected' && tor.exitIp && (
<p className="text-muted-foreground">
{t('settings.advanced.torExitIp')}{' '}
<span className="font-mono text-foreground">{tor.exitIp}</span>
</p>
)}
{tor.status === 'failed' && tor.error && (
<p className="text-destructive leading-relaxed">{tor.error}</p>
)}
{(tor.status === 'failed' || tor.status === 'connecting') && (
<Button variant="outline" size="sm" onClick={() => retryTor()}>
{t('settings.advanced.torCheckAgain')}
</Button>
)}
</div>
)}
</div>
</div>
)}
{/* Wallet collapsible — only when logged in */}
{user && (
<Collapsible open={walletOpen} onOpenChange={setWalletOpen}>
+1
View File
@@ -116,6 +116,7 @@ export function TestApp({ children }: TestAppProps) {
imageQuality: 'compressed',
imageProxy: '',
lowBandwidthMode: false,
torEnabled: false,
esploraApis: ['https://mempool.space/api'],
blockbookBaseUrl: 'https://btc.trezor.io',
bip352IndexerUrl: '',