diff --git a/app/src/main/java/io/netbird/client/MainActivity.java b/app/src/main/java/io/netbird/client/MainActivity.java index c7fb0b54..7f15bfaa 100644 --- a/app/src/main/java/io/netbird/client/MainActivity.java +++ b/app/src/main/java/io/netbird/client/MainActivity.java @@ -421,6 +421,32 @@ public String debugBundle(boolean anonymize) throws Exception { return mBinder.debugBundle(anonymize); } + @Override + public String getServerPushedConnectionMode() { + if (mBinder == null) { + return ""; + } + return mBinder.getServerPushedConnectionMode(); + } + + @Override + public long getServerPushedRelayTimeoutSecs() { + if (mBinder == null) return 0; + return mBinder.getServerPushedRelayTimeoutSecs(); + } + + @Override + public long getServerPushedP2pTimeoutSecs() { + if (mBinder == null) return 0; + return mBinder.getServerPushedP2pTimeoutSecs(); + } + + @Override + public long getServerPushedP2pRetryMaxSecs() { + if (mBinder == null) return 0; + return mBinder.getServerPushedP2pRetryMaxSecs(); + } + @Override public void addRouteChangeListener(RouteChangeListener listener) { if (mBinder == null) { diff --git a/app/src/main/java/io/netbird/client/ServiceAccessor.java b/app/src/main/java/io/netbird/client/ServiceAccessor.java index d37d399c..f4126884 100644 --- a/app/src/main/java/io/netbird/client/ServiceAccessor.java +++ b/app/src/main/java/io/netbird/client/ServiceAccessor.java @@ -19,4 +19,31 @@ public interface ServiceAccessor { void removeRouteChangeListener(RouteChangeListener listener); String debugBundle(boolean anonymize) throws Exception; + + /** + * Returns the canonical name (e.g. "p2p-dynamic") of the connection + * mode the management server most recently pushed. Empty string when + * the engine has not connected yet or no value has been pushed -- + * the UI should then hide the "(currently: ...)" suffix on the + * Follow-server entry of the override dropdown. + */ + String getServerPushedConnectionMode(); + + /** + * Returns the relay timeout (seconds) the management server most + * recently pushed. 0 when not yet known. Used as a hint in the + * override field so the user can see the value they are about to + * override. + */ + long getServerPushedRelayTimeoutSecs(); + + /** + * Returns the p2p (ICE-only) timeout in seconds most recently pushed. + */ + long getServerPushedP2pTimeoutSecs(); + + /** + * Returns the p2p retry-max cap in seconds most recently pushed. + */ + long getServerPushedP2pRetryMaxSecs(); } \ No newline at end of file diff --git a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java index 3f1e2a7c..6593899e 100644 --- a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java +++ b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java @@ -3,21 +3,24 @@ import android.content.Context; import android.content.SharedPreferences; import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.EditText; 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.Preferences; import io.netbird.client.tool.ProfileManagerWrapper; @@ -31,38 +34,6 @@ 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(); - }); - - // Make parent layout clickable to toggle switch (for TV remote) - binding.getRoot().setOnClickListener(v -> { - binding.switchControl.toggle(); - }); - } - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -158,8 +129,6 @@ public View onCreateView(@NonNull LayoutInflater inflater, binding.switchRosenpassPermissive.toggle(); }); - configureForceRelayConnectionSwitch(binding.layoutForceRelayConnection, preferences); - // Initialize engine config switches (your settings) initializeEngineConfigSwitches(); @@ -198,6 +167,12 @@ private void initializeEngineConfigSwitches() { binding.switchAllowSsh.setChecked(goPreferences.getServerSSHAllowed()); binding.switchBlockInbound.setChecked(goPreferences.getBlockInbound()); + // Connection mode + timeouts (Phase 3.7h Android UI). Default + // selection is "Follow server" (index 0 in connection_mode_entries), + // which clears any local override. Selecting an explicit mode + // unhides the timeout fields below. + initializeConnectionModeUI(); + // Set up change listeners binding.switchDisableClientRoutes.setOnCheckedChangeListener((buttonView, isChecked) -> { try { @@ -252,12 +227,12 @@ private void initializeEngineConfigSwitches() { Log.e(LOGTAG, "Failed to set block inbound", e); } }); - + // Make parent layouts clickable to toggle switches (for TV remote) binding.layoutAllowSsh.setOnClickListener(v -> { binding.switchAllowSsh.toggle(); }); - + binding.layoutBlockInbound.setOnClickListener(v -> { binding.switchBlockInbound.toggle(); }); @@ -283,6 +258,200 @@ private void initializeEngineConfigSwitches() { } } + /** + * Mapping from spinner position to canonical connection-mode string. + * Index 0 = "Follow server" -> empty string clears the local override + * so the daemon uses the server-pushed value. Other entries set an + * explicit local override that wins over the server value. + * + * Order matches connection_mode_entries (res/values/connection_mode_array.xml): + * Follow server, relay-forced, p2p, p2p-lazy, p2p-dynamic. + */ + private static final String[] CONNECTION_MODE_VALUES = new String[] { + "", // 0: Follow server + "relay-forced", // 1 + "p2p", // 2 + "p2p-lazy", // 3 + "p2p-dynamic" // 4 + }; + + private void initializeConnectionModeUI() { + try { + // Build a theme-aware adapter so the dropdown uses our nb_txt color + // and the popup picks up nb_bg in dark mode. + // The "Follow server" entry gets a "(currently: )" suffix + // that surfaces the value the management server most recently + // pushed -- refreshed on every spinner-touch in case the engine + // was not connected yet when the fragment first opened. + refreshConnectionModeAdapter(); + + // Hydrate spinner from current persisted local override. + String currentMode = goPreferences.getConnectionMode(); + int selectedIdx = 0; + for (int i = 0; i < CONNECTION_MODE_VALUES.length; i++) { + if (CONNECTION_MODE_VALUES[i].equals(currentMode)) { + selectedIdx = i; + break; + } + } + binding.spinnerConnectionMode.setSelection(selectedIdx); + updateTimeoutsVisibility(selectedIdx); + + // Hydrate timeout fields with the locally-stored override (if any). + // Empty when no override is set so the user sees the field is + // currently inactive; the hint text shows the server-pushed + // default for that field as guidance. + long relay = goPreferences.getRelayTimeoutSeconds(); + long p2p = goPreferences.getP2pTimeoutSeconds(); + long retry = goPreferences.getP2pRetryMaxSeconds(); + binding.editRelayTimeout.setText(relay == 0 ? "" : String.valueOf(relay)); + binding.editP2pTimeout.setText(p2p == 0 ? "" : String.valueOf(p2p)); + binding.editP2pRetryMax.setText(retry == 0 ? "" : String.valueOf(retry)); + + // Refresh the "(currently: ...)" suffix every time the spinner is + // touched. Cheap (just a getter call to the engine), and covers + // the case where the user opens this fragment before the daemon + // received its first PeerConfig. + binding.spinnerConnectionMode.setOnTouchListener((v, event) -> { + if (event.getActionMasked() == android.view.MotionEvent.ACTION_DOWN) { + refreshConnectionModeAdapter(); + } + return false; + }); + + binding.spinnerConnectionMode.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + try { + goPreferences.setConnectionMode(CONNECTION_MODE_VALUES[position]); + goPreferences.commit(); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to set connection mode", e); + } + updateTimeoutsVisibility(position); + } + + @Override + public void onNothingSelected(AdapterView parent) { } + }); + + wireTimeoutEditOnBlur(binding.editRelayTimeout, "relay", v -> { + try { goPreferences.setRelayTimeoutSeconds(v); goPreferences.commit(); } + catch (Exception e) { Log.e(LOGTAG, "Failed to set relay timeout", e); } + }); + wireTimeoutEditOnBlur(binding.editP2pTimeout, "p2p", v -> { + try { goPreferences.setP2pTimeoutSeconds(v); goPreferences.commit(); } + catch (Exception e) { Log.e(LOGTAG, "Failed to set p2p timeout", e); } + }); + wireTimeoutEditOnBlur(binding.editP2pRetryMax, "p2pRetryMax", v -> { + try { goPreferences.setP2pRetryMaxSeconds(v); goPreferences.commit(); } + catch (Exception e) { Log.e(LOGTAG, "Failed to set p2p retry max", e); } + }); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to initialize connection mode UI", e); + } + } + + private void refreshConnectionModeAdapter() { + if (binding == null) return; + String[] base = getResources().getStringArray(R.array.connection_mode_entries); + String[] entries = base.clone(); + String pushed = ""; + try { + if (requireActivity() instanceof io.netbird.client.ServiceAccessor) { + pushed = ((io.netbird.client.ServiceAccessor) requireActivity()) + .getServerPushedConnectionMode(); + } + } catch (Throwable t) { + Log.d(LOGTAG, "no server-pushed mode available yet: " + t.getMessage()); + } + if (pushed != null && !pushed.isEmpty()) { + entries[0] = base[0] + " (currently: " + pushed + ")"; + } else { + entries[0] = base[0] + " (engine not yet connected)"; + } + int currentSelection = binding.spinnerConnectionMode.getSelectedItemPosition(); + ArrayAdapter adapter = new ArrayAdapter<>( + requireContext(), R.layout.spinner_item_themed, entries); + adapter.setDropDownViewResource(R.layout.spinner_item_themed); + binding.spinnerConnectionMode.setAdapter(adapter); + if (currentSelection >= 0 && currentSelection < entries.length) { + binding.spinnerConnectionMode.setSelection(currentSelection); + } + // Server-pushed timeout values may have changed too; refresh hints. + refreshTimeoutHints(); + } + + private void updateTimeoutsVisibility(int spinnerPosition) { + // Inactivity timeouts only apply when the lazy/dynamic connection + // manager is active. Mapping (CONNECTION_MODE_VALUES indices): + // 0 follow-server : hide all (server may push any mode; default off) + // 1 relay-forced : hide all (relay tunnel always up, no teardown) + // 2 p2p : hide all (no inactivity manager runs) + // 3 p2p-lazy : show relay_timeout (whole-peer teardown) + // 4 p2p-dynamic : show all three (ICE-only + relay + retry-cap) + boolean lazyActive = (spinnerPosition == 3 || spinnerPosition == 4); + boolean dynamicActive = (spinnerPosition == 4); + + binding.layoutTimeoutsContainer.setVisibility(lazyActive ? View.VISIBLE : View.GONE); + if (!lazyActive) return; + + // relay timeout shown for both p2p-lazy and p2p-dynamic. + binding.labelP2pTimeout.setVisibility(dynamicActive ? View.VISIBLE : View.GONE); + binding.editP2pTimeout.setVisibility(dynamicActive ? View.VISIBLE : View.GONE); + binding.labelP2pRetryMax.setVisibility(dynamicActive ? View.VISIBLE : View.GONE); + binding.editP2pRetryMax.setVisibility(dynamicActive ? View.VISIBLE : View.GONE); + + // Refresh hint text from the latest server-pushed values so users + // see what they would inherit if they leave a field blank. + refreshTimeoutHints(); + } + + private void refreshTimeoutHints() { + long relayServer = 0, p2pServer = 0, retryServer = 0; + try { + if (requireActivity() instanceof io.netbird.client.ServiceAccessor) { + io.netbird.client.ServiceAccessor sa = + (io.netbird.client.ServiceAccessor) requireActivity(); + relayServer = sa.getServerPushedRelayTimeoutSecs(); + p2pServer = sa.getServerPushedP2pTimeoutSecs(); + retryServer = sa.getServerPushedP2pRetryMaxSecs(); + } + } catch (Throwable t) { + Log.d(LOGTAG, "server-pushed timeouts unavailable: " + t.getMessage()); + } + binding.editRelayTimeout.setHint(formatHint(relayServer)); + binding.editP2pTimeout.setHint(formatHint(p2pServer)); + binding.editP2pRetryMax.setHint(formatHint(retryServer)); + } + + private static String formatHint(long secs) { + if (secs <= 0) { + return "use server default"; + } + return "use server default (" + secs + "s)"; + } + + private interface LongConsumer { void accept(long v); } + + private void wireTimeoutEditOnBlur(EditText edit, String label, LongConsumer onCommit) { + edit.setOnFocusChangeListener((view, hasFocus) -> { + if (hasFocus) return; + String s = edit.getText().toString().trim(); + long val = 0; + if (!s.isEmpty()) { + try { val = Long.parseLong(s); } + catch (NumberFormatException nfe) { + Log.w(LOGTAG, "Invalid " + label + " timeout: " + s); + edit.setText(""); + return; + } + if (val < 0) val = 0; + } + onCommit.accept(val); + }); + } + @Override public void onDestroyView() { super.onDestroyView(); diff --git a/app/src/main/res/layout/fragment_advanced.xml b/app/src/main/res/layout/fragment_advanced.xml index ad46aea4..7d3b507a 100644 --- a/app/src/main/res/layout/fragment_advanced.xml +++ b/app/src/main/res/layout/fragment_advanced.xml @@ -267,6 +267,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/layout_connection_mode"> - - + app:layout_constraintTop_toBottomOf="@id/layout_disable_firewall"> + diff --git a/app/src/main/res/values/connection_mode_array.xml b/app/src/main/res/values/connection_mode_array.xml new file mode 100644 index 00000000..abb7fafd --- /dev/null +++ b/app/src/main/res/values/connection_mode_array.xml @@ -0,0 +1,13 @@ + + + + + Follow server + relay-forced + p2p + p2p-lazy + p2p-dynamic + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d6b1f51b..189e58cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -96,6 +96,14 @@ Allows SSH connections to this device Block inbound connections Blocks all inbound connections to this device and routed networks. This overrides any policies received from the management service + Lazy connection + Defer per-peer connection setup until first actual traffic. Reduces background load but adds latency to the first packet to a peer + Connection mode + Override the connection mode pushed by the server. Leave on \"Follow server\" to use the server-configured value. Other modes activate the timeout fields below. + Relay timeout (seconds; leave empty to use server default) + P2P (ICE) timeout (seconds; leave empty to use server default) + P2P retry-max cap (seconds; leave empty to use server default) + use server default NetBird logo @@ -109,8 +117,6 @@ Dark Light Choose the app appearance mode. - Force relay connection - Forces usage of relay when connecting to peers exclamation mark To apply the setting, you will need to reconnect. Using setup keys for user devices is not recommended. SSO with MFA provides stronger security, proper user-device association, and periodic re-authentication. diff --git a/netbird b/netbird index 5a89e662..c90d008b 160000 --- a/netbird +++ b/netbird @@ -1 +1 @@ -Subproject commit 5a89e6621bf41cb6cb0da3040ffe68ae790e3c02 +Subproject commit c90d008ba7514f1cfe9cd769fcaf8065e025ea73 diff --git a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java index dfdbf846..68940849 100644 --- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java +++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java @@ -214,6 +214,30 @@ public void renewTUN(int fd) { } } + public String getServerPushedConnectionMode() { + try { + return goClient.getServerPushedConnectionMode(); + } catch (Throwable t) { + android.util.Log.d(LOGTAG, "getServerPushedConnectionMode unavailable: " + t.getMessage()); + return ""; + } + } + + public long getServerPushedRelayTimeoutSecs() { + try { return goClient.getServerPushedRelayTimeoutSecs(); } + catch (Throwable t) { return 0; } + } + + public long getServerPushedP2pTimeoutSecs() { + try { return goClient.getServerPushedP2pTimeoutSecs(); } + catch (Throwable t) { return 0; } + } + + public long getServerPushedP2pRetryMaxSecs() { + try { return goClient.getServerPushedP2pRetryMaxSecs(); } + catch (Throwable t) { return 0; } + } + public String debugBundle(boolean anonymize) throws Exception { String configPath = profileManager.getActiveConfigPath(); String statePath = profileManager.getActiveStateFilePath(); diff --git a/tool/src/main/java/io/netbird/client/tool/EnvVarPackager.java b/tool/src/main/java/io/netbird/client/tool/EnvVarPackager.java index f6413e99..3ba7cb2a 100644 --- a/tool/src/main/java/io/netbird/client/tool/EnvVarPackager.java +++ b/tool/src/main/java/io/netbird/client/tool/EnvVarPackager.java @@ -1,14 +1,9 @@ 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; + return new EnvList(); } } diff --git a/tool/src/main/java/io/netbird/client/tool/Preferences.java b/tool/src/main/java/io/netbird/client/tool/Preferences.java index ddf57ca5..5de30663 100644 --- a/tool/src/main/java/io/netbird/client/tool/Preferences.java +++ b/tool/src/main/java/io/netbird/client/tool/Preferences.java @@ -7,8 +7,6 @@ public class Preferences { private final String keyTraceLog = "tracelog"; - private final String keyForceRelayConnection = "isConnectionForceRelayed"; - private final SharedPreferences sharedPref; public Preferences(Context context) { @@ -26,18 +24,6 @@ public void disableTraceLog() { sharedPref.edit().putBoolean(keyTraceLog, false).apply(); } - public boolean isConnectionForceRelayed() { - return sharedPref.getBoolean(keyForceRelayConnection, true); - } - - 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"; } diff --git a/tool/src/main/java/io/netbird/client/tool/VPNService.java b/tool/src/main/java/io/netbird/client/tool/VPNService.java index 8494d48d..af9077db 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -243,6 +243,22 @@ public String debugBundle(boolean anonymize) throws Exception { return engineRunner.debugBundle(anonymize); } + public String getServerPushedConnectionMode() { + return engineRunner.getServerPushedConnectionMode(); + } + + public long getServerPushedRelayTimeoutSecs() { + return engineRunner.getServerPushedRelayTimeoutSecs(); + } + + public long getServerPushedP2pTimeoutSecs() { + return engineRunner.getServerPushedP2pTimeoutSecs(); + } + + public long getServerPushedP2pRetryMaxSecs() { + return engineRunner.getServerPushedP2pRetryMaxSecs(); + } + public void selectRoute(String route) throws Exception { engineRunner.selectRoute(route); }