commit acdd333172f67847e2c363172b55f67e9992e4ee parent 618f06e2db5612d4219f3afd1a86cc2cba3262d0 Author: Pier Angelo Vendrame <pierov@torproject.org> Date: Tue, 31 Oct 2023 23:48:32 +0100 TB 42247: Android helpers for the TorProvider GeckoView is missing some API we use on desktop for the integration with the tor daemon, such as subprocess. Therefore, we need to implement them in Java and plumb the data back and forth between JS and Java. Diffstat:
15 files changed, 1480 insertions(+), 0 deletions(-)
diff --git a/browser/app/profile/000-tor-browser.js b/browser/app/profile/000-tor-browser.js @@ -139,3 +139,4 @@ pref("browser.torsettings.log_level", "Warn"); pref("browser.torMoat.loglevel", "Warn"); pref("browser.tordomainisolator.loglevel", "Warn"); pref("browser.torcircuitpanel.loglevel", "Log"); +pref("browser.tor_android.log_level", "Info"); diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java @@ -265,6 +265,8 @@ public final class GeckoRuntime implements Parcelable { private final CrashPullController.CrashPullProxy mCrashPullProxy; private final GeckoScreenChangeListener mScreenChangeListener; + private TorAndroidIntegration mTorIntegration; + private GeckoRuntime() { mWebExtensionController = new WebExtensionController(this); mContentBlockingController = new ContentBlockingController(); @@ -576,6 +578,8 @@ public final class GeckoRuntime implements Parcelable { mScreenChangeListener.enable(); } + mTorIntegration = new TorAndroidIntegration(context); + ProfilerController.addMarker( "GeckoView Initialization START", ProfilerController.getProfilerTime()); return true; @@ -676,6 +680,10 @@ public final class GeckoRuntime implements Parcelable { mScreenChangeListener.disable(); } + if (mTorIntegration != null) { + mTorIntegration.shutdown(); + } + GeckoThread.forceQuit(); } @@ -1145,6 +1153,14 @@ public final class GeckoRuntime implements Parcelable { } /** + * Get the Tor integration controller for this runtime. + */ + @UiThread + public @NonNull TorAndroidIntegration getTorIntegrationController() { + return mTorIntegration; + } + + /** * Appends notes to crash report. * * @param notes The application notes to append to the crash report. diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java @@ -2755,6 +2755,24 @@ public class GeckoSession { } /** + * Try to get last circuit used in this session, if possible. + * + * @return The circuit information as a {@link GeckoResult} object. + */ + @AnyThread + public @NonNull GeckoResult<GeckoBundle> getTorCircuit() { + return mEventDispatcher.queryBundle("GeckoView:GetTorCircuit"); + } + + /** + * Change the circuit for this session. + */ + @UiThread + public void newTorCircuit() { + mEventDispatcher.dispatch("GeckoView:NewTorCircuit", null); + } + + /** * Set this GeckoSession as active or inactive, which represents if the session is currently * visible or not. Setting a GeckoSession to inactive will significantly reduce its memory * footprint, but should only be done if the GeckoSession is not currently visible. Note that a diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorAndroidIntegration.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorAndroidIntegration.java @@ -0,0 +1,756 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.content.Context; +import android.os.AsyncTask; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.InterruptedIOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.geckoview.androidlegacysettings.TorLegacyAndroidSettings; + +public class TorAndroidIntegration implements BundleEventListener { + private static final String TAG = "TorAndroidIntegration"; + + // Events we listen to + private static final String EVENT_TOR_START = "GeckoView:Tor:StartTor"; + private static final String EVENT_TOR_STOP = "GeckoView:Tor:StopTor"; + private static final String EVENT_MEEK_START = "GeckoView:Tor:StartMeek"; + private static final String EVENT_MEEK_STOP = "GeckoView:Tor:StopMeek"; + private static final String EVENT_CONNECT_STAGE_CHANGED = "GeckoView:Tor:ConnectStageChanged"; + private static final String EVENT_BOOTSTRAP_PROGRESS = "GeckoView:Tor:BootstrapProgress"; + private static final String EVENT_TOR_LOGS = "GeckoView:Tor:Logs"; + private static final String EVENT_SETTINGS_READY = "GeckoView:Tor:SettingsReady"; + private static final String EVENT_SETTINGS_CHANGED = "GeckoView:Tor:SettingsChanged"; + + // Events we emit + private static final String EVENT_SETTINGS_GET = "GeckoView:Tor:SettingsGet"; + private static final String EVENT_SETTINGS_SET = "GeckoView:Tor:SettingsSet"; + private static final String EVENT_BOOTSTRAP_BEGIN = "GeckoView:Tor:BootstrapBegin"; + private static final String EVENT_BOOTSTRAP_BEGIN_AUTO = "GeckoView:Tor:BootstrapBeginAuto"; + private static final String EVENT_BOOTSTRAP_CANCEL = "GeckoView:Tor:BootstrapCancel"; + private static final String EVENT_BOOTSTRAP_GET_STATE = "GeckoView:Tor:BootstrapGetState"; + private static final String EVENT_START_AGAIN = "GeckoView:Tor:StartAgain"; + private static final String EVENT_QUICKSTART_GET = "GeckoView:Tor:QuickstartGet"; + private static final String EVENT_QUICKSTART_SET = "GeckoView:Tor:QuickstartSet"; + private static final String EVENT_REGION_NAMES_GET = "GeckoView:Tor:RegionNamesGet"; + private static final String EVENT_SHOULD_SHOW_TOR_CONNECT = "GeckoView:Tor:ShouldShowTorConnect"; + + private static final String CONTROL_PORT_FILE = "/control-ipc"; + private static final String SOCKS_FILE = "/socks-ipc"; + private static final String COOKIE_AUTH_FILE = "/auth-file"; + + private final String mLibraryDir; + private final String mCacheDir; + private final String mIpcDirectory; + private final File mDataDir; + + private TorProcess mTorProcess = null; + + /** + * The first time we run a Tor process in this session, we copy some configuration files to be + * sure we always have the latest version, but if we re-launch a tor process we do not need to + * copy them again. + */ + private boolean mCopiedConfigFiles = false; + + /** + * Allow multiple proxies to be started, even though it might not actually happen. The key should + * be positive (also 0 is not allowed). + */ + private final HashMap<Integer, MeekTransport> mMeeks = new HashMap<>(); + + private int mMeekCounter; + + private final MutableLiveData<TorConnectStage> _lastKnownStage = new MutableLiveData<>(null); + public LiveData<TorConnectStage> lastKnowStage = _lastKnownStage; + + /** + * mSettings is a Java-side copy of the authoritative settings in the JS code. It's useful to + * maintain as the UI may be fetching these options often and we don't watch each fetch to be a + * passthrough to JS with marshalling/unmarshalling each time. + */ + private TorSettings mSettings = null; + + /* package */ TorAndroidIntegration(Context context) { + mLibraryDir = context.getApplicationInfo().nativeLibraryDir; + mCacheDir = context.getCacheDir().getAbsolutePath(); + mIpcDirectory = mCacheDir + "/tor-private"; + mDataDir = new File(context.getFilesDir(), "tor"); + registerListener(); + } + + /* package */ synchronized void shutdown() { + // FIXME: It seems this never gets called + if (mTorProcess != null) { + mTorProcess.shutdown(); + mTorProcess = null; + } + } + + private void registerListener() { + EventDispatcher.getInstance() + .registerUiThreadListener( + this, + EVENT_TOR_START, + EVENT_MEEK_START, + EVENT_MEEK_STOP, + EVENT_SETTINGS_READY, + EVENT_SETTINGS_CHANGED, + EVENT_CONNECT_STAGE_CHANGED, + EVENT_BOOTSTRAP_PROGRESS, + EVENT_TOR_LOGS); + } + + @Override // BundleEventListener + public synchronized void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + if (EVENT_TOR_START.equals(event)) { + startDaemon(message, callback); + } else if (EVENT_TOR_STOP.equals(event)) { + stopDaemon(message, callback); + } else if (EVENT_MEEK_START.equals(event)) { + startMeek(message, callback); + } else if (EVENT_MEEK_STOP.equals(event)) { + stopMeek(message, callback); + } else if (EVENT_SETTINGS_READY.equals(event)) { + try { + new SettingsLoader().execute(message); + } catch (Exception e) { + Log.e(TAG, "SettingsLoader error: " + e.toString()); + } + } else if (EVENT_SETTINGS_CHANGED.equals(event)) { + GeckoBundle newSettings = message.getBundle("settings"); + if (newSettings != null) { + // TODO: Should we notify listeners? + mSettings = new TorSettings(newSettings); + } else { + Log.w(TAG, "Ignoring a settings changed event that did not have the new settings."); + } + } else if (EVENT_CONNECT_STAGE_CHANGED.equals(event)) { + TorConnectStage stage = new TorConnectStage(message.getBundle("stage")); + _lastKnownStage.setValue(stage); + for (BootstrapStateChangeListener listener : mBootstrapStateListeners) { + listener.onBootstrapStageChange(stage); + } + } else if (EVENT_BOOTSTRAP_PROGRESS.equals(event)) { + double progress = message.getDouble("progress"); + boolean hasWarnings = message.getBoolean("hasWarnings"); + for (BootstrapStateChangeListener listener : mBootstrapStateListeners) { + listener.onBootstrapProgress(progress, hasWarnings); + } + } else if (EVENT_TOR_LOGS.equals(event)) { + String msg = message.getString("message"); + String type = message.getString("logType"); + String timestamp = message.getString("timestamp"); + for (TorLogListener listener : mLogListeners) { + listener.onLog(type, msg, timestamp); + } + } + } + + private class SettingsLoader extends AsyncTask<GeckoBundle, Void, TorSettings> { + protected TorSettings doInBackground(GeckoBundle... messages) { + GeckoBundle message = messages[0]; + TorSettings settings; + if (TorLegacyAndroidSettings.unmigrated()) { + settings = TorLegacyAndroidSettings.loadTorSettings(); + } else { + GeckoBundle bundle = message.getBundle("settings"); + settings = new TorSettings(bundle); + } + return settings; + } + + @Override + protected void onPostExecute(TorSettings torSettings) { + mSettings = torSettings; + if (TorLegacyAndroidSettings.unmigrated()) { + setSettings(mSettings); + TorLegacyAndroidSettings.setMigrated(); + } + } + } + + private synchronized void startDaemon(final GeckoBundle message, final EventCallback callback) { + // Let JS generate this to possibly reduce the chance of race conditions. + String handle = message.getString("handle", ""); + if (handle.isEmpty()) { + Log.e(TAG, "Requested to start a tor process without a handle."); + callback.sendError("Expected a handle for the new process."); + return; + } + Log.d(TAG, "Starting the a tor process with handle " + handle); + + TorProcess previousProcess = mTorProcess; + if (previousProcess != null) { + Log.w(TAG, "We still have a running process: " + previousProcess.getHandle()); + } + + boolean tcpSocks = message.getBoolean("tcpSocks", false); + mTorProcess = new TorProcess(handle, tcpSocks); + + GeckoBundle bundle = new GeckoBundle(3); + bundle.putString("controlPortPath", mIpcDirectory + CONTROL_PORT_FILE); + bundle.putString("cookieFilePath", mIpcDirectory + COOKIE_AUTH_FILE); + if (tcpSocks) { + bundle.putInt("socksPort", 0); + } else { + bundle.putString("socksPath", mIpcDirectory + SOCKS_FILE); + } + callback.sendSuccess(bundle); + } + + private synchronized void stopDaemon(final GeckoBundle message, final EventCallback callback) { + if (mTorProcess == null) { + if (callback != null) { + callback.sendSuccess(null); + } + return; + } + String handle = message.getString("handle", ""); + if (!mTorProcess.getHandle().equals(handle)) { + GeckoBundle bundle = new GeckoBundle(1); + bundle.putString( + "error", "The requested process has not been found. It might have already been stopped."); + callback.sendError(bundle); + return; + } + mTorProcess.shutdown(); + mTorProcess = null; + callback.sendSuccess(null); + } + + class TorProcess extends Thread { + private static final String EVENT_TOR_STARTED = "GeckoView:Tor:TorStarted"; + private static final String EVENT_TOR_START_FAILED = "GeckoView:Tor:TorStartFailed"; + private static final String EVENT_TOR_EXITED = "GeckoView:Tor:TorExited"; + private final String mHandle; + private final boolean mTcpSocks; + private Process mProcess = null; + + TorProcess(String handle, boolean tcpSocks) { + mHandle = handle; + mTcpSocks = tcpSocks; + setName("tor-process-" + handle); + start(); + } + + @Override + public void run() { + cleanIpcDirectory(); + + final String ipcDir = TorAndroidIntegration.this.mIpcDirectory; + final ArrayList<String> args = new ArrayList<>(); + args.add(mLibraryDir + "/libTor.so"); + args.add("DisableNetwork"); + args.add("1"); + args.add("+__ControlPort"); + args.add("unix:" + ipcDir + CONTROL_PORT_FILE); + final String socksFlags = " IPv6Traffic PreferIPv6 KeepAliveIsolateSOCKSAuth"; + args.add("+__SocksPort"); + args.add("unix:" + ipcDir + SOCKS_FILE + socksFlags); + if (mTcpSocks) { + args.add("+__SocksPort"); + args.add("auto " + socksFlags); + } + args.add("CookieAuthentication"); + args.add("1"); + args.add("CookieAuthFile"); + args.add(ipcDir + COOKIE_AUTH_FILE); + args.add("DataDirectory"); + args.add(mDataDir.getAbsolutePath()); + boolean copied = true; + try { + copyAndUseConfigFile("--defaults-torrc", "torrc-defaults", args); + } catch (IOException e) { + Log.w( + TAG, "torrc-default cannot be created, pluggable transports will not be available", e); + copied = false; + } + // tor-browser#42607: For now we do not ship geoip databases, as we + // do not have the circuit display functionality and they allow us + // to save some space in the final APK. + /*try { + copyAndUseConfigFile("GeoIPFile", "geoip", args); + copyAndUseConfigFile("GeoIPv6File", "geoip6", args); + } catch (IOException e) { + Log.w(TAG, "GeoIP files cannot be created, this feature will not be available.", e); + copied = false; + }*/ + mCopiedConfigFiles = copied; + + Log.d(TAG, "Starting tor with the follwing args: " + args.toString()); + final ProcessBuilder builder = new ProcessBuilder(args); + builder.directory(new File(mLibraryDir)); + try { + mProcess = builder.start(); + } catch (IOException e) { + Log.e(TAG, "Cannot start tor " + mHandle, e); + final GeckoBundle data = new GeckoBundle(2); + data.putString("handle", mHandle); + data.putString("error", e.getMessage()); + EventDispatcher.getInstance().dispatch(EVENT_TOR_START_FAILED, data); + return; + } + Log.i(TAG, "Tor process " + mHandle + " started."); + { + final GeckoBundle data = new GeckoBundle(1); + data.putString("handle", mHandle); + EventDispatcher.getInstance().dispatch(EVENT_TOR_STARTED, data); + } + try { + BufferedReader reader = + new BufferedReader(new InputStreamReader(mProcess.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + Log.i(TAG, "[tor-" + mHandle + "] " + line); + } + } catch (IOException e) { + Log.e(TAG, "Failed to read stdout of the tor process " + mHandle, e); + } + Log.d(TAG, "Exiting the stdout loop for process " + mHandle); + final GeckoBundle data = new GeckoBundle(2); + data.putString("handle", mHandle); + try { + data.putInt("status", mProcess.waitFor()); + } catch (InterruptedException e) { + Log.e(TAG, "Failed to wait for the tor process " + mHandle, e); + data.putInt("status", 0xdeadbeef); + } + // FIXME: We usually don't reach this when the application is killed! + // So, we don't do our cleanup. + Log.i(TAG, "Tor process " + mHandle + " has exited."); + EventDispatcher.getInstance().dispatch(EVENT_TOR_EXITED, data); + } + + private void cleanIpcDirectory() { + File directory = new File(TorAndroidIntegration.this.mIpcDirectory); + if (!directory.isDirectory()) { + if (!directory.mkdirs()) { + Log.e(TAG, "Failed to create the IPC directory."); + return; + } + try { + // First remove the permissions for everybody... + directory.setReadable(false, false); + directory.setWritable(false, false); + directory.setExecutable(false, false); + // ... then add them back, but only for the owner. + directory.setReadable(true, true); + directory.setWritable(true, true); + directory.setExecutable(true, true); + } catch (SecurityException e) { + Log.e(TAG, "Could not set the permissions to the IPC directory.", e); + } + return; + } + // We assume we do not have child directories, only files + File[] maybeFiles = directory.listFiles(); + if (maybeFiles != null) { + for (File file : maybeFiles) { + if (!file.delete()) { + Log.d(TAG, "Could not delete " + file); + } + } + } + } + + private void copyAndUseConfigFile(String option, String name, ArrayList<String> args) + throws IOException { + File file = copyConfigFile(name); + args.add(option); + args.add(file.getAbsolutePath()); + } + + private File copyConfigFile(String name) throws IOException { + final File file = new File(mCacheDir, name); + if (mCopiedConfigFiles && file.exists()) { + return file; + } + + final Context context = GeckoAppShell.getApplicationContext(); + final InputStream in = context.getAssets().open("common/" + name); + // Files.copy is API 26+, so use java.io and a loop for now. + FileOutputStream out = null; + try { + out = new FileOutputStream(file); + } catch (IOException e) { + in.close(); + throw e; + } + try { + byte buffer[] = new byte[4096]; + int read; + while ((read = in.read(buffer)) >= 0) { + out.write(buffer, 0, read); + } + } finally { + try { + in.close(); + } catch (IOException e) { + Log.w(TAG, "Cannot close the input stream for " + name); + } + try { + out.close(); + } catch (IOException e) { + Log.w(TAG, "Cannot close the output stream for " + name); + } + } + return file; + } + + public void shutdown() { + if (mProcess != null && mProcess.isAlive()) { + mProcess.destroy(); + } + if (isAlive()) { + try { + join(); + } catch (InterruptedException e) { + Log.e( + TAG, + "Cannot join the thread for tor process " + mHandle + ", possibly already terminated", + e); + } + } + } + + public String getHandle() { + return mHandle; + } + } + + private synchronized void startMeek(final GeckoBundle message, final EventCallback callback) { + if (callback == null) { + Log.e(TAG, "Tried to start Meek without a callback."); + return; + } + mMeekCounter++; + mMeeks.put( + new Integer(mMeekCounter), + new MeekTransport(callback, mMeekCounter, message.getStringArray("arguments"))); + } + + private synchronized void stopMeek(final GeckoBundle message, final EventCallback callback) { + final Integer key = message.getInteger("id"); + final MeekTransport meek = mMeeks.remove(key); + if (meek != null) { + meek.shutdown(); + } + if (callback != null) { + callback.sendSuccess(null); + } + } + + private class MeekTransport extends Thread { + private static final String TRANSPORT = "meek_lite"; + private Process mProcess; + private final EventCallback mCallback; + private final int mId; + + MeekTransport(final EventCallback callback, int id, String[] args) { + setName("meek-" + id); + + final String command = mLibraryDir + "/libLyrebird.so"; + ArrayList<String> argList = new ArrayList<String>(); + argList.add(command); + if (args != null && args.length > 0) { + // Normally not used, but it helps to debug only by editing JS. + Log.d(TAG, "Requested custom arguments for meek: " + String.join(" ", args)); + argList.addAll(Arrays.asList(args)); + } + final ProcessBuilder builder = new ProcessBuilder(argList); + + File ptStateDir = new File(mDataDir, "pt_state"); + Log.d(TAG, "Using " + ptStateDir.getAbsolutePath() + " as a state directory for meek."); + final Map<String, String> env = builder.environment(); + env.put("TOR_PT_MANAGED_TRANSPORT_VER", "1"); + env.put("TOR_PT_STATE_LOCATION", ptStateDir.getAbsolutePath()); + env.put("TOR_PT_EXIT_ON_STDIN_CLOSE", "1"); + env.put("TOR_PT_CLIENT_TRANSPORTS", TRANSPORT); + + mCallback = callback; + mId = id; + try { + // We expect this process to be short-lived, therefore we do not bother with + // implementing this as a service. + mProcess = builder.start(); + } catch (IOException e) { + Log.e(TAG, "Cannot start the PT", e); + callback.sendError(e.getMessage()); + return; + } + start(); + } + + /** + * Parse the standard output of the pluggable transport to find the hostname and port it is + * listening on. + * + * <p>See also the specs for the IPC protocol at https://spec.torproject.org/pt-spec/ipc.html. + */ + @Override + public void run() { + final String PROTOCOL_VERSION = "1"; + String hostname = ""; + boolean valid = false; + int port = 0; + String error = "Did not see a CMETHOD"; + try { + InputStreamReader isr = new InputStreamReader(mProcess.getInputStream()); + BufferedReader reader = new BufferedReader(isr); + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + Log.d(TAG, "Meek line: " + line); + // Split produces always at least one item + String[] tokens = line.split(" "); + if ("VERSION".equals(tokens[0]) + && (tokens.length != 2 || !PROTOCOL_VERSION.equals(tokens[1]))) { + error = "Bad version: " + line; + break; + } + if ("CMETHOD".equals(tokens[0])) { + if (tokens.length != 4) { + error = "Bad number of tokens in CMETHOD: " + line; + break; + } + if (!tokens[1].equals(TRANSPORT)) { + error = "Unexpected transport: " + tokens[1]; + break; + } + if (!"socks5".equals(tokens[2])) { + error = "Unexpected proxy type: " + tokens[2]; + break; + } + String[] addr = tokens[3].split(":"); + if (addr.length != 2) { + error = "Invalid address"; + break; + } + hostname = addr[0]; + try { + port = Integer.parseInt(addr[1]); + } catch (NumberFormatException e) { + error = "Invalid port: " + e.getMessage(); + break; + } + if (port < 1 || port > 65535) { + error = "Invalid port: out of bounds"; + break; + } + valid = true; + break; + } + if (tokens[0].endsWith("-ERROR")) { + error = "Seen an error: " + line; + break; + } + } + } catch (Exception e) { + error = e.getMessage(); + } + if (valid) { + Log.d(TAG, "Setup a meek transport " + mId + ": " + hostname + ":" + port); + final GeckoBundle bundle = new GeckoBundle(3); + bundle.putInt("id", mId); + bundle.putString("address", hostname); + bundle.putInt("port", port); + mCallback.sendSuccess(bundle); + } else { + Log.e(TAG, "Failed to get a usable config from the PT: " + error); + mCallback.sendError(error); + return; + } + dumpStdout(); + } + + void shutdown() { + if (mProcess != null) { + Log.i(TAG, "Shutting down meek process " + mId); + mProcess.destroy(); + mProcess = null; + } else { + Log.w( + TAG, + "Shutdown request on the meek process " + mId + " that has already been shutdown."); + } + try { + join(); + } catch (InterruptedException e) { + Log.e(TAG, "Could not join the meek thread", e); + } + } + + void dumpStdout() { + try { + BufferedReader reader = + new BufferedReader(new InputStreamReader(mProcess.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + Log.d(TAG, "[meek-" + mId + "] " + line); + } + } catch (InterruptedIOException e) { + // This happens normally, do not log it. + } catch (IOException e) { + Log.e(TAG, "Failed to read stdout of the meek process process " + mId, e); + } + } + } + + public interface BootstrapStateChangeListener { + void onBootstrapStageChange(@NonNull TorConnectStage stage); // new upgrade + + void onBootstrapProgress(double progress, boolean hasWarnings); + } + + public interface TorLogListener { + void onLog(String logType, String message, String timestamp); + } + + private @NonNull void reloadSettings() { + EventDispatcher.getInstance() + .queryBundle(EVENT_SETTINGS_GET) + .then( + new GeckoResult.OnValueListener<GeckoBundle, Void>() { + public GeckoResult<Void> onValue(final GeckoBundle bundle) { + mSettings = new TorSettings(bundle); + return new GeckoResult<Void>(); + } + }); + } + + public TorSettings getSettings() { + return mSettings; + } + + public void setSettings(final TorSettings settings) { + mSettings = settings; + + emitSetSettings(settings) + .then( + new GeckoResult.OnValueListener<Void, Void>() { + public GeckoResult<Void> onValue(Void v) { + return new GeckoResult<Void>(); + } + }, + new GeckoResult.OnExceptionListener<Void>() { + public GeckoResult<Void> onException(final Throwable e) { + Log.e(TAG, "Failed to set settings", e); + reloadSettings(); + return new GeckoResult<Void>(); + } + }); + } + + private @NonNull GeckoResult<Void> emitSetSettings( + final TorSettings settings) { + GeckoBundle bundle = new GeckoBundle(1); + bundle.putBundle("settings", settings.asGeckoBundle()); + return EventDispatcher.getInstance().queryVoid(EVENT_SETTINGS_SET, bundle); + } + + public @NonNull GeckoResult<Void> startAgain() { + return EventDispatcher.getInstance().queryVoid(EVENT_START_AGAIN); + } + + public interface QuickstartGetter { + void onValue(boolean enabled); + } + + public void quickstartGet(QuickstartGetter quickstartGetter) { + EventDispatcher.getInstance().queryBoolean(EVENT_QUICKSTART_GET).then(enabled -> { + quickstartGetter.onValue(Boolean.TRUE.equals(enabled)); + return new GeckoResult<Void>(); + }); + } + + public @NonNull GeckoResult<Void> quickstartSet(boolean enabled) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putBoolean("enabled", enabled); + return EventDispatcher.getInstance().queryVoid(EVENT_QUICKSTART_SET, bundle); + } + + public interface RegionNamesGetter { + void onValue(GeckoBundle regionNames); + } + + public void regionNamesGet(RegionNamesGetter regionNamesGetter) { + EventDispatcher.getInstance().queryBundle(EVENT_REGION_NAMES_GET).then(regionNames -> { + regionNamesGetter.onValue(regionNames); + return new GeckoResult<Void>(); + }); + } + + public interface ShouldShowTorConnectGetter { + void onValue(Boolean shouldShowTorConnect); + } + + public void shouldShowTorConnectGet(ShouldShowTorConnectGetter shouldShowTorConnectGetter) { + EventDispatcher.getInstance().queryBoolean(EVENT_SHOULD_SHOW_TOR_CONNECT).then(shouldShowTorConnect -> { + shouldShowTorConnectGetter.onValue(shouldShowTorConnect); + return new GeckoResult<Void>(); + }); + } + + public @NonNull GeckoResult<Void> beginBootstrap() { + return EventDispatcher.getInstance().queryVoid(EVENT_BOOTSTRAP_BEGIN); + } + + public @NonNull GeckoResult<Void> beginAutoBootstrap(final String countryCode) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("countryCode", countryCode); + return EventDispatcher.getInstance().queryVoid(EVENT_BOOTSTRAP_BEGIN_AUTO, bundle); + } + + public @NonNull GeckoResult<Void> beginAutoBootstrap() { + return beginAutoBootstrap(null); + } + + public @NonNull GeckoResult<Void> cancelBootstrap() { + return EventDispatcher.getInstance().queryVoid(EVENT_BOOTSTRAP_CANCEL); + } + + public synchronized void registerBootstrapStateChangeListener(BootstrapStateChangeListener listener) { + mBootstrapStateListeners.add(listener); + } + + public synchronized void unregisterBootstrapStateChangeListener(BootstrapStateChangeListener listener) { + mBootstrapStateListeners.remove(listener); + } + + private final HashSet<BootstrapStateChangeListener> mBootstrapStateListeners = new HashSet<>(); + + public synchronized void registerLogListener(TorLogListener listener) { + mLogListeners.add(listener); + } + + public synchronized void unregisterLogListener(TorLogListener listener) { + mLogListeners.remove(listener); + } + + private final HashSet<TorLogListener> mLogListeners = new HashSet<>(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorBootstrappingStatus.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorBootstrappingStatus.java @@ -0,0 +1,14 @@ +package org.mozilla.geckoview; + +import org.mozilla.gecko.util.GeckoBundle; + +// Class to receive BootstrappingStatus object from TorConnect.sys.mjs ~ln698 +public class TorBootstrappingStatus { + public int progress; // percent of bootstrap progress + public boolean hasWarning; // Whether this bootstrap has a warning in the tor log + + public TorBootstrappingStatus(GeckoBundle bundle) { + progress = bundle.getInt("progress"); + hasWarning = bundle.getBoolean("hasWarning"); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorConnectError.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorConnectError.java @@ -0,0 +1,24 @@ +package org.mozilla.geckoview; + +import org.mozilla.gecko.util.GeckoBundle; + +public class TorConnectError { + public String code; + public String message; + public String phase; + public String reason; + + public TorConnectError(GeckoBundle bundle) { + code = bundle.getString("code"); + message = bundle.getString("message"); + phase = bundle.getString("phase"); + reason = bundle.getString("reason"); + } + + public TorConnectError(String code, String message, String phase, String reason) { + this.code = code; + this.message = message; + this.phase = phase; + this.reason = reason; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorConnectStage.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorConnectStage.java @@ -0,0 +1,34 @@ +package org.mozilla.geckoview; + +import org.mozilla.gecko.util.GeckoBundle; + +// Class to receive ConnectStage object from TorConnect.sys.mjs ~ln677 +public class TorConnectStage { + + public TorConnectStageName name; + // The TorConnectStage prior to this bootstrap attempt. Only set during the "Bootstrapping" stage. + public TorConnectStageName bootstrapTrigger; + public TorConnectError error; + public String defaultRegion; + public Boolean potentiallyBlocked; + public Boolean tryAgain; + public TorBootstrappingStatus bootstrappingStatus; + + public TorConnectStage(GeckoBundle bundle) { + name = TorConnectStageName.fromString(bundle.getString("name")); + if (bundle.getString("bootstrapTrigger") != null) { + bootstrapTrigger = TorConnectStageName.fromString(bundle.getString("bootstrapTrigger")); + } + defaultRegion = bundle.getString("defaultRegion"); + potentiallyBlocked = bundle.getBoolean("potentiallyBlocked"); + tryAgain = bundle.getBoolean("tryAgain"); + if (bundle.getBundle("error") != null) { + error = new TorConnectError(bundle.getBundle("error")); + } + bootstrappingStatus = new TorBootstrappingStatus(bundle.getBundle("bootstrappingStatus")); + } + + public Boolean isBootstrapped() { + return name.isBootstrapped(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorConnectStageName.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorConnectStageName.java @@ -0,0 +1,43 @@ +package org.mozilla.geckoview; + +import java.security.InvalidParameterException; + +public enum TorConnectStageName { + // These names should match entries from TorConnectStage in TorConnect.sys.mjs at ~ln163. + Disabled("Disabled"), + Loading("Loading"), + Start("Start"), + Bootstrapping("Bootstrapping"), + Offline("Offline"), + ChooseRegion("ChooseRegion"), + RegionNotFound("RegionNotFound"), + ConfirmRegion("ConfirmRegion"), + FinalError("FinalError"), + Bootstrapped("Bootstrapped"); + + private String valueText; + + TorConnectStageName(String valueText) { + this.valueText = valueText; + } + + public Boolean isBootstrapped() { + return this == Bootstrapped; + } + + public String getString() { + return this.valueText; + } + + public static TorConnectStageName fromString(String text) { + for (TorConnectStageName tcs : TorConnectStageName.values()) { + if (tcs.valueText.equalsIgnoreCase(text)) { + return tcs; + } + } + if (BuildConfig.BUILD_TYPE == "debug") { + throw new InvalidParameterException("Unknown TorConnectStageName " + text); + } + return null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TorSettings.java @@ -0,0 +1,183 @@ +package org.mozilla.geckoview; + +import android.util.Log; +import org.mozilla.gecko.util.GeckoBundle; + +public class TorSettings { + + public enum BridgeSource { + Invalid(-1), + BuiltIn(0), + BridgeDB(1), + UserProvided(2); + + private int source; + + BridgeSource(final int source) { + this.source = source; + } + + public static BridgeSource fromInt(int i) { + switch (i) { + case -1: + return Invalid; + case 0: + return BuiltIn; + case 1: + return BridgeDB; + case 2: + return UserProvided; + } + return Invalid; + } + + public int toInt() { + return this.source; + } + } + + public enum ProxyType { + Invalid(-1), + Socks4(0), + Socks5(1), + HTTPS(2); + + private int type; + + ProxyType(final int type) { + this.type = type; + } + + public int toInt() { + return type; + } + + public static ProxyType fromInt(int i) { + switch (i) { + case -1: + return Invalid; + case 0: + return Socks4; + case 1: + return Socks5; + case 2: + return HTTPS; + } + return Invalid; + } + } + + public enum BridgeBuiltinType { + /* TorSettings.sys.mjs ~ln43: string: obfs4|meek|snowflake|etc */ + Invalid("invalid"), + Obfs4("obfs4"), + Meek("meek"), + Snowflake("snowflake"); + + private String type; + + BridgeBuiltinType(String type) { + this.type = type; + } + + public String toString() { + return type; + } + + public static BridgeBuiltinType fromString(String s) { + switch (s) { + case "obfs4": + return Obfs4; + case "meek": + return Meek; + case "snowflake": + return Snowflake; + } + return Invalid; + } + } + + private boolean loaded = false; + + public boolean enabled = true; + + // bridges section + public boolean bridgesEnabled = false; + public BridgeSource bridgesSource = BridgeSource.Invalid; + public BridgeBuiltinType bridgesBuiltinType = BridgeBuiltinType.Invalid; + public String[] bridgeBridgeStrings; + + // proxy section + public boolean proxyEnabled = false; + public ProxyType proxyType = ProxyType.Invalid; + public String proxyAddress = ""; + public int proxyPort = 0; + public String proxyUsername = ""; + public String proxyPassword = ""; + + // firewall section + public boolean firewallEnabled = false; + public int[] firewallAllowedPorts; + + public TorSettings() {} + + public TorSettings(GeckoBundle bundle) { + try { + GeckoBundle bridges = bundle.getBundle("bridges"); + GeckoBundle proxy = bundle.getBundle("proxy"); + GeckoBundle firewall = bundle.getBundle("firewall"); + + bridgesEnabled = bridges.getBoolean("enabled"); + bridgesSource = BridgeSource.fromInt(bridges.getInt("source")); + bridgesBuiltinType = BridgeBuiltinType.fromString(bridges.getString("builtin_type")); + bridgeBridgeStrings = bridges.getStringArray("bridge_strings"); + + firewallEnabled = firewall.getBoolean("enabled"); + firewallAllowedPorts = firewall.getIntArray("allowed_ports"); + + proxyEnabled = proxy.getBoolean("enabled"); + proxyAddress = proxy.getString("address"); + proxyUsername = proxy.getString("username"); + proxyPassword = proxy.getString("password"); + proxyPort = proxy.getInt("port"); + proxyType = ProxyType.fromInt(proxy.getInt("type")); + + loaded = true; + } catch (Exception e) { + Log.e("TorSettings", "bundle access error: " + e.toString(), e); + } + } + + public GeckoBundle asGeckoBundle() { + GeckoBundle bundle = new GeckoBundle(); + + GeckoBundle bridges = new GeckoBundle(); + GeckoBundle proxy = new GeckoBundle(); + GeckoBundle firewall = new GeckoBundle(); + + bridges.putBoolean("enabled", bridgesEnabled); + bridges.putInt("source", bridgesSource.toInt()); + bridges.putString("builtin_type", bridgesBuiltinType.toString()); + bridges.putStringArray("bridge_strings", bridgeBridgeStrings); + + firewall.putBoolean("enabled", firewallEnabled); + firewall.putIntArray("allowed_ports", firewallAllowedPorts); + + proxy.putBoolean("enabled", proxyEnabled); + proxy.putString("address", proxyAddress); + proxy.putString("username", proxyUsername); + proxy.putString("password", proxyPassword); + proxy.putInt("port", proxyPort); + proxy.putInt("type", proxyType.toInt()); + + bundle.putBundle("bridges", bridges); + bundle.putBundle("proxy", proxy); + bundle.putBundle("firewall", firewall); + + return bundle; + } + + public boolean isLoaded() { + return this.loaded; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/androidlegacysettings/Prefs.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/androidlegacysettings/Prefs.java @@ -0,0 +1,69 @@ +package org.mozilla.geckoview.androidlegacysettings; + +import android.content.Context; +import android.content.SharedPreferences; +import java.util.Locale; +import org.mozilla.gecko.GeckoAppShell; + +// tor-android-service utils/Prefs.java + +/* package */ class Prefs { + private static final String PREF_BRIDGES_ENABLED = "pref_bridges_enabled"; + private static final String PREF_BRIDGES_LIST = "pref_bridges_list"; + + private static SharedPreferences prefs; + + // OrbotConstants + private static final String PREF_TOR_SHARED_PREFS = "org.torproject.android_preferences"; + + // tor-android-service utils/TorServiceUtil.java + + private static void setContext() { + if (prefs == null) { + prefs = + GeckoAppShell.getApplicationContext() + .getSharedPreferences(PREF_TOR_SHARED_PREFS, Context.MODE_MULTI_PROCESS); + } + } + + public static boolean getBoolean(String key, boolean def) { + setContext(); + return prefs.getBoolean(key, def); + } + + public static void putBoolean(String key, boolean value) { + setContext(); + prefs.edit().putBoolean(key, value).apply(); + } + + public static void putString(String key, String value) { + setContext(); + prefs.edit().putString(key, value).apply(); + } + + public static String getString(String key, String def) { + setContext(); + return prefs.getString(key, def); + } + + public static boolean bridgesEnabled() { + setContext(); + // for Locale.getDefault().getLanguage().equals("fa"), bridges were enabled by default (and + // it was meek). This was a default set in 2019 code, but it is not a good default anymore, + // so we removed the check. + return prefs.getBoolean(PREF_BRIDGES_ENABLED, false); + } + + public static String getBridgesList() { + setContext(); + String list = prefs.getString(PREF_BRIDGES_LIST, ""); + // list might be empty if the default PT was used, so check also if bridges are enabled. + if (list.isEmpty() && prefs.getBoolean(PREF_BRIDGES_ENABLED, false)) { + // Even though the check on the fa locale is not good to enable bridges by default, we + // still check it here, because if the list was empty, it was likely that it was the + // choice for users with this locale. + return (Locale.getDefault().getLanguage().equals("fa")) ? "meek" : "obfs4"; + } + return list; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/androidlegacysettings/TorLegacyAndroidSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/androidlegacysettings/TorLegacyAndroidSettings.java @@ -0,0 +1,71 @@ +package org.mozilla.geckoview.androidlegacysettings; + +import org.mozilla.geckoview.TorSettings; + +public class TorLegacyAndroidSettings { + + private static String PREF_USE_MOZ_PREFS = "tor_use_moz_prefs"; + + public static boolean unmigrated() { + return !Prefs.getBoolean(PREF_USE_MOZ_PREFS, false); + } + + public static void setUnmigrated() { + Prefs.putBoolean(PREF_USE_MOZ_PREFS, false); + } + + public static void setMigrated() { + Prefs.putBoolean(PREF_USE_MOZ_PREFS, true); + } + + public static TorSettings loadTorSettings() { + TorSettings settings = new TorSettings(); + + // always true, tor is enabled in TB + settings.enabled = true; + + settings.bridgesEnabled = Prefs.bridgesEnabled(); + + // tor-android-service CustomTorInstaller.java + /* + BridgesList is an overloaded field, which can cause some confusion. + The list can be: + 1) a filter like obfs4, meek, or snowflake OR + 2) it can be a custom bridge + For (1), we just pass back all bridges, the filter will occur + elsewhere in the library. + For (2) we return the bridge list as a raw stream. + If length is greater than 9, then we know this is a custom bridge + */ + String userDefinedBridgeList = Prefs.getBridgesList(); + boolean userDefinedBridge = userDefinedBridgeList.length() > 9; + // Terrible hack. Must keep in sync with topl::addBridgesFromResources. + if (!userDefinedBridge) { + settings.bridgesSource = TorSettings.BridgeSource.BuiltIn; + switch (userDefinedBridgeList) { + case "obfs4": + case "snowflake": + settings.bridgesBuiltinType = + TorSettings.BridgeBuiltinType.fromString(userDefinedBridgeList); + break; + case "meek": + settings.bridgesBuiltinType = TorSettings.BridgeBuiltinType.Meek; + break; + default: + settings.bridgesSource = TorSettings.BridgeSource.Invalid; + break; + } + } else { + settings.bridgesSource = TorSettings.BridgeSource.UserProvided; // user provided + settings.bridgeBridgeStrings = userDefinedBridgeList.split("\r\n"); + } + + // Tor Browser Android doesn't take proxy and firewall settings + settings.proxyEnabled = false; + + settings.firewallEnabled = false; + settings.firewallAllowedPorts = new int[0]; + + return settings; + } +} diff --git a/mobile/shared/components/geckoview/GeckoViewStartup.sys.mjs b/mobile/shared/components/geckoview/GeckoViewStartup.sys.mjs @@ -13,6 +13,7 @@ ChromeUtils.defineESModuleGetters(lazy, { EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", PdfJs: "resource://pdf.js/PdfJs.sys.mjs", RFPHelper: "resource://gre/modules/RFPHelper.sys.mjs", + TorAndroidIntegration: "resource://gre/modules/TorAndroidIntegration.sys.mjs", TorDomainIsolator: "resource://gre/modules/TorDomainIsolator.sys.mjs", }); @@ -273,6 +274,7 @@ export class GeckoViewStartup { this.#migratePreferences(); + lazy.TorAndroidIntegration.init(); lazy.TorDomainIsolator.init(); Services.obs.addObserver(this, "browser-idle-startup-tasks-finished"); diff --git a/mobile/shared/modules/geckoview/GeckoViewContent.sys.mjs b/mobile/shared/modules/geckoview/GeckoViewContent.sys.mjs @@ -4,6 +4,11 @@ import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + TorDomainIsolator: "resource://gre/modules/TorDomainIsolator.sys.mjs", +}); + export class GeckoViewContent extends GeckoViewModule { onInit() { this.registerListener([ @@ -24,6 +29,8 @@ export class GeckoViewContent extends GeckoViewModule { "GeckoView:IsPdfJs", "GeckoView:GetWebCompatInfo", "GeckoView:SendMoreWebCompatInfo", + "GeckoView:GetTorCircuit", + "GeckoView:NewTorCircuit", ]); } @@ -290,6 +297,12 @@ export class GeckoViewContent extends GeckoViewModule { case "GeckoView:HasCookieBannerRuleForBrowsingContextTree": this._hasCookieBannerRuleForBrowsingContextTree(aCallback); break; + case "GeckoView:GetTorCircuit": + this._getTorCircuit(aCallback); + break; + case "GeckoView:NewTorCircuit": + this._newTorCircuit(aCallback); + break; } } @@ -459,6 +472,25 @@ export class GeckoViewContent extends GeckoViewModule { } } + _getTorCircuit(aCallback) { + if (this.browser && aCallback) { + const domain = lazy.TorDomainIsolator.getDomainForBrowser(this.browser); + const nodes = lazy.TorDomainIsolator.getCircuit( + this.browser, + domain, + this.browser.contentPrincipal.originAttributes.userContextId + ); + aCallback?.onSuccess({ domain, nodes }); + } else { + aCallback?.onSuccess(null); + } + } + + _newTorCircuit(aCallback) { + lazy.TorDomainIsolator.newCircuitForBrowser(this.browser); + aCallback?.onSuccess(); + } + async _containsFormData(aCallback) { aCallback.onSuccess(await this.actor.containsFormData()); } diff --git a/toolkit/modules/TorAndroidIntegration.sys.mjs b/toolkit/modules/TorAndroidIntegration.sys.mjs @@ -0,0 +1,216 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + TorConnect: "resource://gre/modules/TorConnect.sys.mjs", + TorConnectTopics: "resource://gre/modules/TorConnect.sys.mjs", + TorSettingsTopics: "resource://gre/modules/TorSettings.sys.mjs", + TorProviderBuilder: "resource://gre/modules/TorProviderBuilder.sys.mjs", + TorProviderTopics: "resource://gre/modules/TorProviderBuilder.sys.mjs", + TorSettings: "resource://gre/modules/TorSettings.sys.mjs", +}); + +const Prefs = Object.freeze({ + logLevel: "browser.tor_android.log_level", +}); + +const logger = console.createInstance({ + maxLogLevelPref: Prefs.logLevel, + prefix: "TorAndroidIntegration", +}); + +const EmittedEvents = Object.freeze({ + settingsReady: "GeckoView:Tor:SettingsReady", + settingsChanged: "GeckoView:Tor:SettingsChanged", + connectStateChanged: "GeckoView:Tor:ConnectStateChanged", // deprecation path + connectStageChanged: "GeckoView:Tor:ConnectStageChanged", // new replacement path + bootstrapProgress: "GeckoView:Tor:BootstrapProgress", + bootstrapComplete: "GeckoView:Tor:BootstrapComplete", + torLogs: "GeckoView:Tor:Logs", +}); + +const ListenedEvents = Object.freeze({ + settingsGet: "GeckoView:Tor:SettingsGet", + // The data is passed directly to TorSettings. + settingsSet: "GeckoView:Tor:SettingsSet", + bootstrapBegin: "GeckoView:Tor:BootstrapBegin", + // Optionally takes a countryCode, as data.countryCode. + bootstrapBeginAuto: "GeckoView:Tor:BootstrapBeginAuto", + bootstrapCancel: "GeckoView:Tor:BootstrapCancel", + bootstrapGetState: "GeckoView:Tor:BootstrapGetState", + startAgain: "GeckoView:Tor:StartAgain", + quickstartGet: "GeckoView:Tor:QuickstartGet", + quickstartSet: "GeckoView:Tor:QuickstartSet", + regionNamesGet: "GeckoView:Tor:RegionNamesGet", + shouldShowTorConnectGet: "GeckoView:Tor:ShouldShowTorConnect", +}); + +/** + * The implementation for the global `TorAndroidIntegration` object. + */ +class TorAndroidIntegrationImpl { + #initialized = false; + + /** + * Register our listeners. + * We want this function to block GeckoView initialization, so it should not be + * async. Any async task should be moved to #deferredInit, instead. + */ + init() { + if (this.#initialized) { + logger.warn("Something tried to initilize us again."); + return; + } + this.#initialized = true; + + lazy.EventDispatcher.instance.registerListener( + this, + Object.values(ListenedEvents) + ); + + Services.obs.addObserver(this, lazy.TorProviderTopics.TorLog); + + for (const topic in lazy.TorConnectTopics) { + Services.obs.addObserver(this, lazy.TorConnectTopics[topic]); + } + + for (const topic in lazy.TorSettingsTopics) { + Services.obs.addObserver(this, lazy.TorSettingsTopics[topic]); + } + + lazy.TorProviderBuilder.init(); + // On Android immediately call firstWindowLoaded. This should be safe to + // call since it will await the initialisation of the TorProvider set up + // by TorProviderBuilder.init. + lazy.TorProviderBuilder.firstWindowLoaded(); + + this.#deferredInit(); + } + + /** + * Perform our init tasks that should not block the initialization of + * GeckoView. This function will not be awaited, so errors can only be logged. + */ + async #deferredInit() { + try { + await lazy.TorSettings.init(); + await lazy.TorConnect.init(); + } catch (e) { + logger.error("Cannot initialize TorSettings or TorConnect", e); + } + } + + observe(subj, topic) { + switch (topic) { + // TODO: Replace with StageChange. + case lazy.TorConnectTopics.StateChange: + lazy.EventDispatcher.instance.sendRequest({ + type: EmittedEvents.connectStateChanged, + state: subj.wrappedJSObject.state ?? "", + }); + break; + case lazy.TorConnectTopics.StageChange: + lazy.EventDispatcher.instance.sendRequest({ + type: EmittedEvents.connectStageChanged, + stage: subj.wrappedJSObject ?? "", + }); + break; + case lazy.TorConnectTopics.RegionNamesChange: + // TODO: Respond to change in region names if we are showing a + // TorConnectStage that uses them. + break; + case lazy.TorConnectTopics.BootstrapProgress: + lazy.EventDispatcher.instance.sendRequest({ + type: EmittedEvents.bootstrapProgress, + progress: subj.wrappedJSObject.progress ?? 0, + hasWarnings: subj.wrappedJSObject.hasWarnings ?? false, + }); + break; + case lazy.TorConnectTopics.BootstrapComplete: + lazy.EventDispatcher.instance.sendRequest({ + type: EmittedEvents.bootstrapComplete, + }); + break; + case lazy.TorProviderTopics.TorLog: + lazy.EventDispatcher.instance.sendRequest({ + type: EmittedEvents.torLogs, + logType: subj.wrappedJSObject.type ?? "", + message: subj.wrappedJSObject.msg ?? "", + timestamp: subj.wrappedJSObject.timestamp ?? "", + }); + break; + case lazy.TorSettingsTopics.Ready: + lazy.EventDispatcher.instance.sendRequest({ + type: EmittedEvents.settingsReady, + settings: lazy.TorSettings.getSettings(), + }); + break; + case lazy.TorSettingsTopics.SettingsChanged: + // For Android we push also the settings object to avoid a round trip on + // the event dispatcher. + lazy.EventDispatcher.instance.sendRequest({ + type: EmittedEvents.settingsChanged, + changes: subj.wrappedJSObject.changes ?? [], + settings: lazy.TorSettings.getSettings(), + }); + break; + } + } + + async onEvent(event, data, callback) { + logger.debug(`Received event ${event}`, data); + try { + switch (event) { + case ListenedEvents.settingsGet: + callback?.onSuccess(lazy.TorSettings.getSettings()); + return; + case ListenedEvents.settingsSet: + // TODO: Handle setting throw? This can throw if data.settings is + // incorrectly formatted, but more like it can throw when the settings + // fail to be passed onto the TorProvider. tor-browser#43405. + await lazy.TorSettings.changeSettings(data.settings); + break; + case ListenedEvents.bootstrapBegin: + lazy.TorConnect.beginBootstrapping(); + break; + case ListenedEvents.bootstrapBeginAuto: + // TODO: The countryCode should be set to "automatic" by the caller + // rather than `null`, so we can just pass in `data.countryCode` + // directly. + lazy.TorConnect.beginBootstrapping(data.countryCode || "automatic"); + break; + case ListenedEvents.bootstrapCancel: + lazy.TorConnect.cancelBootstrapping(); + break; + // TODO: Replace with TorConnect.stage. + case ListenedEvents.bootstrapGetState: + callback?.onSuccess(lazy.TorConnect.state); + return; + case ListenedEvents.startAgain: + lazy.TorConnect.startAgain(); + break; + case ListenedEvents.quickstartGet: + callback?.onSuccess(lazy.TorConnect.quickstart); + return; + case ListenedEvents.quickstartSet: + lazy.TorConnect.quickstart = data.enabled; + break; + case ListenedEvents.regionNamesGet: + callback?.onSuccess(lazy.TorConnect.getRegionNames()); + return; + case ListenedEvents.shouldShowTorConnectGet: + callback?.onSuccess(lazy.TorConnect.shouldShowTorConnect()); + return; + } + callback?.onSuccess(); + } catch (e) { + logger.error(`Error while handling event ${event}`, e); + callback?.onError(e); + } + } +} + +export const TorAndroidIntegration = new TorAndroidIntegrationImpl(); diff --git a/toolkit/modules/moz.build b/toolkit/modules/moz.build @@ -212,6 +212,7 @@ EXTRA_JS_MODULES += [ "Sqlite.sys.mjs", "SubDialog.sys.mjs", "Timer.sys.mjs", + "TorAndroidIntegration.sys.mjs", "TorConnect.sys.mjs", "TorSettings.sys.mjs", "TorStrings.sys.mjs",