From 5508d239ced93d01ed88adbaf75c1ef6a49a95de Mon Sep 17 00:00:00 2001 From: notTanveer Date: Sun, 8 Mar 2026 20:36:18 +0530 Subject: [PATCH 01/14] feat: orbot --- App.tsx | 4 + blue_modules/SilentPaymentIndexer.ts | 3 +- blue_modules/socks5Fetch.ts | 223 +++++++++++++++++ blue_modules/torManager.ts | 228 ++++++++++++++++++ components/Context/SettingsProvider.tsx | 79 ++++++ helpers/silent-payments/IndexerHttpClient.ts | 81 ++++++- helpers/silent-payments/types.ts | 1 + navigation/DetailViewScreensStack.tsx | 6 + navigation/DetailViewStackParamList.ts | 1 + screen/settings/NetworkSettings.tsx | 5 + screen/settings/Settings.tsx | 2 +- screen/settings/TorSettings.tsx | 238 +++++++++++++++++++ 12 files changed, 867 insertions(+), 4 deletions(-) create mode 100644 blue_modules/socks5Fetch.ts create mode 100644 blue_modules/torManager.ts create mode 100644 screen/settings/TorSettings.tsx diff --git a/App.tsx b/App.tsx index 4bd6a519e..91702af2f 100644 --- a/App.tsx +++ b/App.tsx @@ -10,11 +10,15 @@ import { useLogger } from '@react-navigation/devtools'; import { StorageProvider } from './components/Context/StorageProvider'; import { initializeIndexer } from './blue_modules/SilentPaymentIndexer'; import { initializeRustJsiBridge } from './blue_modules/RustJsiBridge'; +import TorManager from './blue_modules/torManager'; + +TorManager.getInstance().loadSettings(); const App = () => { initializeRustJsiBridge(); initializeIndexer({ baseUrl: 'https://superparamount-kendal-halting.ngrok-free.dev/', + onionUrl: '', timeout: 100000, // 100 seconds for blockchain scanning operations (increased for slower connections) }); diff --git a/blue_modules/SilentPaymentIndexer.ts b/blue_modules/SilentPaymentIndexer.ts index 4fcfbab86..c3fd7863b 100644 --- a/blue_modules/SilentPaymentIndexer.ts +++ b/blue_modules/SilentPaymentIndexer.ts @@ -14,7 +14,8 @@ export class SilentPaymentIndexer { constructor(config: SilentPaymentIndexerConfig) { const baseUrl = config.baseUrl.replace(/\/$/, ''); - this.httpClient = new IndexerHttpClient(baseUrl, config.timeout); + const onionUrl = config.onionUrl?.replace(/\/$/, ''); + this.httpClient = new IndexerHttpClient(baseUrl, config.timeout, onionUrl); } getBaseUrl(): string { diff --git a/blue_modules/socks5Fetch.ts b/blue_modules/socks5Fetch.ts new file mode 100644 index 000000000..39d7524a7 --- /dev/null +++ b/blue_modules/socks5Fetch.ts @@ -0,0 +1,223 @@ +import TcpSocket from 'react-native-tcp-socket'; + +const DEFAULT_SOCKS_HOST = '127.0.0.1'; +const DEFAULT_SOCKS_PORT = 9050; +const DEFAULT_TIMEOUT = 30000; + +interface Socks5FetchOptions { + method?: string; + headers?: Record; + body?: string; + timeout?: number; + socksHost?: string; + socksPort?: number; +} + +interface Socks5Response { + ok: boolean; + status: number; + statusText: string; + headers: Record; + json: () => Promise; + text: () => Promise; +} + +function parseUrl(url: string): { host: string; port: number; path: string } { + // Simple URL parser for http:// URLs (onion addresses use http) + const match = url.match(/^https?:\/\/([^/:]+)(?::(\d+))?(\/.*)?$/); + if (!match) { + throw new Error(`Invalid URL: ${url}`); + } + const host = match[1]; + const port = match[2] ? parseInt(match[2], 10) : (url.startsWith('https') ? 443 : 80); + const path = match[3] || '/'; + return { host, port, path }; +} + +function decodeChunked(data: string): string { + let result = ''; + let remaining = data; + + while (remaining.length > 0) { + const lineEnd = remaining.indexOf('\r\n'); + if (lineEnd === -1) break; + + const chunkSizeStr = remaining.substring(0, lineEnd).trim(); + if (!chunkSizeStr) break; + + const chunkSize = parseInt(chunkSizeStr, 16); + if (isNaN(chunkSize) || chunkSize === 0) break; + + const chunkStart = lineEnd + 2; + const chunkData = remaining.substring(chunkStart, chunkStart + chunkSize); + result += chunkData; + remaining = remaining.substring(chunkStart + chunkSize + 2); // +2 for trailing \r\n + } + + return result; +} + +/** + * Make an HTTP request through a SOCKS5 proxy (e.g., Orbot). + * This enables connecting to .onion addresses via Tor. + */ +export function socks5Fetch(url: string, options: Socks5FetchOptions = {}): Promise { + const { + method = 'GET', + headers = {}, + body, + timeout = DEFAULT_TIMEOUT, + socksHost = DEFAULT_SOCKS_HOST, + socksPort = DEFAULT_SOCKS_PORT, + } = options; + + const { host, port, path } = parseUrl(url); + + return new Promise((resolve, reject) => { + let timer: ReturnType; + let resolved = false; + const chunks: Buffer[] = []; + + const finish = (fn: () => void) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + fn(); + }; + + timer = setTimeout(() => { + finish(() => { + try { + client.destroy(); + } catch {} + reject(new Error(`SOCKS5 request timeout after ${timeout}ms`)); + }); + }, timeout); + + const client = TcpSocket.createConnection({ host: socksHost, port: socksPort }, () => { + // Phase 1: SOCKS5 greeting - version 5, 1 auth method, no auth + client.write(Buffer.from([0x05, 0x01, 0x00])); + }); + + let phase: 'greeting' | 'connect' | 'http' = 'greeting'; + + client.on('data', (data: string | Buffer) => { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); + if (phase === 'greeting') { + // Verify SOCKS5 server accepted no-auth + if (buf.length < 2 || buf[0] !== 0x05 || buf[1] !== 0x00) { + finish(() => { + client.destroy(); + reject(new Error('SOCKS5 authentication negotiation failed')); + }); + return; + } + + // Phase 2: Send CONNECT request with domain name + phase = 'connect'; + const domainBuf = Buffer.from(host, 'ascii'); + const req = Buffer.alloc(7 + domainBuf.length); + req[0] = 0x05; // SOCKS version + req[1] = 0x01; // CONNECT command + req[2] = 0x00; // Reserved + req[3] = 0x03; // Address type: domain name + req[4] = domainBuf.length; // Domain length + domainBuf.copy(req, 5); + req.writeUInt16BE(port, 5 + domainBuf.length); // Port + client.write(req); + } else if (phase === 'connect') { + // Verify CONNECT succeeded + if (buf.length < 2 || buf[0] !== 0x05 || buf[1] !== 0x00) { + const errorCode = buf.length >= 2 ? buf[1] : -1; + finish(() => { + client.destroy(); + reject(new Error(`SOCKS5 CONNECT failed (code: ${errorCode})`)); + }); + return; + } + + // Phase 3: Tunnel established - send HTTP request + phase = 'http'; + const requestHeaders: Record = { + Host: host, + Connection: 'close', + Accept: 'application/json', + ...headers, + }; + + let httpRequest = `${method} ${path} HTTP/1.1\r\n`; + for (const [key, value] of Object.entries(requestHeaders)) { + httpRequest += `${key}: ${value}\r\n`; + } + if (body) { + httpRequest += `Content-Length: ${Buffer.byteLength(body)}\r\n`; + } + httpRequest += '\r\n'; + if (body) { + httpRequest += body; + } + + client.write(Buffer.from(httpRequest)); + } else if (phase === 'http') { + chunks.push(buf); + } + }); + + client.on('close', () => { + finish(() => { + try { + const rawResponse = Buffer.concat(chunks).toString('utf-8'); + const headerEnd = rawResponse.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + reject(new Error('Invalid HTTP response: no header terminator')); + return; + } + + const headerPart = rawResponse.substring(0, headerEnd); + const bodyPart = rawResponse.substring(headerEnd + 4); + + // Parse status line + const statusLine = headerPart.split('\r\n')[0]; + const statusMatch = statusLine.match(/HTTP\/[\d.]+\s+(\d+)\s*(.*)/); + const status = statusMatch ? parseInt(statusMatch[1], 10) : 0; + const statusText = statusMatch ? statusMatch[2] : ''; + + // Parse headers + const responseHeaders: Record = {}; + const headerLines = headerPart.split('\r\n').slice(1); + for (const line of headerLines) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.substring(0, colonIndex).trim().toLowerCase(); + const value = line.substring(colonIndex + 1).trim(); + responseHeaders[key] = value; + } + } + + // Handle chunked transfer encoding + let responseBody = bodyPart; + if (responseHeaders['transfer-encoding']?.includes('chunked')) { + responseBody = decodeChunked(bodyPart); + } + + resolve({ + ok: status >= 200 && status < 300, + status, + statusText, + headers: responseHeaders, + json: async () => JSON.parse(responseBody), + text: async () => responseBody, + }); + } catch (error) { + reject(new Error(`Failed to parse response: ${error instanceof Error ? error.message : String(error)}`)); + } + }); + }); + + client.on('error', (error: Error) => { + finish(() => { + reject(new Error(`SOCKS5 connection error: ${error.message}`)); + }); + }); + }); +} diff --git a/blue_modules/torManager.ts b/blue_modules/torManager.ts new file mode 100644 index 000000000..1378d62ea --- /dev/null +++ b/blue_modules/torManager.ts @@ -0,0 +1,228 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import TcpSocket from 'react-native-tcp-socket'; +import { Linking, NativeModules, Platform } from 'react-native'; + +const TOR_SETTINGS_KEY = '@tor_settings'; +const DEFAULT_SOCKS_HOST = '127.0.0.1'; +const DEFAULT_SOCKS_PORT = 9050; +const ORBOT_PACKAGE = 'org.torproject.android'; + +export type TorStatus = 'disabled' | 'checking' | 'connected' | 'unavailable'; + +export interface TorSettings { + enabled: boolean; + socksPort: number; + /** When true, requests fail if Tor is unavailable instead of falling back to clearnet */ + torOnly: boolean; + retryAttempts: number; +} + +const DEFAULT_SETTINGS: TorSettings = { + enabled: false, + socksPort: DEFAULT_SOCKS_PORT, + torOnly: false, + retryAttempts: 3, +}; + +class TorManager { + private static instance: TorManager; + private _status: TorStatus = 'disabled'; + private _settings: TorSettings = { ...DEFAULT_SETTINGS }; + private _listeners: Set<(status: TorStatus) => void> = new Set(); + + static getInstance(): TorManager { + if (!TorManager.instance) { + TorManager.instance = new TorManager(); + } + return TorManager.instance; + } + + get status(): TorStatus { + return this._status; + } + + get settings(): TorSettings { + return { ...this._settings }; + } + + get socksHost(): string { + return DEFAULT_SOCKS_HOST; + } + + get socksPort(): number { + return this._settings.socksPort; + } + + get isReady(): boolean { + return this._status === 'connected'; + } + + /** When true, clearnet fallback is blocked — requests must go through Tor or fail */ + get isTorOnly(): boolean { + return this._settings.enabled && this._settings.torOnly; + } + + get retryAttempts(): number { + return this._settings.retryAttempts; + } + + async loadSettings(): Promise { + try { + const stored = await AsyncStorage.getItem(TOR_SETTINGS_KEY); + if (stored) { + this._settings = { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }; + } + } catch (e) { + console.warn('[TorManager] Failed to load settings:', e); + } + + if (this._settings.enabled) { + await this.checkConnection(); + } else { + this._setStatus('disabled'); + } + + return this._settings; + } + + async saveSettings(settings: Partial): Promise { + this._settings = { ...this._settings, ...settings }; + try { + await AsyncStorage.setItem(TOR_SETTINGS_KEY, JSON.stringify(this._settings)); + } catch (e) { + console.error('[TorManager] Failed to save settings:', e); + } + } + + async setEnabled(enabled: boolean): Promise { + await this.saveSettings({ enabled }); + if (enabled) { + await this.checkConnection(); + } else { + this._setStatus('disabled'); + } + } + + async setTorOnly(torOnly: boolean): Promise { + await this.saveSettings({ torOnly }); + } + + async setRetryAttempts(retryAttempts: number): Promise { + await this.saveSettings({ retryAttempts: Math.max(1, Math.min(retryAttempts, 10)) }); + } + + async setSocksPort(port: number): Promise { + await this.saveSettings({ socksPort: port }); + if (this._settings.enabled) { + await this.checkConnection(); + } + } + + async checkConnection(): Promise { + if (!this._settings.enabled) { + this._setStatus('disabled'); + return false; + } + + this._setStatus('checking'); + + try { + const available = await this._testSocksProxy(); + this._setStatus(available ? 'connected' : 'unavailable'); + return available; + } catch { + this._setStatus('unavailable'); + return false; + } + } + + /** + * Check if Orbot is installed on the device (Android only). + * On iOS, returns false — users must configure manually. + */ + static async isOrbotInstalled(): Promise { + if (Platform.OS !== 'android') return false; + try { + const { PackageManager } = NativeModules; + if (!PackageManager?.isPackageInstalled) return false; + return await PackageManager.isPackageInstalled(ORBOT_PACKAGE); + } catch { + return false; + } + } + + /** Try to launch Orbot app (Android only) */ + static async launchOrbot(): Promise { + if (Platform.OS !== 'android') return false; + try { + const { PackageManager } = NativeModules; + if (!PackageManager?.launchPackage) return false; + return await PackageManager.launchPackage(ORBOT_PACKAGE); + } catch { + return false; + } + } + + /** Open Orbot's store listing for installation */ + static openOrbotInstallPage(): void { + if (Platform.OS === 'android') { + Linking.openURL('market://details?id=org.torproject.android').catch(() => { + Linking.openURL('https://play.google.com/store/apps/details?id=org.torproject.android'); + }); + } else { + Linking.openURL('https://apps.apple.com/app/orbot/id1609461599'); + } + } + + private _testSocksProxy(): Promise { + return new Promise(resolve => { + const timeout = setTimeout(() => { + try { + client.destroy(); + } catch {} + resolve(false); + }, 5000); + + const client = TcpSocket.createConnection( + { host: DEFAULT_SOCKS_HOST, port: this._settings.socksPort }, + () => { + // Send SOCKS5 greeting: version 5, 1 method, no-auth + const greeting = Buffer.from([0x05, 0x01, 0x00]); + client.write(greeting); + }, + ); + + client.on('data', (data: string | Buffer) => { + clearTimeout(timeout); + try { + client.destroy(); + } catch {} + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); + // Valid SOCKS5 response: version 5, no auth method selected + resolve(buf.length >= 2 && buf[0] === 0x05 && buf[1] === 0x00); + }); + + client.on('error', () => { + clearTimeout(timeout); + resolve(false); + }); + }); + } + + private _setStatus(status: TorStatus): void { + if (this._status !== status) { + this._status = status; + console.log(`[TorManager] Status: ${status}`); + this._listeners.forEach(cb => cb(status)); + } + } + + addStatusListener(listener: (status: TorStatus) => void): () => void { + this._listeners.add(listener); + return () => { + this._listeners.delete(listener); + }; + } +} + +export default TorManager; diff --git a/components/Context/SettingsProvider.tsx b/components/Context/SettingsProvider.tsx index 6fa8b5df7..8a2b81056 100644 --- a/components/Context/SettingsProvider.tsx +++ b/components/Context/SettingsProvider.tsx @@ -15,6 +15,7 @@ import { } from '../../hooks/useDeviceQuickActions'; import { useStorage } from '../../hooks/context/useStorage'; import { BitcoinUnit } from '../../models/bitcoinUnits'; +import TorManager, { type TorStatus } from '../../blue_modules/torManager'; const TotalWalletsBalanceKey = 'TotalWalletsBalance'; const TotalWalletsBalancePreferredUnit = 'TotalWalletsBalancePreferredUnit'; @@ -95,6 +96,13 @@ interface SettingsContextType { setBlockExplorerStorage: (explorer: BlockExplorer) => Promise; isElectrumDisabled: boolean; setIsElectrumDisabled: (value: boolean) => void; + isTorEnabled: boolean; + setIsTorEnabled: (value: boolean) => Promise; + isTorOnly: boolean; + setIsTorOnly: (value: boolean) => Promise; + torSocksPort: number; + setTorSocksPort: (port: number) => Promise; + torStatus: TorStatus; } const defaultSettingsContext: SettingsContextType = { @@ -120,6 +128,13 @@ const defaultSettingsContext: SettingsContextType = { setBlockExplorerStorage: async () => false, isElectrumDisabled: false, setIsElectrumDisabled: () => {}, + isTorEnabled: false, + setIsTorEnabled: async () => {}, + isTorOnly: false, + setIsTorOnly: async () => {}, + torSocksPort: 9050, + setTorSocksPort: async () => {}, + torStatus: 'disabled' as TorStatus, }; export const SettingsContext = createContext(defaultSettingsContext); @@ -136,6 +151,10 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = React.m const [totalBalancePreferredUnit, setTotalBalancePreferredUnit] = useState(BitcoinUnit.BTC); const [selectedBlockExplorer, setSelectedBlockExplorer] = useState(BLOCK_EXPLORERS.default); const [isElectrumDisabled, setIsElectrumDisabled] = useState(true); + const [isTorEnabled, setIsTorEnabledState] = useState(false); + const [isTorOnly, setIsTorOnlyState] = useState(false); + const [torSocksPort, setTorSocksPortState] = useState(9050); + const [torStatus, setTorStatus] = useState('disabled'); const { walletsInitialized } = useStorage(); @@ -176,6 +195,12 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = React.m const predefinedExplorer = Object.values(BLOCK_EXPLORERS).find(explorer => normalizeUrl(explorer.url) === normalizeUrl(url)); setSelectedBlockExplorer(predefinedExplorer ?? ({ key: 'custom', name: 'Custom', url } as BlockExplorer)); }), + TorManager.getInstance().loadSettings().then(settings => { + setIsTorEnabledState(settings.enabled); + setIsTorOnlyState(settings.torOnly); + setTorSocksPortState(settings.socksPort); + setTorStatus(TorManager.getInstance().status); + }), ]; const results = await Promise.allSettled(promises); @@ -301,6 +326,46 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = React.m } }, []); + const setIsTorEnabled = useCallback(async (value: boolean): Promise => { + try { + const torManager = TorManager.getInstance(); + await torManager.setEnabled(value); + setIsTorEnabledState(value); + setTorStatus(torManager.status); + } catch (e) { + console.error('Error setting Tor enabled:', e); + } + }, []); + + const setIsTorOnly = useCallback(async (value: boolean): Promise => { + try { + const torManager = TorManager.getInstance(); + await torManager.setTorOnly(value); + setIsTorOnlyState(value); + } catch (e) { + console.error('Error setting Tor-only mode:', e); + } + }, []); + + const setTorSocksPort = useCallback(async (port: number): Promise => { + try { + const torManager = TorManager.getInstance(); + await torManager.setSocksPort(port); + setTorSocksPortState(port); + setTorStatus(torManager.status); + } catch (e) { + console.error('Error setting Tor SOCKS port:', e); + } + }, []); + + // Listen for Tor status changes + useEffect(() => { + const unsubscribe = TorManager.getInstance().addStatusListener(status => { + setTorStatus(status); + }); + return unsubscribe; + }, []); + const value = useMemo( () => ({ preferredFiatCurrency, @@ -325,6 +390,13 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = React.m setBlockExplorerStorage, isElectrumDisabled, setIsElectrumDisabled, + isTorEnabled, + setIsTorEnabled, + isTorOnly, + setIsTorOnly, + torSocksPort, + setTorSocksPort, + torStatus, }), [ preferredFiatCurrency, @@ -348,6 +420,13 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = React.m selectedBlockExplorer, setBlockExplorerStorage, isElectrumDisabled, + isTorEnabled, + setIsTorEnabled, + isTorOnly, + setIsTorOnly, + torSocksPort, + setTorSocksPort, + torStatus, ], ); diff --git a/helpers/silent-payments/IndexerHttpClient.ts b/helpers/silent-payments/IndexerHttpClient.ts index d42ee980a..8015aa00d 100644 --- a/helpers/silent-payments/IndexerHttpClient.ts +++ b/helpers/silent-payments/IndexerHttpClient.ts @@ -1,12 +1,81 @@ import { fetchWithRetries } from '../../util/fetch'; +import { socks5Fetch } from '../../blue_modules/socks5Fetch'; +import TorManager from '../../blue_modules/torManager'; export class IndexerHttpClient { + private onionUrl?: string; + constructor( private baseUrl: string, - private timeout: number = 30000 - ) {} + private timeout: number = 30000, + onionUrl?: string, + ) { + this.onionUrl = onionUrl?.replace(/\/$/, ''); + } private async executeGet(endpoint: string, errorContext: string): Promise { + const torManager = TorManager.getInstance(); + + // Try Tor/onion route first when available + if (torManager.settings.enabled && this.onionUrl) { + const retryAttempts = torManager.retryAttempts; + + for (let attempt = 1; attempt <= retryAttempts; attempt++) { + if (torManager.isReady) { + try { + const response = await socks5Fetch(`${this.onionUrl}${endpoint}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + timeout: this.timeout, + socksHost: torManager.socksHost, + socksPort: torManager.socksPort, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (torError) { + const message = torError instanceof Error ? torError.message : String(torError); + console.warn( + `[IndexerHttpClient] Tor attempt ${attempt}/${retryAttempts} failed: ${message}`, + ); + + // Exponential backoff between retries + if (attempt < retryAttempts) { + const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000); + await new Promise(resolve => setTimeout(resolve, delay)); + // Re-check connection before next attempt + await torManager.checkConnection(); + } + } + } else if (attempt < retryAttempts) { + // Tor not ready yet — wait and re-check + const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000); + await new Promise(resolve => setTimeout(resolve, delay)); + await torManager.checkConnection(); + } + } + + // All Tor attempts exhausted + if (torManager.isTorOnly) { + throw new Error( + `${errorContext}: Tor-only mode is enabled but all ${retryAttempts} Tor attempts failed. ` + + 'Clearnet fallback is blocked. Ensure Orbot is running.', + ); + } + + console.warn('[IndexerHttpClient] Tor exhausted, falling back to clearnet'); + } else if (torManager.isTorOnly) { + // Tor enabled in tor-only mode but no onion URL configured + throw new Error( + `${errorContext}: Tor-only mode is enabled but no .onion URL is configured. ` + + 'Set an onion URL or disable Tor-only mode.', + ); + } + + // Clearnet fallback try { const response = await fetchWithRetries(`${this.baseUrl}${endpoint}`, { method: 'GET', @@ -38,4 +107,12 @@ export class IndexerHttpClient { setBaseUrl(url: string): void { this.baseUrl = url.replace(/\/$/, ''); } + + getOnionUrl(): string | undefined { + return this.onionUrl; + } + + setOnionUrl(url: string | undefined): void { + this.onionUrl = url?.replace(/\/$/, ''); + } } diff --git a/helpers/silent-payments/types.ts b/helpers/silent-payments/types.ts index 02b94640d..6ac7fd124 100644 --- a/helpers/silent-payments/types.ts +++ b/helpers/silent-payments/types.ts @@ -45,6 +45,7 @@ export interface SilentPaymentUTXOSerializable extends Omit { component={NetworkSettings} options={navigationStyle({ title: loc.settings.network })(theme)} /> + { navigation.navigate('SettingsBlockExplorer'); }; + const navigateToTorSettings = () => { + navigation.navigate('TorSettings'); + }; + return ( + {isNotificationsCapable && ( diff --git a/screen/settings/Settings.tsx b/screen/settings/Settings.tsx index 18dcbc8fb..51e6b3b16 100644 --- a/screen/settings/Settings.tsx +++ b/screen/settings/Settings.tsx @@ -18,7 +18,7 @@ const Settings = () => { navigate('Currency')} testID="Currency" chevron /> navigate('Language')} testID="Language" chevron /> {/* navigate('EncryptStorage')} testID="SecurityButton" chevron /> */} - {/* navigate('NetworkSettings')} testID="NetworkSettings" chevron /> */} + navigate('NetworkSettings')} testID="NetworkSettings" chevron /> {/* navigate('ToolsScreen')} testID="Tools" chevron /> */} {/* TODO: Eventually make this a separate screen with proper description */} diff --git a/screen/settings/TorSettings.tsx b/screen/settings/TorSettings.tsx new file mode 100644 index 000000000..10eb5a8da --- /dev/null +++ b/screen/settings/TorSettings.tsx @@ -0,0 +1,238 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { StyleSheet, View, Switch, TextInput, ActivityIndicator, Platform } from 'react-native'; +import { BlueCard, BlueText } from '../../BlueComponents'; +import { useSettings } from '../../hooks/context/useSettings'; +import { useTheme } from '../../components/themes'; +import Button from '../../components/Button'; +import SafeAreaScrollView from '../../components/SafeAreaScrollView'; +import { BlueSpacing20 } from '../../components/BlueSpacing'; +import TorManager from '../../blue_modules/torManager'; + +const TorSettings: React.FC = () => { + const { colors } = useTheme(); + const { isTorEnabled, setIsTorEnabled, isTorOnly, setIsTorOnly, torSocksPort, setTorSocksPort, torStatus } = useSettings(); + const [portInput, setPortInput] = useState(String(torSocksPort)); + const [isChecking, setIsChecking] = useState(false); + const [orbotInstalled, setOrbotInstalled] = useState(null); + + const stylesHook = StyleSheet.create({ + inputContainer: { + borderColor: colors.formBorder, + borderBottomColor: colors.formBorder, + backgroundColor: colors.inputBackgroundColor, + }, + input: { + color: colors.foregroundColor, + }, + }); + + useEffect(() => { + TorManager.isOrbotInstalled() + .then(setOrbotInstalled) + .catch(() => setOrbotInstalled(null)); + }, []); + + const statusColor = torStatus === 'connected' ? '#4caf50' : torStatus === 'unavailable' ? '#f44336' : colors.foregroundColor; + const statusLabel = + torStatus === 'disabled' + ? 'Disabled' + : torStatus === 'checking' + ? 'Checking...' + : torStatus === 'connected' + ? 'Connected to Orbot' + : 'Orbot Unavailable'; + + const handleToggle = useCallback( + async (value: boolean) => { + await setIsTorEnabled(value); + }, + [setIsTorEnabled], + ); + + const handleTorOnlyToggle = useCallback( + async (value: boolean) => { + await setIsTorOnly(value); + }, + [setIsTorOnly], + ); + + const handleSavePort = useCallback(async () => { + const port = parseInt(portInput, 10); + if (isNaN(port) || port < 1 || port > 65535) { + return; + } + await setTorSocksPort(port); + }, [portInput, setTorSocksPort]); + + const handleTestConnection = useCallback(async () => { + setIsChecking(true); + await TorManager.getInstance().checkConnection(); + setIsChecking(false); + }, []); + + const handleLaunchOrbot = useCallback(async () => { + await TorManager.launchOrbot(); + }, []); + + const handleInstallOrbot = useCallback(async () => { + await TorManager.openOrbotInstallPage(); + }, []); + + return ( + + + Use Orbot (Tor) + + Route indexer requests through Orbot's SOCKS5 proxy. When enabled, the app will try to connect to the .onion address first + and fall back to clearnet if Tor is unavailable. + + + Enable Tor + + + + {isTorEnabled && ( + <> + + + Tor-Only Mode + Blocks all clearnet fallback + + + + + )} + + + + + Status: + {statusLabel} + {torStatus === 'checking' && } + + + {Platform.OS === 'android' && orbotInstalled !== null && ( + <> + + + Orbot: + + {orbotInstalled ? 'Installed' : 'Not Installed'} + + + + )} + + + + SOCKS5 Port + Default: 9050 (Orbot), 9150 (Tor Browser). Change only if you have a custom configuration. + + + + + + + {isTorEnabled && ( + +