From c55435b78d93ea56462fb85b8c724bfd72d9eb33 Mon Sep 17 00:00:00 2001 From: PabloMK7 Date: Thu, 19 Mar 2026 13:48:58 +0100 Subject: [PATCH] android: Fix lifecycle bugs on SetupFragment (#1902) * android: Attempt fixing lifecycle bugs on SetupFragment * android: Fixed setup page number being lost on recreation * Move the registerForActivityResult to MainActivity * Code cleanup * ViewUtils.kt: Added missing guard clause in showView * Fixed permission buttons appearing to duplicate during setup * ViewUtils.kt: Updated license header --------- Co-authored-by: OpenSauce04 --- .../citra_emu/fragments/SetupFragment.kt | 183 ++++++++++-------- .../citra/citra_emu/ui/main/MainActivity.kt | 29 ++- .../org/citra/citra_emu/utils/ViewUtils.kt | 6 +- .../citra_emu/viewmodel/HomeViewModel.kt | 18 +- 4 files changed, 146 insertions(+), 90 deletions(-) diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupFragment.kt index f4cefc94f..17bd32dc4 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupFragment.kt @@ -23,7 +23,6 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -32,7 +31,6 @@ import androidx.preference.PreferenceManager import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import com.google.android.material.snackbar.Snackbar import com.google.android.material.transition.MaterialFadeThrough -import org.citra.citra_emu.BuildConfig import org.citra.citra_emu.CitraApplication import org.citra.citra_emu.NativeLibrary import org.citra.citra_emu.R @@ -92,23 +90,20 @@ class SetupFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { mainActivity = requireActivity() as MainActivity - homeViewModel.setNavigationVisibility(visible = false, animated = false) - - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (binding.viewPager2.currentItem > 0) { - pageBackward() - } else { - requireActivity().finish() - } - } + homeViewModel.selectedCitraDirectoryLiveData.observe(viewLifecycleOwner) { uri -> + if (uri == null) { + return@observe } - ) - - requireActivity().window.navigationBarColor = - ContextCompat.getColor(requireContext(), android.R.color.transparent) + onOpenCitraDirectory(uri) + homeViewModel.selectedCitraDirectory = null + } + homeViewModel.selectedGamesDirectoryLiveData.observe(viewLifecycleOwner) { uri -> + if (uri == null) { + return@observe + } + onGetGamesDirectory(uri) + homeViewModel.selectedGamesDirectory = null + } pages = mutableListOf() pages.apply { @@ -320,7 +315,7 @@ class SetupFragment : Fragment() { R.string.select_citra_user_folder_description, buttonAction = { pageButtonCallback = it - PermissionsHandler.compatibleSelectDirectory(openCitraDirectory) + PermissionsHandler.compatibleSelectDirectory(mainActivity.setupOpenCitraDirectory) }, buttonState = { if (PermissionsHandler.hasWriteAccess(requireContext())) { @@ -342,9 +337,9 @@ class SetupFragment : Fragment() { R.drawable.ic_controller, R.string.games, R.string.games_description, - buttonAction = { + buttonAction = { pageButtonCallback = it - getGamesDirectory.launch( + mainActivity.setupGetGamesDirectory.launch( Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data ) }, @@ -409,27 +404,33 @@ class SetupFragment : Fragment() { } binding.viewPager2.registerOnPageChangeCallback(object : OnPageChangeCallback() { - var previousPosition: Int = 0 override fun onPageSelected(position: Int) { super.onPageSelected(position) - - if (position == 1 && previousPosition == 0) { - ViewUtils.showView(binding.buttonNext) - ViewUtils.showView(binding.buttonBack) - } else if (position == 0 && previousPosition == 1) { - ViewUtils.hideView(binding.buttonBack) - ViewUtils.hideView(binding.buttonNext) - } else if (position == pages.size - 1 && previousPosition == pages.size - 2) { - ViewUtils.hideView(binding.buttonNext) - } else if (position == pages.size - 2 && previousPosition == pages.size - 1) { - ViewUtils.showView(binding.buttonNext) - } - - previousPosition = position + updateNavigationButtons(position) } }) + homeViewModel.setNavigationVisibility(visible = false, animated = false) + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (binding.viewPager2.currentItem > 0) { + pageBackward() + } else { + requireActivity().finish() + } + } + } + ) + + binding.viewPager2.currentItem = homeViewModel.setupCurrentPage + + requireActivity().window.navigationBarColor = + ContextCompat.getColor(requireContext(), android.R.color.transparent) + binding.buttonNext.setOnClickListener { val index = binding.viewPager2.currentItem val currentPage = pages[index] @@ -479,29 +480,23 @@ class SetupFragment : Fragment() { } binding.buttonBack.setOnClickListener { pageBackward() } - if (savedInstanceState != null) { - val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY) - val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY) - hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!! - - if (nextIsVisible) { - binding.buttonNext.visibility = View.VISIBLE - } - if (backIsVisible) { - binding.buttonBack.visibility = View.VISIBLE - } - } else { + if (savedInstanceState == null) { hasBeenWarned = BooleanArray(pages.size) + } else { + hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED) ?: BooleanArray(pages.size) } + updateNavigationButtons(binding.viewPager2.currentItem) + setInsets() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible) - outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible) - outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned) + + if (::hasBeenWarned.isInitialized) { + outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned) + } } override fun onDestroyView() { @@ -510,15 +505,39 @@ class SetupFragment : Fragment() { } private lateinit var pageButtonCallback: SetupCallback - private val checkForButtonState: () -> Unit = { - val page = pages[binding.viewPager2.currentItem] - page.pageButtons?.forEach { - if (it.buttonState() == ButtonState.BUTTON_ACTION_COMPLETE) { - pageButtonCallback.onStepCompleted(it.titleId, pageFullyCompleted = false) - } - if (page.pageSteps() == PageState.PAGE_STEPS_COMPLETE) { - pageButtonCallback.onStepCompleted(0, pageFullyCompleted = true) + private fun updateNavigationButtons(position: Int) { + if (position == 0) { + ViewUtils.hideView(binding.buttonBack) + } else { + ViewUtils.showView(binding.buttonBack) + } + + if (position == 0 || position == pages.size - 1) { + ViewUtils.hideView(binding.buttonNext) + } else { + ViewUtils.showView(binding.buttonNext) + } + } + + private val checkForButtonState: () -> Unit = { + val currentIndex = binding.viewPager2.currentItem + val page = pages[currentIndex] + + val isPageComplete = page.pageSteps() == PageState.PAGE_STEPS_COMPLETE + + if (isPageComplete) { + binding.viewPager2.adapter?.notifyItemChanged(currentIndex) + ViewUtils.showView(binding.buttonNext) + } else { + page.pageButtons?.forEach { + if (it.buttonState() == ButtonState.BUTTON_ACTION_COMPLETE) { + if (this::pageButtonCallback.isInitialized) { + pageButtonCallback.onStepCompleted(it.titleId, pageFullyCompleted = false) + } else { + binding.viewPager2.adapter?.notifyItemChanged(currentIndex) + } + } } } } @@ -559,13 +578,7 @@ class SetupFragment : Fragment() { showPermissionDeniedSnackbar() } - private val openCitraDirectory = registerForActivityResult( - ActivityResultContracts.OpenDocumentTree() - ) { result: Uri? -> - if (result == null) { - return@registerForActivityResult - } - + private fun onOpenCitraDirectory(result: Uri) { if (!BuildUtil.isGooglePlayBuild) { if (NativeLibrary.getNativePath(result) == "") { SelectUserDirectoryDialogFragment.newInstance( @@ -573,34 +586,30 @@ class SetupFragment : Fragment() { R.string.invalid_selection, R.string.invalid_user_directory ).show(mainActivity.supportFragmentManager, SelectUserDirectoryDialogFragment.TAG) - return@registerForActivityResult + return } } - CitraDirectoryHelper(requireActivity(), true).showCitraDirectoryDialog(result, pageButtonCallback, checkForButtonState) + CitraDirectoryHelper(requireActivity(), true).showCitraDirectoryDialog(result, + null, checkForButtonState) } - private val getGamesDirectory = - registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> - if (result == null) { - return@registerForActivityResult - } + private fun onGetGamesDirectory(result: Uri) { + requireActivity().contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) - requireActivity().contentResolver.takePersistableUriPermission( - result, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) + // When a new directory is picked, we currently will reset the existing games + // database. This effectively means that only one game directory is supported. + preferences.edit() + .putString(GameHelper.KEY_GAME_PATH, result.toString()) + .apply() - // When a new directory is picked, we currently will reset the existing games - // database. This effectively means that only one game directory is supported. - preferences.edit() - .putString(GameHelper.KEY_GAME_PATH, result.toString()) - .apply() + homeViewModel.setGamesDir(requireActivity(), result.path!!) - homeViewModel.setGamesDir(requireActivity(), result.path!!) - - checkForButtonState.invoke() - } + checkForButtonState.invoke() + } private fun finishSetup() { preferences.edit() @@ -611,10 +620,12 @@ class SetupFragment : Fragment() { fun pageForward() { binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1 + homeViewModel.setupCurrentPage = binding.viewPager2.currentItem } fun pageBackward() { binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1 + homeViewModel.setupCurrentPage = binding.viewPager2.currentItem } fun setPageWarned(page: Int) { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt index c3d3673b4..91df60632 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt @@ -76,6 +76,10 @@ class MainActivity : AppCompatActivity(), ThemeProvider { override var themeId: Int = 0 + companion object { + const val KEY_SETUP_CURRENT_PAGE = "SetupCurrentPage" + } + override fun onCreate(savedInstanceState: Bundle?) { RefreshRateUtil.enforceRefreshRate(this) @@ -136,7 +140,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { val navHostFragment = supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment - setUpNavigation(navHostFragment.navController) + setUpNavigation(savedInstanceState, navHostFragment.navController) (binding.navigationView as NavigationBarView).setOnItemReselectedListener { when (it.itemId) { R.id.gamesFragment -> { @@ -188,6 +192,14 @@ class MainActivity : AppCompatActivity(), ThemeProvider { setInsets() } + override fun onSaveInstanceState(outState: Bundle) { + // Save the user's current game state. + outState.putInt(KEY_SETUP_CURRENT_PAGE, homeViewModel.setupCurrentPage) + + // Always call the superclass so it can save the view hierarchy state. + super.onSaveInstanceState(outState) + } + override fun onResume() { checkUserPermissions() @@ -263,11 +275,12 @@ class MainActivity : AppCompatActivity(), ThemeProvider { (binding.navigationView as NavigationBarView).setupWithNavController(navController) } - private fun setUpNavigation(navController: NavController) { + private fun setUpNavigation(savedInstanceState: Bundle?, navController: NavController) { val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext) .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true) if (firstTimeSetup && !homeViewModel.navigatedToSetup) { + homeViewModel.setupCurrentPage = savedInstanceState?.getInt(KEY_SETUP_CURRENT_PAGE) ?: 0 navController.navigate(R.id.firstTimeSetupFragment) homeViewModel.navigatedToSetup = true } else { @@ -424,4 +437,16 @@ class MainActivity : AppCompatActivity(), ThemeProvider { .build() ) } + + val setupOpenCitraDirectory = registerForActivityResult( + ActivityResultContracts.OpenDocumentTree(), + ) { result: Uri? -> + homeViewModel.selectedCitraDirectory = result + } + + val setupGetGamesDirectory = registerForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { result: Uri? -> + homeViewModel.selectedGamesDirectory = result + } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ViewUtils.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/ViewUtils.kt index 828561579..f9647b631 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/ViewUtils.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ViewUtils.kt @@ -1,4 +1,4 @@ -// Copyright 2023 Citra Emulator Project +// Copyright Citra Emulator Project / Azahar Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. @@ -9,6 +9,10 @@ import android.view.ViewGroup object ViewUtils { fun showView(view: View, length: Long = 300) { + if (view.visibility == View.VISIBLE) { + return + } + view.apply { alpha = 0f visibility = View.VISIBLE diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt index 32b2449fb..739075522 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt @@ -1,4 +1,4 @@ -// Copyright 2023 Citra Emulator Project +// Copyright Citra Emulator Project / Azahar Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. @@ -7,6 +7,8 @@ package org.citra.citra_emu.viewmodel import android.content.res.Resources import android.net.Uri import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager @@ -62,6 +64,20 @@ class HomeViewModel : ViewModel() { var navigatedToSetup = false + var setupCurrentPage = 0 + + private val _selectedCitraDirectory = MutableLiveData() + val selectedCitraDirectoryLiveData: LiveData = _selectedCitraDirectory + var selectedCitraDirectory: Uri? + get() = _selectedCitraDirectory.value + set(value) { _selectedCitraDirectory.value = value } + + private val _selectedGamesDirectory = MutableLiveData() + val selectedGamesDirectoryLiveData: LiveData = _selectedGamesDirectory + var selectedGamesDirectory: Uri? + get() = _selectedGamesDirectory.value + set(value) { _selectedGamesDirectory.value = value } + fun setNavigationVisibility(visible: Boolean, animated: Boolean) { if (_navigationVisible.value.first == visible) { return