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 ( +

+
+ {failed ? ( + + ) : ( + + )} + + {failed + ? error || t('tor.banner.failed') + : t('tor.banner.connecting') + + (bootstrapPercent > 0 ? ` (${bootstrapPercent}%)` : '')} + + {failed && ( + + )} +
+
+ ); +} + +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); + }} + /> +
+

+ {t('settings.advanced.torApplyNote')} +

+ {config.torEnabled && ( +
+

+ {t('settings.advanced.torStatusLabel')}{' '} + + {t(`tor.status.${tor.status}`)} + + {tor.status === 'connecting' && tor.bootstrapPercent > 0 + ? ` (${tor.bootstrapPercent}%)` + : ''} +

+ {tor.status === 'connected' && tor.exitIp && ( +

+ {t('settings.advanced.torExitIp')}{' '} + {tor.exitIp} +

+ )} + {tor.status === 'failed' && tor.error && ( +

{tor.error}

+ )} + {(tor.status === 'failed' || tor.status === 'connecting') && ( + + )} +
+ )} +
+
+ )} + {/* Wallet collapsible — only when logged in */} {user && ( diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index ebc687de..f20b620b 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -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: '',