From c65cd0c4c0182c1b9868ded531e50c098608ddf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 7 Oct 2026 13:28:08 +0200 Subject: [PATCH 1/4] Show dialog fragments synchronously so a pause cannot flush the show DialogFragment.show() commits the transaction asynchronously. If the activity starts finishing before the main looper gets to it, the pending transaction is executed by FragmentActivity.onPause itself (dispatchPause -> execPendingActions), which brings the fresh fragment to STARTED and calls Dialog.show() on a window token that is already gone: RuntimeException: Unable to pause activity {MainActivity}: WindowManager$BadTokenException: Unable to add window -- token is not valid; is your activity running? at DialogFragment.onStart at ProfilePickerSheet.onStart A tap on the profile chip followed by a back gesture in the same frame is enough, since input events are dispatched ahead of an already queued pause message. The six sheets opened from click listeners now use showNow(). The click runs while the activity is resumed and focused, so the synchronous commit adds the window with a valid token, and nothing is left pending for the pause to pick up. Click listeners never run inside a fragment transaction, so the reentrancy restriction of commitNow does not apply. The two QrCodeDialog sites run from the engine's URLOpener and can fire at any time, including after onSaveInstanceState, where both show() and showNow() throw. They now go through a helper that skips the dialog when the activity is finishing, destroyed or past state save, and shows it synchronously otherwise. The browser intent is still started as before. --- .../java/io/netbird/client/MainActivity.java | 21 +++++++++++++------ .../client/ui/advanced/AdvancedFragment.java | 2 +- .../netbird/client/ui/home/HomeFragment.java | 4 ++-- .../client/ui/settings/SettingsFragment.java | 2 +- .../SplitTunnelingFragment.java | 2 +- .../ui/troubleshoot/TroubleshootFragment.java | 2 +- 6 files changed, 21 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/io/netbird/client/MainActivity.java b/app/src/main/java/io/netbird/client/MainActivity.java index 31e06e06..7dbe3c1b 100644 --- a/app/src/main/java/io/netbird/client/MainActivity.java +++ b/app/src/main/java/io/netbird/client/MainActivity.java @@ -298,7 +298,7 @@ public boolean canConnect() { @Override public void open(String url, String userCode) { runOnUiThread(() -> { - qrCodeDialog = QrCodeDialog.newInstance(url, userCode, () -> { + showQrCodeDialog(QrCodeDialog.newInstance(url, userCode, () -> { if (isSSOFinishedWell) { return; } @@ -306,8 +306,7 @@ public void open(String url, String userCode) { return; } mBinder.stopEngine(); - }); - qrCodeDialog.show(getSupportFragmentManager(), "QrCodeDialog"); + })); if (!isRunningOnTV) { try { @@ -996,6 +995,17 @@ public void onError(Exception e) { }); } + // The opener fires from the engine, so by the time this runs the activity + // may already be finishing or past onSaveInstanceState; neither can host a + // dialog, and a deferred show() would be flushed by the pause itself. + private void showQrCodeDialog(QrCodeDialog dialog) { + if (isFinishing() || isDestroyed() || getSupportFragmentManager().isStateSaved()) { + return; + } + qrCodeDialog = dialog; + dialog.showNow(getSupportFragmentManager(), "QrCodeDialog"); + } + // The login urlOpener stops the engine when the SSO surface is dismissed // without success, which also kills its pending PKCE wait. An extend must // keep the tunnel up, so it cancels just the extend flow instead — @@ -1009,9 +1019,8 @@ private URLOpener buildExtendURLOpener() { @Override public void open(String url, String userCode) { runOnUiThread(() -> { - qrCodeDialog = QrCodeDialog.newInstance(url, userCode, - MainActivity.this::cancelExtendSession); - qrCodeDialog.show(getSupportFragmentManager(), "QrCodeDialog"); + showQrCodeDialog(QrCodeDialog.newInstance(url, userCode, + MainActivity.this::cancelExtendSession)); if (!isRunningOnTV) { try { 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 9bdd253d..a05323ce 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 @@ -156,7 +156,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, binding.rowTheme.setOnClickListener(v -> { ThemePickerSheet sheet = new ThemePickerSheet(); - sheet.show(getChildFragmentManager(), "ThemePickerSheet"); + sheet.showNow(getChildFragmentManager(), "ThemePickerSheet"); }); return root; diff --git a/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java b/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java index c438166d..32168ceb 100644 --- a/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java +++ b/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java @@ -200,12 +200,12 @@ public View onCreateView(@NonNull LayoutInflater inflater, binding.profileChip.setOnClickListener(v -> { ProfilePickerSheet sheet = new ProfilePickerSheet(); - sheet.show(getChildFragmentManager(), "ProfilePickerSheet"); + sheet.showNow(getChildFragmentManager(), "ProfilePickerSheet"); }); binding.exitNodeRow.setOnClickListener(v -> { ExitNodePickerSheet sheet = new ExitNodePickerSheet(); - sheet.show(getChildFragmentManager(), "ExitNodePickerSheet"); + sheet.showNow(getChildFragmentManager(), "ExitNodePickerSheet"); }); binding.sessionExpiryRow.setOnClickListener(v -> { if (serviceAccessor != null) { diff --git a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java index 6e88456d..2cc758cd 100644 --- a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java +++ b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java @@ -56,7 +56,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat navController.navigate(R.id.nav_split_tunneling)); binding.rowLanguage.setOnClickListener(v -> - new LanguagePickerSheet().show(getChildFragmentManager(), "language_picker")); + new LanguagePickerSheet().showNow(getChildFragmentManager(), "language_picker")); binding.rowTroubleshoot.setOnClickListener(v -> navController.navigate(R.id.nav_troubleshoot)); diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java index 1fe398bc..41231dd8 100644 --- a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java +++ b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java @@ -81,7 +81,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat binding.appsRecyclerView.setAdapter(adapter); binding.rowMode.setOnClickListener(v -> - SplitTunnelModeSheet.newInstance(mode).show(getChildFragmentManager(), "split_tunnel_mode")); + SplitTunnelModeSheet.newInstance(mode).showNow(getChildFragmentManager(), "split_tunnel_mode")); binding.searchView.addTextChangedListener(new TextWatcher() { @Override diff --git a/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java b/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java index 4a2220a5..97e15e76 100644 --- a/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java +++ b/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java @@ -49,7 +49,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, updateAnonymizeValue(); binding.anonymizeLayout.setOnClickListener(v -> AnonymizeLevelSheet.newInstance(preferences.getAnonymizeLevel()) - .show(getChildFragmentManager(), "anonymize_level")); + .showNow(getChildFragmentManager(), "anonymize_level")); initializeRemoteJobsSwitch(inflater.getContext()); From 138cbbcf9420c5f8d1c688a7969152de1cabad94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 7 Oct 2026 14:06:36 +0200 Subject: [PATCH 2/4] Show dialogs only while the host is resumed showNow() alone covers one ordering: the click runs first and the pause flushes the pending show(). It does not cover the other one. View.onTouchEvent does not invoke the click listener inline; it posts it with post(mPerformClick), so the listener is a regular message that can land behind an already queued pause. It then runs on a paused, finishing activity and showNow() throws the same BadTokenException from the click handler, or an IllegalStateException if it lands after onSaveInstanceState. All eight dialog sites now go through one helper that checks the host's lifecycle is at least RESUMED before showNow(), and skips the dialog otherwise. RESUMED also rules out finishing after pause, destroyed and state-saved hosts, so the QrCodeDialog helper drops its separate checks and only keeps the field assignment for a dialog that was actually shown. --- .../java/io/netbird/client/MainActivity.java | 10 +++---- .../io/netbird/client/ui/DialogFragments.java | 26 +++++++++++++++++++ .../client/ui/advanced/AdvancedFragment.java | 3 ++- .../netbird/client/ui/home/HomeFragment.java | 5 ++-- .../client/ui/settings/SettingsFragment.java | 5 ++-- .../SplitTunnelingFragment.java | 5 ++-- .../ui/troubleshoot/TroubleshootFragment.java | 7 ++--- 7 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/io/netbird/client/ui/DialogFragments.java diff --git a/app/src/main/java/io/netbird/client/MainActivity.java b/app/src/main/java/io/netbird/client/MainActivity.java index 7dbe3c1b..0535f9f3 100644 --- a/app/src/main/java/io/netbird/client/MainActivity.java +++ b/app/src/main/java/io/netbird/client/MainActivity.java @@ -53,6 +53,7 @@ import io.netbird.client.tool.ServiceStateListener; import io.netbird.client.tool.SessionEventListener; import io.netbird.client.tool.VPNService; +import io.netbird.client.ui.DialogFragments; import io.netbird.client.ui.PreferenceUI; import io.netbird.client.ui.ssh.SshSessionManager; import io.netbird.gomobile.android.Android; @@ -995,15 +996,10 @@ public void onError(Exception e) { }); } - // The opener fires from the engine, so by the time this runs the activity - // may already be finishing or past onSaveInstanceState; neither can host a - // dialog, and a deferred show() would be flushed by the pause itself. private void showQrCodeDialog(QrCodeDialog dialog) { - if (isFinishing() || isDestroyed() || getSupportFragmentManager().isStateSaved()) { - return; + if (DialogFragments.showNow(this, getSupportFragmentManager(), dialog, "QrCodeDialog")) { + qrCodeDialog = dialog; } - qrCodeDialog = dialog; - dialog.showNow(getSupportFragmentManager(), "QrCodeDialog"); } // The login urlOpener stops the engine when the SSO surface is dismissed diff --git a/app/src/main/java/io/netbird/client/ui/DialogFragments.java b/app/src/main/java/io/netbird/client/ui/DialogFragments.java new file mode 100644 index 00000000..00232fb6 --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/DialogFragments.java @@ -0,0 +1,26 @@ +package io.netbird.client.ui; + +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; + +public final class DialogFragments { + + private DialogFragments() { + } + + // A click listener runs as a posted message and the engine's URLOpener runs + // from a background callback, so by the time either gets to show a dialog + // the host may already be paused, finishing or past onSaveInstanceState. + // None of those can host a window. Showing synchronously keeps a pending + // show() from being flushed by the pause itself; the resumed check covers + // the click landing behind the pause. + public static boolean showNow(LifecycleOwner host, FragmentManager fm, DialogFragment dialog, String tag) { + if (!host.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) { + return false; + } + dialog.showNow(fm, tag); + return true; + } +} 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 a05323ce..86d8f326 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 @@ -19,6 +19,7 @@ import io.netbird.client.databinding.FragmentAdvancedBinding; import io.netbird.client.tool.Preferences; import io.netbird.client.tool.ProfileManagerWrapper; +import io.netbird.client.ui.DialogFragments; public class AdvancedFragment extends Fragment implements ThemePickerSheet.OnThemeChangedListener { @@ -156,7 +157,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, binding.rowTheme.setOnClickListener(v -> { ThemePickerSheet sheet = new ThemePickerSheet(); - sheet.showNow(getChildFragmentManager(), "ThemePickerSheet"); + DialogFragments.showNow(this, getChildFragmentManager(), sheet, "ThemePickerSheet"); }); return root; diff --git a/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java b/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java index 32168ceb..0f91954d 100644 --- a/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java +++ b/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java @@ -33,6 +33,7 @@ import io.netbird.client.tool.Profile; import io.netbird.client.tool.ProfileManagerWrapper; import io.netbird.client.tool.RouteChangeListener; +import io.netbird.client.ui.DialogFragments; import io.netbird.gomobile.android.NetworkArray; public class HomeFragment extends Fragment implements StateListener, RouteChangeListener, ProfilePickerSheet.OnProfileSwitchedListener { @@ -200,12 +201,12 @@ public View onCreateView(@NonNull LayoutInflater inflater, binding.profileChip.setOnClickListener(v -> { ProfilePickerSheet sheet = new ProfilePickerSheet(); - sheet.showNow(getChildFragmentManager(), "ProfilePickerSheet"); + DialogFragments.showNow(this, getChildFragmentManager(), sheet, "ProfilePickerSheet"); }); binding.exitNodeRow.setOnClickListener(v -> { ExitNodePickerSheet sheet = new ExitNodePickerSheet(); - sheet.showNow(getChildFragmentManager(), "ExitNodePickerSheet"); + DialogFragments.showNow(this, getChildFragmentManager(), sheet, "ExitNodePickerSheet"); }); binding.sessionExpiryRow.setOnClickListener(v -> { if (serviceAccessor != null) { diff --git a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java index 2cc758cd..31ef66ed 100644 --- a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java +++ b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java @@ -20,6 +20,7 @@ import io.netbird.client.databinding.FragmentSettingsBinding; import io.netbird.client.tool.Profile; import io.netbird.client.tool.ProfileManagerWrapper; +import io.netbird.client.ui.DialogFragments; import io.netbird.client.ui.profile.ProfileEditorDialog; public class SettingsFragment extends Fragment { @@ -55,8 +56,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat binding.rowSplitTunneling.setOnClickListener(v -> navController.navigate(R.id.nav_split_tunneling)); - binding.rowLanguage.setOnClickListener(v -> - new LanguagePickerSheet().showNow(getChildFragmentManager(), "language_picker")); + binding.rowLanguage.setOnClickListener(v -> DialogFragments.showNow( + this, getChildFragmentManager(), new LanguagePickerSheet(), "language_picker")); binding.rowTroubleshoot.setOnClickListener(v -> navController.navigate(R.id.nav_troubleshoot)); diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java index 41231dd8..c8bead64 100644 --- a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java +++ b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java @@ -24,6 +24,7 @@ import io.netbird.client.databinding.FragmentSplitTunnelingBinding; import io.netbird.client.tool.SplitTunnelConfig; import io.netbird.client.tool.SplitTunnelStore; +import io.netbird.client.ui.DialogFragments; /** * Lets the user say which applications the tunnel carries. @@ -80,8 +81,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat binding.appsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); binding.appsRecyclerView.setAdapter(adapter); - binding.rowMode.setOnClickListener(v -> - SplitTunnelModeSheet.newInstance(mode).showNow(getChildFragmentManager(), "split_tunnel_mode")); + binding.rowMode.setOnClickListener(v -> DialogFragments.showNow( + this, getChildFragmentManager(), SplitTunnelModeSheet.newInstance(mode), "split_tunnel_mode")); binding.searchView.addTextChangedListener(new TextWatcher() { @Override diff --git a/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java b/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java index 97e15e76..c0b0dcf6 100644 --- a/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java +++ b/app/src/main/java/io/netbird/client/ui/troubleshoot/TroubleshootFragment.java @@ -19,6 +19,7 @@ import io.netbird.client.databinding.FragmentTroubleshootBinding; import io.netbird.client.tool.Preferences; import io.netbird.client.tool.ProfileManagerWrapper; +import io.netbird.client.ui.DialogFragments; public class TroubleshootFragment extends Fragment implements AnonymizeLevelSheet.OnLevelChangedListener { @@ -47,9 +48,9 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, }); updateAnonymizeValue(); - binding.anonymizeLayout.setOnClickListener(v -> - AnonymizeLevelSheet.newInstance(preferences.getAnonymizeLevel()) - .showNow(getChildFragmentManager(), "anonymize_level")); + binding.anonymizeLayout.setOnClickListener(v -> DialogFragments.showNow( + this, getChildFragmentManager(), + AnonymizeLevelSheet.newInstance(preferences.getAnonymizeLevel()), "anonymize_level")); initializeRemoteJobsSwitch(inflater.getContext()); From d98cea3cb0a1df3669ef30f0d12ea55c4dc7a33a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 7 Oct 2026 14:11:23 +0200 Subject: [PATCH 3/4] Cancel the session extend when no authentication surface opened On TV the QR dialog is the only login surface. When the extend URL arrives while the activity cannot show a dialog, nothing is on screen and nobody dismisses it, so cancelExtendSession() never runs: the Go flow keeps its loopback port until it times out and every later extend fails with "already in progress". showQrCodeDialog() now reports whether the dialog was shown. The extend opener cancels the flow when neither the dialog nor the browser opened, which is what dismissing the dialog would have done. --- .../java/io/netbird/client/MainActivity.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/netbird/client/MainActivity.java b/app/src/main/java/io/netbird/client/MainActivity.java index 0535f9f3..6b98ddc7 100644 --- a/app/src/main/java/io/netbird/client/MainActivity.java +++ b/app/src/main/java/io/netbird/client/MainActivity.java @@ -996,10 +996,12 @@ public void onError(Exception e) { }); } - private void showQrCodeDialog(QrCodeDialog dialog) { - if (DialogFragments.showNow(this, getSupportFragmentManager(), dialog, "QrCodeDialog")) { - qrCodeDialog = dialog; + private boolean showQrCodeDialog(QrCodeDialog dialog) { + if (!DialogFragments.showNow(this, getSupportFragmentManager(), dialog, "QrCodeDialog")) { + return false; } + qrCodeDialog = dialog; + return true; } // The login urlOpener stops the engine when the SSO surface is dismissed @@ -1015,16 +1017,23 @@ private URLOpener buildExtendURLOpener() { @Override public void open(String url, String userCode) { runOnUiThread(() -> { - showQrCodeDialog(QrCodeDialog.newInstance(url, userCode, + boolean dialogShown = showQrCodeDialog(QrCodeDialog.newInstance(url, userCode, MainActivity.this::cancelExtendSession)); + boolean browserOpened = false; if (!isRunningOnTV) { try { startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + browserOpened = true; } catch (Exception e) { Log.e(LOGTAG, "Failed to open browser for device code flow: " + e.getMessage()); } } + // Without a dialog there is nobody to dismiss, so the extend would + // otherwise sit on its loopback port until it times out. + if (!dialogShown && !browserOpened) { + cancelExtendSession(); + } }); } From 295e53b886bce8bd7b7b8d611a603bad2f08dfe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 12 Oct 2026 10:19:33 +0200 Subject: [PATCH 4/4] Skip showing dialogs while the host activity is finishing --- .../main/java/io/netbird/client/ui/DialogFragments.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/io/netbird/client/ui/DialogFragments.java b/app/src/main/java/io/netbird/client/ui/DialogFragments.java index 00232fb6..c4933028 100644 --- a/app/src/main/java/io/netbird/client/ui/DialogFragments.java +++ b/app/src/main/java/io/netbird/client/ui/DialogFragments.java @@ -1,6 +1,9 @@ package io.netbird.client.ui; +import android.app.Activity; + import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleOwner; @@ -20,6 +23,10 @@ public static boolean showNow(LifecycleOwner host, FragmentManager fm, DialogFra if (!host.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) { return false; } + Activity activity = host instanceof Fragment ? ((Fragment) host).getActivity() : host instanceof Activity ? (Activity) host : null; + if (activity != null && activity.isFinishing()) { + return false; + } dialog.showNow(fm, tag); return true; }