diff --git a/src/android/app/src/main/java/org/citron/citron_emu/adapters/StorageLocationAdapter.kt b/src/android/app/src/main/java/org/citron/citron_emu/adapters/StorageLocationAdapter.kt new file mode 100644 index 000000000..1f14d87a3 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/citron_emu/adapters/StorageLocationAdapter.kt @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.citron.citron_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.citron.citron_emu.databinding.CardSimpleOutlinedBinding +import org.citron.citron_emu.model.StorageLocation + +class StorageLocationAdapter( + private val locations: List, + private val onLocationSelected: (StorageLocation) -> Unit +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StorageLocationViewHolder { + val binding = CardSimpleOutlinedBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return StorageLocationViewHolder(binding) + } + + override fun onBindViewHolder(holder: StorageLocationViewHolder, position: Int) { + holder.bind(locations[position]) + } + + override fun getItemCount(): Int = locations.size + + inner class StorageLocationViewHolder(private val binding: CardSimpleOutlinedBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(location: StorageLocation) { + binding.title.setText(location.titleId) + binding.description.setText(location.descriptionId) + + binding.root.setOnClickListener { + onLocationSelected(location) + } + } + } +} diff --git a/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/Settings.kt index 90deb71ca..add4c8ea9 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/Settings.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/Settings.kt @@ -33,6 +33,7 @@ object Settings { const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch" const val PREF_MEMORY_WARNING_SHOWN = "MemoryWarningShown" + const val PREF_CUSTOM_STORAGE_PATH = "CustomStoragePath" // Deprecated input overlay preference keys const val PREF_CONTROL_SCALE = "controlScale" diff --git a/src/android/app/src/main/java/org/citron/citron_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/citron/citron_emu/fragments/HomeSettingsFragment.kt index 17d3b91a3..81111b7be 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/fragments/HomeSettingsFragment.kt @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later package org.citron.citron_emu.fragments @@ -44,6 +45,7 @@ import org.citron.citron_emu.utils.FileUtil import org.citron.citron_emu.utils.GpuDriverHelper import org.citron.citron_emu.utils.Log import org.citron.citron_emu.utils.ViewUtils.updateMargins +import org.citron.citron_emu.utils.collect class HomeSettingsFragment : Fragment() { private var _binding: FragmentHomeSettingsBinding? = null @@ -209,6 +211,14 @@ class HomeSettingsFragment : Fragment() { { openFileManager() } ) ) + add( + HomeSetting( + R.string.change_storage_location, + R.string.change_storage_location_description, + R.drawable.ic_install, + { openStorageLocationPicker() } + ) + ) add( HomeSetting( R.string.preferences_theme, @@ -263,6 +273,22 @@ class HomeSettingsFragment : Fragment() { ) } + // Listen for storage location changes + homeViewModel.storageLocationChanged.collect(viewLifecycleOwner, resetState = { + homeViewModel.setStorageLocationChanged(false) + }) { + if (it) { + MessageDialogFragment.newInstance( + titleId = R.string.restart_required, + descriptionId = R.string.restart_required_description, + positiveAction = { + // Kill the entire app process to ensure clean restart + android.os.Process.killProcess(android.os.Process.myPid()) + } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + setInsets() } @@ -368,6 +394,11 @@ class HomeSettingsFragment : Fragment() { } } + private fun openStorageLocationPicker() { + StoragePickerDialogFragment.newInstance() + .show(parentFragmentManager, StoragePickerDialogFragment.TAG) + } + // Share the current log if we just returned from a game but share the old log // if we just started the app and the old log exists. private fun shareLog() { diff --git a/src/android/app/src/main/java/org/citron/citron_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/citron/citron_emu/fragments/SetupFragment.kt index 711d16558..c1e41d2f4 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/fragments/SetupFragment.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/fragments/SetupFragment.kt @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later package org.citron.citron_emu.fragments @@ -80,6 +81,9 @@ class SetupFragment : Fragment() { homeViewModel.setNavigationVisibility(visible = false, animated = false) + // Reset state flows to prevent stale values from triggering callbacks + homeViewModel.setStorageLocationChanged(false) + requireActivity().onBackPressedDispatcher.addCallback( viewLifecycleOwner, object : OnBackPressedCallback(true) { @@ -111,6 +115,37 @@ class SetupFragment : Fragment() { ) ) + add( + SetupPage( + R.drawable.ic_folder_open, + R.string.storage_location, + R.string.storage_location_description, + R.drawable.ic_add, + true, + R.string.select_storage_location, + { + storageCallback = it + StoragePickerDialogFragment.newInstance() + .show(childFragmentManager, StoragePickerDialogFragment.TAG) + }, + false, + 0, + 0, + 0, + { + // Check if user has selected a storage location + // If no custom path is set, treat as incomplete (but skippable) + val preferences = PreferenceManager.getDefaultSharedPreferences(CitronApplication.appContext) + val hasCustomPath = preferences.contains(Settings.PREF_CUSTOM_STORAGE_PATH) + if (hasCustomPath) { + StepState.COMPLETE + } else { + StepState.UNDEFINED + } + } + ) + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { add( SetupPage( @@ -214,6 +249,14 @@ class SetupFragment : Fragment() { viewLifecycleOwner, resetState = { homeViewModel.setGamesDirSelected(false) } ) { if (it) gamesDirCallback.onStepCompleted() } + homeViewModel.storageLocationChanged.collect( + viewLifecycleOwner, + resetState = { homeViewModel.setStorageLocationChanged(false) } + ) { + if (it && ::storageCallback.isInitialized) { + storageCallback.onStepCompleted() + } + } binding.viewPager2.apply { adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages) @@ -333,6 +376,8 @@ class SetupFragment : Fragment() { } } + private lateinit var storageCallback: SetupCallback + private lateinit var gamesDirCallback: SetupCallback val getGamesDirectory = diff --git a/src/android/app/src/main/java/org/citron/citron_emu/fragments/StoragePickerDialogFragment.kt b/src/android/app/src/main/java/org/citron/citron_emu/fragments/StoragePickerDialogFragment.kt new file mode 100644 index 000000000..0e869692b --- /dev/null +++ b/src/android/app/src/main/java/org/citron/citron_emu/fragments/StoragePickerDialogFragment.kt @@ -0,0 +1,289 @@ +// SPDX-FileCopyrightText: 2025 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.citron.citron_emu.fragments + +import android.app.Dialog +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citron.citron_emu.R +import org.citron.citron_emu.CitronApplication +import org.citron.citron_emu.adapters.StorageLocationAdapter +import org.citron.citron_emu.databinding.DialogStoragePickerBinding +import org.citron.citron_emu.features.settings.model.Settings +import org.citron.citron_emu.model.HomeViewModel +import org.citron.citron_emu.model.StorageLocation +import org.citron.citron_emu.utils.DirectoryInitialization +import java.io.File + +class StoragePickerDialogFragment : DialogFragment() { + private var _binding: DialogStoragePickerBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + private val storageLocations = mutableListOf() + + private val getStorageDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + selectStoragePath(result) + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + _binding = DialogStoragePickerBinding.inflate(layoutInflater) + + setupStorageLocations() + + binding.storageLocationList.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = StorageLocationAdapter(storageLocations) { location -> + onStorageLocationSelected(location) + } + } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.select_storage_location) + .setView(binding.root) + .setNegativeButton(android.R.string.cancel, null) + .create() + } + + private fun setupStorageLocations() { + storageLocations.clear() + + // Get all available external storage locations + val externalStoragePaths = CitronApplication.appContext.getExternalFilesDirs(null) + + // Default app-specific storage (primary external storage) + // Create a citron subdirectory to make it more accessible + if (externalStoragePaths.isNotEmpty() && externalStoragePaths[0] != null) { + val defaultPath = File(externalStoragePaths[0], "citron") + storageLocations.add( + StorageLocation( + R.string.storage_default, + defaultPath.canonicalPath, + R.string.storage_default_description, + true + ) + ) + } + + // Add external SD card if available (secondary external storage) + if (externalStoragePaths.size > 1) { + externalStoragePaths.drop(1).forEach { path -> + if (path != null && path.exists()) { + val sdPath = File(path, "citron") + storageLocations.add( + StorageLocation( + R.string.storage_external_sd, + sdPath.canonicalPath, + R.string.storage_external_sd_description, + true + ) + ) + } + } + } + + // Custom location (user selects via SAF) + // Note: SAF content:// URIs cannot be used directly with native code + // This option is available but will require additional handling + storageLocations.add( + StorageLocation( + R.string.storage_custom, + "CUSTOM", + R.string.storage_custom_description, + false + ) + ) + } + + private fun onStorageLocationSelected(location: StorageLocation) { + when (location.path) { + "CUSTOM" -> { + // Open folder picker for custom location + // Note: SAF-selected folders will need special handling as they provide content:// URIs + // which are not directly accessible as file paths by native code + getStorageDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) + } + else -> { + // Direct path selection (app-specific or SD card storage) + setStoragePath(location.path) + dismiss() + } + } + } + + private fun selectStoragePath(uri: Uri) { + try { + requireActivity().contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + + val documentFile = DocumentFile.fromTreeUri(requireContext(), uri) + if (documentFile != null) { + // For SAF URIs, store the content URI + // Note: This requires special handling in DirectoryInitialization + // as native code cannot directly access content:// URIs + setStoragePath(uri.toString()) + dismiss() + } + } catch (e: Exception) { + // Handle permission errors + android.widget.Toast.makeText( + requireContext(), + "Failed to access selected location: ${e.message}", + android.widget.Toast.LENGTH_LONG + ).show() + } + } + + private fun setStoragePath(path: String) { + val preferences = PreferenceManager.getDefaultSharedPreferences(CitronApplication.appContext) + val oldPath = preferences.getString(Settings.PREF_CUSTOM_STORAGE_PATH, null) + + android.util.Log.d("StoragePicker", "Setting storage path: $path") + android.util.Log.d("StoragePicker", "Old path was: $oldPath") + + // Determine the current data directory + val currentDataDir = if (!oldPath.isNullOrEmpty() && !oldPath.startsWith("content://")) { + File(oldPath) + } else { + File(CitronApplication.appContext.getExternalFilesDir(null)!!.canonicalPath) + } + + val newDataDir = File(path) + + // Check if we need to migrate data + if (currentDataDir.exists() && currentDataDir.canonicalPath != newDataDir.canonicalPath) { + val hasData = currentDataDir.listFiles()?.isNotEmpty() == true + + if (hasData) { + // Show migration dialog + migrateData(currentDataDir, newDataDir, path) + } else { + // No data to migrate, just set the path + savePath(path, oldPath) + } + } else { + // Same path or no existing data + savePath(path, oldPath) + } + } + + private fun savePath(path: String, oldPath: String?) { + val preferences = PreferenceManager.getDefaultSharedPreferences(CitronApplication.appContext) + + // Save the new path - use commit() to ensure it's saved immediately + val success = preferences.edit() + .putString(Settings.PREF_CUSTOM_STORAGE_PATH, path) + .commit() + + android.util.Log.d("StoragePicker", "Preference save success: $success") + + // Verify it was saved + val savedPath = preferences.getString(Settings.PREF_CUSTOM_STORAGE_PATH, null) + android.util.Log.d("StoragePicker", "Verified saved path: $savedPath") + + // Show confirmation with the selected path + android.widget.Toast.makeText( + requireContext(), + "Storage location set to:\n$path", + android.widget.Toast.LENGTH_LONG + ).show() + + // Only trigger changed event if the path actually changed + if (oldPath != path) { + homeViewModel.setStorageLocationChanged(true) + } + } + + private fun migrateData(sourceDir: File, destDir: File, newPath: String) { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.migrating_data, + true + ) { progressCallback, _ -> + try { + android.util.Log.i("StoragePicker", "Starting data migration from ${sourceDir.path} to ${destDir.path}") + + // Create destination directory + if (!destDir.exists()) { + destDir.mkdirs() + } + + // Get list of files/directories to copy + val items = sourceDir.listFiles() ?: arrayOf() + val totalItems = items.size + + if (totalItems == 0) { + android.util.Log.i("StoragePicker", "No items to migrate") + return@newInstance getString(R.string.migration_complete) + } + + android.util.Log.i("StoragePicker", "Found $totalItems items to migrate") + + // Copy each item + items.forEachIndexed { index, item -> + // Check if cancelled + if (progressCallback(index, totalItems)) { + android.util.Log.w("StoragePicker", "Migration cancelled by user") + return@newInstance getString(R.string.migration_cancelled) + } + + val dest = File(destDir, item.name) + android.util.Log.d("StoragePicker", "Copying ${item.name}") + + if (item.isDirectory) { + item.copyRecursively(dest, overwrite = true) + } else { + item.copyTo(dest, overwrite = true) + } + } + + android.util.Log.i("StoragePicker", "Migration completed successfully") + + // Save the new path after successful migration + savePath(newPath, DirectoryInitialization.userDirectory) + + getString(R.string.migration_complete) + } catch (e: Exception) { + android.util.Log.e("StoragePicker", "Migration failed", e) + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.migration_failed, + descriptionString = getString(R.string.migration_failed_description, e.message) + ) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + const val TAG = "StoragePickerDialogFragment" + + fun newInstance(): StoragePickerDialogFragment { + return StoragePickerDialogFragment() + } + } +} diff --git a/src/android/app/src/main/java/org/citron/citron_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/citron/citron_emu/model/HomeViewModel.kt index 616af3394..3da4d56b1 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/model/HomeViewModel.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/model/HomeViewModel.kt @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.citron.citron_emu.model @@ -34,6 +35,9 @@ class HomeViewModel : ViewModel() { private val _checkKeys = MutableStateFlow(false) val checkKeys = _checkKeys.asStateFlow() + private val _storageLocationChanged = MutableStateFlow(false) + val storageLocationChanged get() = _storageLocationChanged.asStateFlow() + var navigatedToSetup = false fun setNavigationVisibility(visible: Boolean, animated: Boolean) { @@ -73,4 +77,8 @@ class HomeViewModel : ViewModel() { fun setCheckKeys(value: Boolean) { _checkKeys.value = value } + + fun setStorageLocationChanged(changed: Boolean) { + _storageLocationChanged.value = changed + } } diff --git a/src/android/app/src/main/java/org/citron/citron_emu/model/StorageLocation.kt b/src/android/app/src/main/java/org/citron/citron_emu/model/StorageLocation.kt new file mode 100644 index 000000000..429647690 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/citron_emu/model/StorageLocation.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2025 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.citron.citron_emu.model + +import androidx.annotation.StringRes + +data class StorageLocation( + @StringRes val titleId: Int, + val path: String, + @StringRes val descriptionId: Int, + val isDirectPath: Boolean +) diff --git a/src/android/app/src/main/java/org/citron/citron_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/citron/citron_emu/utils/DirectoryInitialization.kt index 97f106a57..fccfb9c04 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/utils/DirectoryInitialization.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/utils/DirectoryInitialization.kt @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later package org.citron.citron_emu.utils @@ -38,10 +39,43 @@ object DirectoryInitialization { private fun initializeInternalStorage() { try { + val preferences = PreferenceManager.getDefaultSharedPreferences(CitronApplication.appContext) + val customPath = preferences.getString(Settings.PREF_CUSTOM_STORAGE_PATH, null) + + android.util.Log.d("DirectoryInit", "Custom path from preferences: $customPath") + + userPath = if (!customPath.isNullOrEmpty()) { + // Check if it's a content:// URI (from SAF) or a direct file path + if (customPath.startsWith("content://")) { + // For SAF URIs, we cannot use them directly with native code + // Fall back to default location + android.util.Log.w("DirectoryInit", "Content URI detected, falling back to default") + CitronApplication.appContext.getExternalFilesDir(null)!!.canonicalPath + } else { + // Direct file path - ensure the directory exists + val dir = java.io.File(customPath) + if (!dir.exists()) { + android.util.Log.d("DirectoryInit", "Creating directory: $customPath") + dir.mkdirs() + } + android.util.Log.i("DirectoryInit", "Using custom path: $customPath") + customPath + } + } else { + // Default location + val defaultPath = CitronApplication.appContext.getExternalFilesDir(null)!!.canonicalPath + android.util.Log.i("DirectoryInit", "Using default path: $defaultPath") + defaultPath + } + + android.util.Log.i("DirectoryInit", "Final user path: $userPath") + NativeLibrary.setAppDirectory(userPath!!) + } catch (e: Exception) { + android.util.Log.e("DirectoryInit", "Error initializing storage", e) + e.printStackTrace() + // Fall back to default on any error userPath = CitronApplication.appContext.getExternalFilesDir(null)!!.canonicalPath NativeLibrary.setAppDirectory(userPath!!) - } catch (e: IOException) { - e.printStackTrace() } } diff --git a/src/android/app/src/main/res/layout/dialog_storage_picker.xml b/src/android/app/src/main/res/layout/dialog_storage_picker.xml new file mode 100644 index 000000000..78f0dbc0b --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_storage_picker.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index b9d0615d4..af72c699a 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -83,6 +83,26 @@ Homebrew Open citron folder Manage citron\'s internal files + Change storage location + Choose where citron stores its data + Select Storage Location + Choose where citron will store game data, saves, and other files. Changing this will require restarting the app. + Storage Location + Choose where to store your citron data + Internal Storage (Default) + Stored in app\'s directory on internal storage. Automatically removed when app is uninstalled. + External SD Card + Store data on your SD card. Persists after uninstall and provides more storage space. + Custom Location (Advanced) + Choose any accessible folder. Note: May require copying data manually. + Storage location changed. Please restart citron for changes to take effect. + Restart Required + You\'ve changed the storage location. Please restart citron to apply changes. + Migrating Data + Data migration completed successfully! + Data migration cancelled + Data Migration Failed + Failed to migrate data: %1$s\n\nYou may need to manually copy your data to the new location. Modify the look of the app No file manager found Could not open citron directory