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 <jamilbk@users.noreply.github.com>
This commit is contained in:
Jamil
2025-06-22 09:20:11 -07:00
committed by GitHub
parent 867f9dfad3
commit 3029e00355
5 changed files with 35 additions and 32 deletions

View File

@@ -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",
),
)
}

View File

@@ -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() {

View File

@@ -28,11 +28,6 @@ internal class SplashViewModel
private val actionMutableLiveData = MutableLiveData<ViewAction>()
val actionLiveData: LiveData<ViewAction> = 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)
}
}

View File

@@ -320,6 +320,7 @@ class TunnelService : VpnService() {
stopNetworkMonitoring()
stopDisconnectMonitoring()
stopSelf()
}
}
}

View File

@@ -21,6 +21,10 @@ export default function Android() {
<Entries downloadLinks={downloadLinks} title="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. */}
<Unreleased>
<ChangeItem pull="9621">
Fixes an issue where the VPN permission screen wouldn't dismiss after
granting the VPN permission.
</ChangeItem>
<ChangeItem pull="9564">
Fixes an issue where connections would fail to establish if both
Client and Gateway were behind symmetric NAT.