From 3029e00355cf3a920fecab68d002ee752cbba2b9 Mon Sep 17 00:00:00 2001 From: Jamil Date: Sun, 22 Jun 2025 09:20:11 -0700 Subject: [PATCH] fix(android): fix view state lifecycle around tunnel/auth (#9621) `onViewCreated()` is called when the view initializes, and then `onResume()` is called right after, in addition to anytime the view is shown again. To prevent showing the VPN permission activity twice, we remove the `checkTunnelState()` from onViewCreated, allowing only `onResume()` to call it. A boolean flag is added to track whether this is the "first" launch of the app in order to determine whether to `connectOnStart`. Fixes #9584 --------- Signed-off-by: Jamil --- .../android/features/auth/ui/AuthViewModel.kt | 10 ++---- .../features/splash/ui/SplashFragment.kt | 17 ++++++--- .../features/splash/ui/SplashViewModel.kt | 35 ++++++++----------- .../firezone/android/tunnel/TunnelService.kt | 1 + website/src/components/Changelog/Android.tsx | 4 +++ 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthViewModel.kt index d7c0a8ca6..abd77fc9f 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthViewModel.kt @@ -29,15 +29,11 @@ internal class AuthViewModel repo.saveNonceSync(nonce) repo.saveStateSync(state) val config = repo.getConfigSync() - val token = repo.getTokenSync() actionMutableLiveData.postValue( - if (authFlowLaunched || token != null) { - ViewAction.NavigateToSignIn - } else { - authFlowLaunched = true - ViewAction.LaunchAuthFlow("${config.authUrl}/${config.accountSlug}?state=$state&nonce=$nonce&as=client") - }, + ViewAction.LaunchAuthFlow( + "${config.authUrl}/${config.accountSlug}?state=$state&nonce=$nonce&as=client", + ), ) } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashFragment.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashFragment.kt index 385e8441c..265f92850 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashFragment.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashFragment.kt @@ -16,6 +16,7 @@ import dev.firezone.android.features.session.ui.SessionActivity internal class SplashFragment : Fragment(R.layout.fragment_splash) { private lateinit var binding: FragmentSplashBinding private val viewModel: SplashViewModel by viewModels() + private var isInitialLaunch: Boolean = true override fun onViewCreated( view: View, @@ -26,15 +27,23 @@ internal class SplashFragment : Fragment(R.layout.fragment_splash) { setupActionObservers() - if (savedInstanceState == null) { - // Trigger the initial check for tunnel state - viewModel.checkTunnelState(requireContext(), isInitialLaunch = true) + savedInstanceState?.let { + isInitialLaunch = it.getBoolean("isInitialLaunch", true) } } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + // Save the initial launch state to handle edge cases where the fragment is recreated + outState.putBoolean("isInitialLaunch", isInitialLaunch) + } + override fun onResume() { super.onResume() - viewModel.checkTunnelState(requireContext(), isInitialLaunch = false) + viewModel.checkTunnelState(requireContext(), isInitialLaunch) + + isInitialLaunch = false } private fun setupActionObservers() { diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt index 0a750a9b0..ec660a360 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt @@ -28,11 +28,6 @@ internal class SplashViewModel private val actionMutableLiveData = MutableLiveData() val actionLiveData: LiveData = actionMutableLiveData - // This flag is used to ensure that the initial launch check is only performed once, so - // that we can differentiate between a fresh launch and subsequent resumes for the connect - // on start logic. - private var hasPerformedInitialLaunchCheck = false - internal fun checkTunnelState( context: Context, isInitialLaunch: Boolean = false, @@ -41,15 +36,6 @@ internal class SplashViewModel // Stay a while and enjoy the logo delay(REQUEST_DELAY) - // We've already posted the initial action, so we can skip the rest of the checks - if (isInitialLaunch && hasPerformedInitialLaunchCheck) { - return@launch - } - - if (isInitialLaunch) { - hasPerformedInitialLaunchCheck = true - } - // If we don't have VPN permission, we can't continue. if (!hasVpnPermissions(context) && applicationMode != ApplicationMode.TESTING) { actionMutableLiveData.postValue(ViewAction.NavigateToVpnPermission) @@ -57,7 +43,6 @@ internal class SplashViewModel } val token = applicationRestrictions.getString("token") ?: repo.getTokenSync() - val connectOnStart = repo.getConfigSync().connectOnStart // If we don't have a token, we can't connect. if (token.isNullOrBlank()) { @@ -65,17 +50,25 @@ internal class SplashViewModel return@launch } - // If it's the initial launch but connect on start isn't enabled, we navigate to sign in. - if (isInitialLaunch && !connectOnStart) { - actionMutableLiveData.postValue(ViewAction.NavigateToSignIn) + val isRunning = TunnelService.isRunning(context) + + // If the service is already running, we can go directly to the session. + if (isRunning) { + actionMutableLiveData.postValue(ViewAction.NavigateToSession) return@launch } - // If we reach here, we have a token and should attempt to connect. - if (!TunnelService.isRunning(context)) { + val connectOnStart = repo.getConfigSync().connectOnStart + + // If this is the initial launch and connectOnStart is true, try to connect + if (isInitialLaunch && connectOnStart) { TunnelService.start(context) + actionMutableLiveData.postValue(ViewAction.NavigateToSession) + return@launch } - actionMutableLiveData.postValue(ViewAction.NavigateToSession) + + // If we get here, we shouldn't start the tunnel, so show the sign in screen + actionMutableLiveData.postValue(ViewAction.NavigateToSignIn) } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt index e7c6f7b45..0fa896569 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt @@ -320,6 +320,7 @@ class TunnelService : VpnService() { stopNetworkMonitoring() stopDisconnectMonitoring() + stopSelf() } } } diff --git a/website/src/components/Changelog/Android.tsx b/website/src/components/Changelog/Android.tsx index 95b3248b3..08d50cb58 100644 --- a/website/src/components/Changelog/Android.tsx +++ b/website/src/components/Changelog/Android.tsx @@ -21,6 +21,10 @@ export default function Android() { {/* When you cut a release, remove any solved issues from the "known issues" lists over in `client-apps`. This must not be done when the issue's PR merges. */} + + Fixes an issue where the VPN permission screen wouldn't dismiss after + granting the VPN permission. + Fixes an issue where connections would fail to establish if both Client and Gateway were behind symmetric NAT.