Add force relay connection option (#77)

This adds a new switch control to the advanced settings so that users may opt to force usage of relay when connecting to peers.
It also warns the user that this configuration change will only be applied the next time they connect to the VPN.
This commit is contained in:
Diego Romar
2025-09-10 14:21:04 -03:00
committed by GitHub
parent eb0aa36a68
commit 449db69f93
14 changed files with 300 additions and 6 deletions
@@ -9,12 +9,16 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.fragment.app.Fragment;
import io.netbird.client.R;
import io.netbird.client.databinding.ComponentSwitchBinding;
import io.netbird.client.databinding.FragmentAdvancedBinding;
import io.netbird.client.tool.Logcat;
import io.netbird.client.tool.Preferences;
@@ -28,6 +32,33 @@ public class AdvancedFragment extends Fragment {
private FragmentAdvancedBinding binding;
private io.netbird.gomobile.android.Preferences goPreferences;
private void showReconnectionNeededWarningDialog() {
final View dialogView = getLayoutInflater().inflate(R.layout.dialog_simple_alert_message, null);
final AlertDialog alertDialog = new AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme)
.setView(dialogView)
.create();
((TextView)dialogView.findViewById(R.id.txt_dialog)).setText(R.string.reconnectionNeededWarningMessage);
dialogView.findViewById(R.id.btn_ok_dialog).setOnClickListener(v -> alertDialog.dismiss());
alertDialog.show();
}
private void configureForceRelayConnectionSwitch(@NonNull ComponentSwitchBinding binding, @NonNull Preferences preferences) {
binding.switchTitle.setText(R.string.advanced_force_relay_conn);
binding.switchDescription.setText(R.string.advanced_force_relay_conn_desc);
binding.switchControl.setChecked(preferences.isConnectionForceRelayed());
binding.switchControl.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (isChecked) {
preferences.enableForcedRelayConnection();
} else {
preferences.disableForcedRelayConnection();
}
showReconnectionNeededWarningDialog();
});
}
public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
@@ -122,6 +153,8 @@ public class AdvancedFragment extends Fragment {
}
});
configureForceRelayConnectionSwitch(binding.layoutForceRelayConnection, preferences);
// Initialize engine config switches (your settings)
initializeEngineConfigSwitches();
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="28dp" />
<solid android:color="@color/nb_bg" />
</shape>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/switch_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
tools:text="Switch Title"
android:textColor="@color/nb_txt_light" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_control"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<TextView
android:id="@+id/switch_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Switch Description"
android:textColor="@color/nb_txt_light"
android:textSize="12sp"
android:layout_marginTop="2dp"
android:layout_marginStart="0dp"
android:layout_marginEnd="48dp" />
</LinearLayout>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_rounded_nb_bg"
android:maxWidth="560dp"
android:minWidth="280dp"
android:padding="24dp">
<ImageView
android:id="@+id/icon_dialog"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/exclamation_mark"
android:src="@drawable/exclamation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/nb_orange" />
<TextView
android:id="@+id/txt_dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/icon_dialog"
tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus ultricies, lorem sit amet ultrices tincidunt, neque neque molestie lacus, dictum consequat sapien neque at quam. Proin vel justo nulla." />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_ok_dialog"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@android:string/ok"
android:textColor="@color/nb_orange"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/txt_dialog"
tools:text="@android:string/ok" />
</androidx.constraintlayout.widget.ConstraintLayout>
+13 -2
View File
@@ -5,6 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:id="@+id/scr_vw_advanced"
tools:context=".ui.advanced.AdvancedFragment">
<androidx.constraintlayout.widget.ConstraintLayout
@@ -43,7 +44,7 @@
android:textSize="14sp"
android:inputType="textUri"
android:background="@drawable/edit_text_white"
android:padding="12dp"
android:padding="16dp"
android:textColor="@color/nb_txt"
android:textColorHint="@color/nb_txt_light"
app:layout_constraintTop_toBottomOf="@id/text_server_label"
@@ -438,13 +439,23 @@
</LinearLayout>
<include
android:id="@+id/layout_force_relay_connection"
layout="@layout/component_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_disable_firewall" />
<LinearLayout
android:id="@+id/layout_theme"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/layout_disable_firewall"
app:layout_constraintTop_toBottomOf="@id/layout_force_relay_connection"
app:layout_constraintEnd_toEndOf="parent">
<TextView
+4
View File
@@ -105,4 +105,8 @@
<string name="advanced_theme_dark">Dark</string>
<string name="advanced_theme_light">Light</string>
<string name="advanced_theme_desc">Choose the app appearance mode.</string>
<string name="advanced_force_relay_conn">Force relay connection</string>
<string name="advanced_force_relay_conn_desc">Forces usage of relay when connecting to peers</string>
<string name="exclamation_mark">exclamation mark</string>
<string name="reconnectionNeededWarningMessage">To apply the setting, you will need to reconnect.</string>
</resources>
+4
View File
@@ -57,4 +57,8 @@
<item name="trackTint">@color/switch_track_color</item>
</style>
<attr name="nbTabBackground" format="color|reference" />
<style name="AlertDialogTheme" parent="Theme.AppCompat.Dialog.Alert">
<item name="android:windowBackground">@android:color/transparent</item>
</style>
</resources>
+1 -1
Submodule netbird updated: f425870c8e...a53243eed0
@@ -0,0 +1,27 @@
package io.netbird.client.tool;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import io.netbird.gomobile.android.Android;
@RunWith(AndroidJUnit4.class)
public class EnvVarPackagerInstrumentedTest {
@Test
public void shouldReturnEnvironmentVariables() {
var preferences = new Preferences(InstrumentationRegistry.getInstrumentation().getTargetContext());
var environmentVariables = EnvVarPackager.getEnvironmentVariables(preferences);
Assert.assertNotNull(environmentVariables);
var forceRelay = environmentVariables.get(Android.getEnvKeyNBForceRelay());
var variableNotPresentInList = environmentVariables.get("UNKNOWN_VAR");
var emptyString = "";
Assert.assertNotEquals(emptyString, forceRelay);
Assert.assertEquals(emptyString, variableNotPresentInList);
}
}
@@ -21,6 +21,6 @@ public class ExampleInstrumentedTest {
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("io.netbird.client.backend.test", appContext.getPackageName());
assertEquals("io.netbird.client.tool.test", appContext.getPackageName());
}
}
@@ -0,0 +1,93 @@
package io.netbird.client.tool;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.After;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import android.content.Context;
@RunWith(AndroidJUnit4.class)
public class PreferencesInstrumentedTest {
private static Preferences preferences;
private static Context getContext() {
return InstrumentationRegistry.getInstrumentation().getTargetContext();
}
@BeforeClass
public static void setUp() {
preferences = new Preferences(getContext());
}
@After
public void tearDown() {
getContext().getSharedPreferences("netbird", Context.MODE_PRIVATE).edit().clear().apply();
}
@Test
public void shouldCreatePreferencesWithoutThrownException() {
Preferences preferences = null;
Exception thrown = null;
try {
preferences = new Preferences(getContext());
} catch (Exception e) {
thrown = e;
}
Assert.assertNull(thrown);
Assert.assertNotNull(preferences);
}
@Test
public void shouldReturnFalseWhenConnectionForceRelayedIsNotSet() {
Assert.assertFalse(preferences.isConnectionForceRelayed());
}
@Test
public void shouldReturnTrueAfterEnablingForcedRelayConnection() {
preferences.enableForcedRelayConnection();
Assert.assertTrue(preferences.isConnectionForceRelayed());
}
@Test
public void shouldReturnFalseAfterDisablingForcedRelayConnection() {
preferences.enableForcedRelayConnection();
preferences.disableForcedRelayConnection();
Assert.assertFalse(preferences.isConnectionForceRelayed());
}
@Test
public void shouldReturnFalseWhenTraceLogIsNotSet() {
Assert.assertFalse(preferences.isTraceLogEnabled());
}
@Test
public void shouldReturnTrueAfterEnablingTraceLog() {
preferences.enableTraceLog();
Assert.assertTrue(preferences.isTraceLogEnabled());
}
@Test
public void shouldReturnFalseAfterDisablingTraceLog() {
preferences.enableTraceLog();
preferences.disableTraceLog();
Assert.assertFalse(preferences.isTraceLogEnabled());
}
@Test
public void shouldReturnCorrectDefaultServer() {
final var defaultServer = "https://api.netbird.io";
Assert.assertEquals(defaultServer, Preferences.defaultServer());
}
}
@@ -60,12 +60,15 @@ class EngineRunner {
engineIsRunning = true;
Runnable r = () -> {
DNSWatch dnsWatch = new DNSWatch(context);
Preferences preferences = new Preferences(context);
var envList = EnvVarPackager.getEnvironmentVariables(preferences);
try {
notifyServiceStateListeners(true);
if(urlOpener == null) {
goClient.runWithoutLogin(dnsWatch.dnsServers(), () -> dnsWatch.setDNSChangeListener(this::changed));
goClient.runWithoutLogin(dnsWatch.dnsServers(), () -> dnsWatch.setDNSChangeListener(this::changed), envList);
} else {
goClient.run(urlOpener, dnsWatch.dnsServers(), () -> dnsWatch.setDNSChangeListener(this::changed));
goClient.run(urlOpener, dnsWatch.dnsServers(), () -> dnsWatch.setDNSChangeListener(this::changed), envList);
}
} catch (Exception e) {
Log.e(LOGTAG, "goClient error", e);
@@ -0,0 +1,14 @@
package io.netbird.client.tool;
import io.netbird.gomobile.android.Android;
import io.netbird.gomobile.android.EnvList;
public class EnvVarPackager {
public static EnvList getEnvironmentVariables(Preferences preferences) {
var envList = new EnvList();
envList.put(Android.getEnvKeyNBForceRelay(), String.valueOf(preferences.isConnectionForceRelayed()));
return envList;
}
}
@@ -6,6 +6,9 @@ import android.content.SharedPreferences;
public class Preferences {
private final String keyTraceLog = "tracelog";
private final String keyForceRelayConnection = "isConnectionForceRelayed";
private final SharedPreferences sharedPref;
public static String configFile(Context context){
@@ -27,6 +30,18 @@ public class Preferences {
sharedPref.edit().putBoolean(keyTraceLog, false).apply();
}
public boolean isConnectionForceRelayed() {
return sharedPref.getBoolean(keyForceRelayConnection, false);
}
public void enableForcedRelayConnection() {
sharedPref.edit().putBoolean(keyForceRelayConnection, true).apply();
}
public void disableForcedRelayConnection() {
sharedPref.edit().putBoolean(keyForceRelayConnection, false).apply();
}
public static String defaultServer() {
return "https://api.netbird.io";
}