Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/src/main/java/io/netbird/client/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/io/netbird/client/ServiceAccessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
245 changes: 207 additions & 38 deletions app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {

Expand Down Expand Up @@ -158,8 +129,6 @@ public View onCreateView(@NonNull LayoutInflater inflater,
binding.switchRosenpassPermissive.toggle();
});

configureForceRelayConnectionSwitch(binding.layoutForceRelayConnection, preferences);

// Initialize engine config switches (your settings)
initializeEngineConfigSwitches();

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
});
Expand All @@ -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: <mode>)" 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<String> 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();
}
Comment on lines +355 to +383
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Move the new status/hint copy into string resources.

"currently", "engine not yet connected", and the formatHint() text are all hardcoded here, so the new connection-mode UI will not localize with the rest of the screen.

Also applies to: 428-433

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java` around
lines 355 - 383, refreshConnectionModeAdapter contains hardcoded user-visible
strings ("currently", "engine not yet connected") and the formatHint() text
which must be localized; replace these inline literals with string resources and
use getString(...) with placeholders (e.g., a resource like
connection_mode_currently="%1$s (currently: %2$s)" and
connection_mode_engine_not_connected="%1$s (engine not yet connected)") when
building entries, and move the formatHint() literal into its own string resource
and call getString(R.string.<name>, ...) from the same methods
(refreshConnectionModeAdapter and the related block at lines ~428-433) so all UI
copy is loaded from resources and supports localization.


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;
}
Comment on lines +437 to +448
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear the stored timeout override on invalid input.

Clearing the EditText and returning here leaves the previous persisted value intact. The field looks empty, but the engine still uses the old override until the user enters another valid value.

Suggested fix
             if (!s.isEmpty()) {
                 try { val = Long.parseLong(s); }
                 catch (NumberFormatException nfe) {
                     Log.w(LOGTAG, "Invalid " + label + " timeout: " + s);
                     edit.setText("");
+                    onCommit.accept(0);
                     return;
                 }
                 if (val < 0) val = 0;
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
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("");
onCommit.accept(0);
return;
}
if (val < 0) val = 0;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java` around
lines 437 - 448, The EditText blur handler in wireTimeoutEditOnBlur currently
clears the visible field but does not clear the persisted override; when
NumberFormatException occurs, call the provided LongConsumer onCommit with a
sentinel to clear the stored override (e.g. onCommit.accept(-1L)) before
returning so the engine stops using the old timeout override; update the catch
block in wireTimeoutEditOnBlur to invoke onCommit.accept(-1L) (or your project's
defined "clear" sentinel) after edit.setText("").

if (val < 0) val = 0;
}
onCommit.accept(val);
});
}

@Override
public void onDestroyView() {
super.onDestroyView();
Expand Down
Loading