android: Add configurable storage location with automatic data migration

Implement a storage location picker for Android that allows users to
choose where Citron stores game data, saves, and other files instead
of being locked to the default app-specific directory.

Features:
- Storage location selection during first-time setup
- Settings option to change storage location after initial setup
- Support for internal storage, external SD card, and custom locations
- Automatic data migration when changing storage locations
- Progress dialog with cancellation support during migration
- Proper app restart handling to apply new storage path
- Detailed logging for debugging storage initialization

The picker offers three main options:
1. Internal Storage (Default) - App-specific directory, removed on uninstall
2. External SD Card - Persistent storage with more space (if available)
3. Custom Location - User-selected folder via Storage Access Framework

When switching locations, all existing data (saves, keys, config, shaders)
is automatically copied to the new location, ensuring a seamless transition.

This addresses user requests to store data in more accessible locations
like SD cards or Downloads folders, especially useful for devices with
limited internal storage or for easier data backup.

Signed-off-by: Zephyron <zephyron@citron-emu.org>
This commit is contained in:
Zephyron
2025-10-29 20:15:49 +10:00
parent 5025c4ab76
commit f4fb602bc4
10 changed files with 519 additions and 2 deletions

View File

@@ -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<StorageLocation>,
private val onLocationSelected: (StorageLocation) -> Unit
) : RecyclerView.Adapter<StorageLocationAdapter.StorageLocationViewHolder>() {
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)
}
}
}
}

View File

@@ -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"

View File

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

View File

@@ -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 =

View File

@@ -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<StorageLocation>()
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()
}
}
}

View File

@@ -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
}
}

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/text_description"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/storage_picker_description"
android:paddingBottom="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/storage_location_list"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:minHeight="100dp"
android:maxHeight="300dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text_description"
tools:itemCount="3"
tools:listitem="@layout/card_simple_outlined" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -83,6 +83,26 @@
<string name="search_homebrew">Homebrew</string>
<string name="open_user_folder">Open citron folder</string>
<string name="open_user_folder_description">Manage citron\'s internal files</string>
<string name="change_storage_location">Change storage location</string>
<string name="change_storage_location_description">Choose where citron stores its data</string>
<string name="select_storage_location">Select Storage Location</string>
<string name="storage_picker_description">Choose where citron will store game data, saves, and other files. Changing this will require restarting the app.</string>
<string name="storage_location">Storage Location</string>
<string name="storage_location_description">Choose where to store your citron data</string>
<string name="storage_default">Internal Storage (Default)</string>
<string name="storage_default_description">Stored in app\'s directory on internal storage. Automatically removed when app is uninstalled.</string>
<string name="storage_external_sd">External SD Card</string>
<string name="storage_external_sd_description">Store data on your SD card. Persists after uninstall and provides more storage space.</string>
<string name="storage_custom">Custom Location (Advanced)</string>
<string name="storage_custom_description">Choose any accessible folder. Note: May require copying data manually.</string>
<string name="storage_location_changed">Storage location changed. Please restart citron for changes to take effect.</string>
<string name="restart_required">Restart Required</string>
<string name="restart_required_description">You\'ve changed the storage location. Please restart citron to apply changes.</string>
<string name="migrating_data">Migrating Data</string>
<string name="migration_complete">Data migration completed successfully!</string>
<string name="migration_cancelled">Data migration cancelled</string>
<string name="migration_failed">Data Migration Failed</string>
<string name="migration_failed_description">Failed to migrate data: %1$s\n\nYou may need to manually copy your data to the new location.</string>
<string name="theme_and_color_description">Modify the look of the app</string>
<string name="no_file_manager">No file manager found</string>
<string name="notification_no_directory_link">Could not open citron directory</string>