mirror of
https://git.citron-emu.org/citron/emulator
synced 2025-12-19 02:33:32 +00:00
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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ object Settings {
|
|||||||
|
|
||||||
const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch"
|
const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch"
|
||||||
const val PREF_MEMORY_WARNING_SHOWN = "MemoryWarningShown"
|
const val PREF_MEMORY_WARNING_SHOWN = "MemoryWarningShown"
|
||||||
|
const val PREF_CUSTOM_STORAGE_PATH = "CustomStoragePath"
|
||||||
|
|
||||||
// Deprecated input overlay preference keys
|
// Deprecated input overlay preference keys
|
||||||
const val PREF_CONTROL_SCALE = "controlScale"
|
const val PREF_CONTROL_SCALE = "controlScale"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-FileCopyrightText: 2025 citron Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
package org.citron.citron_emu.fragments
|
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.GpuDriverHelper
|
||||||
import org.citron.citron_emu.utils.Log
|
import org.citron.citron_emu.utils.Log
|
||||||
import org.citron.citron_emu.utils.ViewUtils.updateMargins
|
import org.citron.citron_emu.utils.ViewUtils.updateMargins
|
||||||
|
import org.citron.citron_emu.utils.collect
|
||||||
|
|
||||||
class HomeSettingsFragment : Fragment() {
|
class HomeSettingsFragment : Fragment() {
|
||||||
private var _binding: FragmentHomeSettingsBinding? = null
|
private var _binding: FragmentHomeSettingsBinding? = null
|
||||||
@@ -209,6 +211,14 @@ class HomeSettingsFragment : Fragment() {
|
|||||||
{ openFileManager() }
|
{ openFileManager() }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
add(
|
||||||
|
HomeSetting(
|
||||||
|
R.string.change_storage_location,
|
||||||
|
R.string.change_storage_location_description,
|
||||||
|
R.drawable.ic_install,
|
||||||
|
{ openStorageLocationPicker() }
|
||||||
|
)
|
||||||
|
)
|
||||||
add(
|
add(
|
||||||
HomeSetting(
|
HomeSetting(
|
||||||
R.string.preferences_theme,
|
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()
|
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
|
// 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.
|
// if we just started the app and the old log exists.
|
||||||
private fun shareLog() {
|
private fun shareLog() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-FileCopyrightText: 2025 citron Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
package org.citron.citron_emu.fragments
|
package org.citron.citron_emu.fragments
|
||||||
@@ -80,6 +81,9 @@ class SetupFragment : Fragment() {
|
|||||||
|
|
||||||
homeViewModel.setNavigationVisibility(visible = false, animated = false)
|
homeViewModel.setNavigationVisibility(visible = false, animated = false)
|
||||||
|
|
||||||
|
// Reset state flows to prevent stale values from triggering callbacks
|
||||||
|
homeViewModel.setStorageLocationChanged(false)
|
||||||
|
|
||||||
requireActivity().onBackPressedDispatcher.addCallback(
|
requireActivity().onBackPressedDispatcher.addCallback(
|
||||||
viewLifecycleOwner,
|
viewLifecycleOwner,
|
||||||
object : OnBackPressedCallback(true) {
|
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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
add(
|
add(
|
||||||
SetupPage(
|
SetupPage(
|
||||||
@@ -214,6 +249,14 @@ class SetupFragment : Fragment() {
|
|||||||
viewLifecycleOwner,
|
viewLifecycleOwner,
|
||||||
resetState = { homeViewModel.setGamesDirSelected(false) }
|
resetState = { homeViewModel.setGamesDirSelected(false) }
|
||||||
) { if (it) gamesDirCallback.onStepCompleted() }
|
) { if (it) gamesDirCallback.onStepCompleted() }
|
||||||
|
homeViewModel.storageLocationChanged.collect(
|
||||||
|
viewLifecycleOwner,
|
||||||
|
resetState = { homeViewModel.setStorageLocationChanged(false) }
|
||||||
|
) {
|
||||||
|
if (it && ::storageCallback.isInitialized) {
|
||||||
|
storageCallback.onStepCompleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
binding.viewPager2.apply {
|
binding.viewPager2.apply {
|
||||||
adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages)
|
adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages)
|
||||||
@@ -333,6 +376,8 @@ class SetupFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private lateinit var storageCallback: SetupCallback
|
||||||
|
|
||||||
private lateinit var gamesDirCallback: SetupCallback
|
private lateinit var gamesDirCallback: SetupCallback
|
||||||
|
|
||||||
val getGamesDirectory =
|
val getGamesDirectory =
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-FileCopyrightText: 2025 citron Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
package org.citron.citron_emu.model
|
package org.citron.citron_emu.model
|
||||||
@@ -34,6 +35,9 @@ class HomeViewModel : ViewModel() {
|
|||||||
private val _checkKeys = MutableStateFlow(false)
|
private val _checkKeys = MutableStateFlow(false)
|
||||||
val checkKeys = _checkKeys.asStateFlow()
|
val checkKeys = _checkKeys.asStateFlow()
|
||||||
|
|
||||||
|
private val _storageLocationChanged = MutableStateFlow(false)
|
||||||
|
val storageLocationChanged get() = _storageLocationChanged.asStateFlow()
|
||||||
|
|
||||||
var navigatedToSetup = false
|
var navigatedToSetup = false
|
||||||
|
|
||||||
fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
|
fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
|
||||||
@@ -73,4 +77,8 @@ class HomeViewModel : ViewModel() {
|
|||||||
fun setCheckKeys(value: Boolean) {
|
fun setCheckKeys(value: Boolean) {
|
||||||
_checkKeys.value = value
|
_checkKeys.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setStorageLocationChanged(changed: Boolean) {
|
||||||
|
_storageLocationChanged.value = changed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-FileCopyrightText: 2025 citron Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
package org.citron.citron_emu.utils
|
package org.citron.citron_emu.utils
|
||||||
@@ -38,10 +39,43 @@ object DirectoryInitialization {
|
|||||||
|
|
||||||
private fun initializeInternalStorage() {
|
private fun initializeInternalStorage() {
|
||||||
try {
|
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
|
userPath = CitronApplication.appContext.getExternalFilesDir(null)!!.canonicalPath
|
||||||
NativeLibrary.setAppDirectory(userPath!!)
|
NativeLibrary.setAppDirectory(userPath!!)
|
||||||
} catch (e: IOException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -83,6 +83,26 @@
|
|||||||
<string name="search_homebrew">Homebrew</string>
|
<string name="search_homebrew">Homebrew</string>
|
||||||
<string name="open_user_folder">Open citron folder</string>
|
<string name="open_user_folder">Open citron folder</string>
|
||||||
<string name="open_user_folder_description">Manage citron\'s internal files</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="theme_and_color_description">Modify the look of the app</string>
|
||||||
<string name="no_file_manager">No file manager found</string>
|
<string name="no_file_manager">No file manager found</string>
|
||||||
<string name="notification_no_directory_link">Could not open citron directory</string>
|
<string name="notification_no_directory_link">Could not open citron directory</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user