android: Begin migration to raw fs access, starting with Rename reimplementation (#1511)

* Add setup step to grant `MANAGE_EXTERNAL_STORAGE`

* WIP re-implementation of Android `Rename` using native filesystem manipulation

* Applied clang-format and updated license headers

* Support user directories on removable storage devices

* If `MANAGE_EXTERNAL_STORAGE` is lost, re-request it

* Fix missing permission dialog appearing during initial setup

* Added empty code branches to prep for old Android support

* Fixed permission setup completion not accounting for external storage permission

* Implement code for Android <11

* Fixed emulation error if a renamed file is then opened for R/W

* Detect if the current user directory cannot be located, and prompt re-selection

* If an invalid user directory is selected, reject and re-prompt
This commit is contained in:
OpenSauce 2025-12-24 12:18:13 +00:00 committed by OpenSauce04
parent ba5215242f
commit 37dd01fd51
16 changed files with 390 additions and 82 deletions

View File

@ -29,6 +29,8 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:name="org.citra.citra_emu.CitraApplication"

View File

@ -7,10 +7,12 @@ package org.citra.citra_emu
import android.Manifest.permission
import android.app.Dialog
import android.content.DialogInterface
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.Surface
@ -18,11 +20,14 @@ import android.view.View
import android.widget.TextView
import androidx.annotation.Keep
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.fragment.app.DialogFragment
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.citra.citra_emu.activities.EmulationActivity
import org.citra.citra_emu.utils.FileUtil
import org.citra.citra_emu.utils.Log
import org.citra.citra_emu.utils.RemovableStorageHelper
import java.lang.ref.WeakReference
import java.util.Date
@ -629,6 +634,36 @@ object NativeLibrary {
FileUtil.getFilesName(path)
}
@Keep
@JvmStatic
fun getUserDirectory(uriOverride: Uri? = null): String {
val preferences: SharedPreferences =
PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
val dirSep = "/"
val udUri = uriOverride ?:
preferences.getString("CITRA_DIRECTORY", "")!!.toUri()
val udPathSegment = udUri.lastPathSegment!!
val udVirtualPath = udPathSegment.substringAfter(":")
if (udPathSegment.startsWith("primary:")) { // User directory is located in primary storage
val primaryStoragePath = Environment.getExternalStorageDirectory().absolutePath
return primaryStoragePath + dirSep + udVirtualPath + dirSep
} else { // User directory probably located on a removable storage device
val storageIdString = udPathSegment.substringBefore(":")
val udRemovablePath = RemovableStorageHelper.getRemovableStoragePath(storageIdString)
if (udRemovablePath == null) {
android.util.Log.e("NativeLibrary",
"Unknown mount location for storage device '$storageIdString' (URI: $udUri)"
)
return ""
}
return udRemovablePath + dirSep + udVirtualPath + dirSep
}
}
@Keep
@JvmStatic
fun getSize(path: String): Long =
@ -678,16 +713,8 @@ object NativeLibrary {
@Keep
@JvmStatic
fun renameFile(path: String, destinationFilename: String): Boolean =
if (FileUtil.isNativePath(path)) {
try {
CitraApplication.documentsTree.renameFile(path, destinationFilename)
} catch (e: Exception) {
false
}
} else {
FileUtil.renameFile(path, destinationFilename)
}
fun updateDocumentLocation(sourcePath: String, destinationPath: String): Boolean =
CitraApplication.documentsTree.updateDocumentLocation(sourcePath, destinationPath)
@Keep
@JvmStatic

View File

@ -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.
@ -60,7 +60,7 @@ class CitraDirectoryDialogFragment : DialogFragment() {
}
.setNegativeButton(android.R.string.cancel) { _: DialogInterface?, _: Int ->
if (!PermissionsHandler.hasWriteAccess(requireContext())) {
(requireActivity() as MainActivity)?.openCitraDirectory?.launch(null)
PermissionsHandler.compatibleSelectDirectory((requireActivity() as MainActivity).openCitraDirectory)
}
}
.show()

View File

@ -0,0 +1,77 @@
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.fragments
import android.Manifest
import android.app.Dialog
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.citra.citra_emu.R
import org.citra.citra_emu.ui.main.MainActivity
class GrantMissingFilesystemPermissionFragment : DialogFragment() {
private lateinit var mainActivity: MainActivity
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
mainActivity = requireActivity() as MainActivity
isCancelable = false
val requestPermissionFunction =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
{
manageExternalStoragePermissionLauncher.launch(
Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.fromParts("package", mainActivity.packageName, null)
)
)
}
} else {
{ permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) }
}
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.filesystem_permission_warning)
.setMessage(R.string.filesystem_permission_lost)
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
requestPermissionFunction()
}
.show()
}
@RequiresApi(Build.VERSION_CODES.R)
private val manageExternalStoragePermissionLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (Environment.isExternalStorageManager()) {
return@registerForActivityResult
}
}
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
return@registerForActivityResult
}
}
companion object {
const val TAG = "GrantMissingFilesystemPermissionFragment"
fun newInstance(): GrantMissingFilesystemPermissionFragment {
return GrantMissingFilesystemPermissionFragment()
}
}
}

View File

@ -159,7 +159,7 @@ class HomeSettingsFragment : Fragment() {
R.string.select_citra_user_folder,
R.string.select_citra_user_folder_home_description,
R.drawable.ic_home,
{ mainActivity?.openCitraDirectory?.launch(null) },
{ PermissionsHandler.compatibleSelectDirectory(mainActivity.openCitraDirectory) },
details = homeViewModel.userDir
),
HomeSetting(

View File

@ -13,21 +13,25 @@ import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.citra.citra_emu.R
import org.citra.citra_emu.ui.main.MainActivity
import org.citra.citra_emu.utils.PermissionsHandler
import org.citra.citra_emu.viewmodel.HomeViewModel
class SelectUserDirectoryDialogFragment : DialogFragment() {
class SelectUserDirectoryDialogFragment(titleOverride: Int? = null, descriptionOverride: Int? = null) : DialogFragment() {
private lateinit var mainActivity: MainActivity
private val title = titleOverride ?: R.string.select_citra_user_folder
private val description = descriptionOverride ?: R.string.selecting_user_directory_without_write_permissions
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
mainActivity = requireActivity() as MainActivity
isCancelable = false
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.select_citra_user_folder)
.setMessage(R.string.selecting_user_directory_without_write_permissions)
.setTitle(title)
.setMessage(description)
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
mainActivity?.openCitraDirectoryLostPermission?.launch(null)
PermissionsHandler.compatibleSelectDirectory(mainActivity.openCitraDirectoryLostPermission)
}
.show()
}
@ -35,9 +39,10 @@ class SelectUserDirectoryDialogFragment : DialogFragment() {
companion object {
const val TAG = "SelectUserDirectoryDialogFragment"
fun newInstance(activity: FragmentActivity): SelectUserDirectoryDialogFragment {
fun newInstance(activity: FragmentActivity, titleOverride: Int? = null, descriptionOverride: Int? = null):
SelectUserDirectoryDialogFragment {
ViewModelProvider(activity)[HomeViewModel::class.java].setPickingUserDir(true)
return SelectUserDirectoryDialogFragment()
return SelectUserDirectoryDialogFragment(titleOverride, descriptionOverride)
}
}
}

View File

@ -11,11 +11,13 @@ import android.content.pm.PackageManager
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.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
@ -31,6 +33,7 @@ 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.CitraApplication
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.R
import org.citra.citra_emu.adapters.SetupAdapter
import org.citra.citra_emu.databinding.FragmentSetupBinding
@ -142,7 +145,54 @@ class SetupFragment : Fragment() {
false,
0,
pageButtons = mutableListOf<PageButton>().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(
PageButton(
R.drawable.ic_folder,
R.string.filesystem_permission,
R.string.filesystem_permission_description,
buttonAction = {
pageButtonCallback = it
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
manageExternalStoragePermissionLauncher.launch(
Intent(
android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.fromParts(
"package",
requireActivity().packageName,
null
)
)
)
} else {
permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
},
buttonState = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Environment.isExternalStorageManager()) {
ButtonState.BUTTON_ACTION_COMPLETE
} else {
ButtonState.BUTTON_ACTION_INCOMPLETE
}
} else {
if (ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
) {
ButtonState.BUTTON_ACTION_COMPLETE
} else {
ButtonState.BUTTON_ACTION_INCOMPLETE
}
}
},
isUnskippable = true,
hasWarning = true,
R.string.filesystem_permission_warning,
R.string.filesystem_permission_warning_description,
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
add(
PageButton(
R.drawable.ic_notification,
@ -214,18 +264,31 @@ class SetupFragment : Fragment() {
)
},
) {
if (
var permissionsComplete =
// Microphone
ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED &&
// Camera
ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED &&
// Notifications
NotificationManagerCompat.from(requireContext())
.areNotificationsEnabled()
) {
// External Storage
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
permissionsComplete = (permissionsComplete && Environment.isExternalStorageManager())
} else {
permissionsComplete = (permissionsComplete && ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED)
}
if (permissionsComplete) {
PageState.PAGE_STEPS_COMPLETE
} else {
PageState.PAGE_STEPS_INCOMPLETE
@ -249,7 +312,7 @@ class SetupFragment : Fragment() {
R.string.select_citra_user_folder_description,
buttonAction = {
pageButtonCallback = it
openCitraDirectory.launch(null)
PermissionsHandler.compatibleSelectDirectory(openCitraDirectory)
},
buttonState = {
if (PermissionsHandler.hasWriteAccess(requireContext())) {
@ -452,6 +515,19 @@ class SetupFragment : Fragment() {
}
}
private fun showPermissionDeniedSnackbar() {
Snackbar.make(binding.root, R.string.permission_denied, Snackbar.LENGTH_LONG)
.setAnchorView(binding.buttonNext)
.setAction(R.string.grid_menu_core_settings) {
val intent =
Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
val uri = Uri.fromParts("package", requireActivity().packageName, null)
intent.data = uri
startActivity(intent)
}
.show()
}
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
@ -459,16 +535,19 @@ class SetupFragment : Fragment() {
return@registerForActivityResult
}
Snackbar.make(binding.root, R.string.permission_denied, Snackbar.LENGTH_LONG)
.setAnchorView(binding.buttonNext)
.setAction(R.string.grid_menu_core_settings) {
val intent =
Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
val uri = Uri.fromParts("package", requireActivity().packageName, null)
intent.data = uri
startActivity(intent)
}
.show()
showPermissionDeniedSnackbar()
}
// We can't use permissionLauncher because MANAGE_EXTERNAL_STORAGE is a special snowflake
@RequiresApi(Build.VERSION_CODES.R)
private val manageExternalStoragePermissionLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (Environment.isExternalStorageManager()) {
checkForButtonState.invoke()
return@registerForActivityResult
}
showPermissionDeniedSnackbar()
}
private val openCitraDirectory = registerForActivityResult<Uri, Uri>(
@ -478,6 +557,15 @@ class SetupFragment : Fragment() {
return@registerForActivityResult
}
if (NativeLibrary.getUserDirectory(result) == "") {
SelectUserDirectoryDialogFragment.newInstance(
mainActivity,
R.string.invalid_selection,
R.string.invalid_user_directory
).show(mainActivity.supportFragmentManager, SelectUserDirectoryDialogFragment.TAG)
return@registerForActivityResult
}
CitraDirectoryHelper(requireActivity(), true).showCitraDirectoryDialog(result, pageButtonCallback, checkForButtonState)
}

View File

@ -4,9 +4,12 @@
package org.citra.citra_emu.ui.main
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.view.WindowManager
@ -36,6 +39,7 @@ import androidx.work.WorkManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.navigation.NavigationBarView
import kotlinx.coroutines.launch
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.R
import org.citra.citra_emu.contracts.OpenFileResultContract
import org.citra.citra_emu.databinding.ActivityMainBinding
@ -43,6 +47,7 @@ import org.citra.citra_emu.features.settings.model.Settings
import org.citra.citra_emu.features.settings.model.SettingsViewModel
import org.citra.citra_emu.features.settings.ui.SettingsActivity
import org.citra.citra_emu.features.settings.utils.SettingsFile
import org.citra.citra_emu.fragments.GrantMissingFilesystemPermissionFragment
import org.citra.citra_emu.fragments.SelectUserDirectoryDialogFragment
import org.citra.citra_emu.fragments.UpdateUserDirectoryDialogFragment
import org.citra.citra_emu.utils.CiaInstallWorker
@ -185,14 +190,48 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext)
.getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)
if (!firstTimeSetup && !PermissionsHandler.hasWriteAccess(this) &&
!homeViewModel.isPickingUserDir.value
) {
if (firstTimeSetup) {
return
}
fun requestMissingFilesystemPermission() =
GrantMissingFilesystemPermissionFragment.newInstance()
.show(supportFragmentManager,GrantMissingFilesystemPermissionFragment.TAG)
if (supportFragmentManager.findFragmentByTag(GrantMissingFilesystemPermissionFragment.TAG) == null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (!Environment.isExternalStorageManager()) {
requestMissingFilesystemPermission()
}
} else {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) != PackageManager.PERMISSION_GRANTED) {
requestMissingFilesystemPermission()
}
}
}
if (homeViewModel.isPickingUserDir.value) {
return
}
if (!PermissionsHandler.hasWriteAccess(this)) {
SelectUserDirectoryDialogFragment.newInstance(this)
.show(supportFragmentManager, SelectUserDirectoryDialogFragment.TAG)
} else if (!firstTimeSetup && !homeViewModel.isPickingUserDir.value && CitraDirectoryUtils.needToUpdateManually()) {
return
} else if (CitraDirectoryUtils.needToUpdateManually()) {
UpdateUserDirectoryDialogFragment.newInstance(this)
.show(supportFragmentManager,UpdateUserDirectoryDialogFragment.TAG)
return
}
if (supportFragmentManager.findFragmentByTag(SelectUserDirectoryDialogFragment.TAG) == null) {
if (NativeLibrary.getUserDirectory() == "") {
SelectUserDirectoryDialogFragment.newInstance(this)
.show(supportFragmentManager, SelectUserDirectoryDialogFragment.TAG)
}
}
}
@ -316,6 +355,15 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
return@registerForActivityResult
}
if (NativeLibrary.getUserDirectory(result) == "") {
SelectUserDirectoryDialogFragment.newInstance(
this,
R.string.invalid_selection,
R.string.invalid_user_directory
).show(supportFragmentManager, SelectUserDirectoryDialogFragment.TAG)
return@registerForActivityResult
}
CitraDirectoryHelper(this@MainActivity, permissionsLost)
.showCitraDirectoryDialog(result, buttonState = {})
}

View File

@ -6,10 +6,12 @@ package org.citra.citra_emu.utils
import android.net.Uri
import android.provider.DocumentsContract
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.model.CheapDocument
import java.net.URLDecoder
import java.nio.file.Paths
import java.util.StringTokenizer
import java.util.concurrent.ConcurrentHashMap
@ -190,19 +192,6 @@ class DocumentsTree {
}
}
@Synchronized
fun renameFile(filepath: String, destinationFilename: String?): Boolean {
val node = resolvePath(filepath) ?: return false
try {
val filename = URLDecoder.decode(destinationFilename, FileUtil.DECODE_METHOD)
val newUri = DocumentsContract.renameDocument(context.contentResolver, node.uri!!, filename)
node.rename(filename, newUri)
return true
} catch (e: Exception) {
error("[DocumentsTree]: Cannot rename file, error: " + e.message)
}
}
@Synchronized
fun deleteDocument(filepath: String): Boolean {
val node = resolvePath(filepath) ?: return false
@ -219,6 +208,29 @@ class DocumentsTree {
}
}
@Synchronized
fun updateDocumentLocation(sourcePath: String, destinationPath: String): Boolean {
Log.error("Got paths: $sourcePath, $destinationPath")
val sourceNode = resolvePath(sourcePath)
val newName = Paths.get(destinationPath).fileName.toString()
val parentPath = Paths.get(destinationPath).parent.toString()
val newParent = resolvePath(parentPath)
val newUri = (getUri(parentPath).toString() + "%2F$newName").toUri() // <- Is there a better way?
if (sourceNode == null || newParent == null)
return false
sourceNode.parent!!.removeChild(sourceNode)
sourceNode.name = newName
sourceNode.parent = newParent
sourceNode.uri = newUri
newParent.addChild(sourceNode)
return true
}
@Synchronized
private fun resolvePath(filepath: String): DocumentsNode? {
root ?: return null
@ -288,15 +300,6 @@ class DocumentsTree {
loaded = true
}
@Synchronized
fun rename(name: String, uri: Uri?) {
parent ?: return
parent!!.removeChild(this)
this.name = name
this.uri = uri
parent!!.addChild(this)
}
fun addChild(node: DocumentsNode) {
children[node.name.lowercase()] = node
}

View File

@ -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.
@ -422,18 +422,6 @@ object FileUtil {
}
}
@JvmStatic
fun renameFile(path: String, destinationFilename: String): Boolean {
try {
val uri = Uri.parse(path)
DocumentsContract.renameDocument(context.contentResolver, uri, destinationFilename)
return true
} catch (e: Exception) {
Log.error("[FileUtil]: Cannot rename file, error: " + e.message)
}
return false
}
@JvmStatic
fun deleteDocument(path: String): Boolean {
try {

View File

@ -8,6 +8,9 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import androidx.activity.result.ActivityResultLauncher
import androidx.preference.PreferenceManager
import androidx.documentfile.provider.DocumentFile
import org.citra.citra_emu.CitraApplication
@ -48,4 +51,17 @@ object PermissionsHandler {
fun setCitraDirectory(uriString: String?) =
preferences.edit().putString(CITRA_DIRECTORY, uriString).apply()
fun compatibleSelectDirectory(activityLauncher: ActivityResultLauncher<Uri?>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activityLauncher.launch(null)
} else {
val initialUri = DocumentsContract.buildRootUri(
"com.android.externalstorage.documents",
"primary"
)
activityLauncher.launch(initialUri)
}
}
}

View File

@ -0,0 +1,24 @@
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.utils
import java.io.File
object RemovableStorageHelper {
// This really shouldn't be necessary, but the Android API seemingly
// doesn't have a way of doing this?
// Apparently, on certain devices the mount location can vary, so add
// extra cases here if we discover any new ones.
fun getRemovableStoragePath(idString: String): String? {
var pathFile: File
pathFile = File("/mnt/media_rw/$idString");
if (pathFile.exists()) {
return pathFile.absolutePath
}
return null
}
}

View File

@ -76,10 +76,14 @@
<string name="give_permission">Grant permission</string>
<string name="notification_warning">Skip granting the notification permission?</string>
<string name="notification_warning_description">Azahar won\'t be able to notify you of important information.</string>
<string name="filesystem_permission_warning">Missing Permissions</string>
<string name="filesystem_permission_warning_description">Azahar requires permission to manage files on this device in order to store and manage its data.\n\nPlease grant the \"Filesystem\" permission before continuing.</string>
<string name="camera_permission">Camera</string>
<string name="camera_permission_description">Grant the camera permission below to emulate the 3DS camera.</string>
<string name="microphone_permission">Microphone</string>
<string name="microphone_permission_description">Grant the microphone permission below to emulate the 3DS microphone.</string>
<string name="filesystem_permission">Filesystem</string>
<string name="filesystem_permission_description">Grant the filesystem permission below to allow Azahar to store files.</string>
<string name="permission_denied">Permission denied</string>
<string name="add_games_warning">Skip selecting applications folder?</string>
<string name="add_games_warning_description">Software won\'t be displayed in the Applications list if a folder isn\'t selected.</string>
@ -100,6 +104,9 @@
<string name="cannot_skip">You can\'t skip setting up the user folder</string>
<string name="cannot_skip_directory_description">This step is required to allow Azahar to work. Please select a directory and then you can continue.</string>
<string name="selecting_user_directory_without_write_permissions">You have lost write permissions on your <a href="https://web.archive.org/web/20240304193549/https://github.com/citra-emu/citra/wiki/Citra-Android-user-data-and-storage">user data</a> directory, where saves and other information are stored. This can happen after some app or Android updates. Please re-select the directory to regain permissions so you can continue.</string>
<string name="invalid_selection">Invalid Selection</string>
<string name="invalid_user_directory">The user directory selection was invalid.\nPlease re-select the user directory, ensuring that you navigate to it from the root of your device\'s storage.</string>
<string name="filesystem_permission_lost">Azahar has lost permission to manage files on this device. This can happen after some app or Android updates. Please re-grant this permission on the next screen to continue using the app.</string>
<string name="cannot_skip_directory_help" translatable="false">https://web.archive.org/web/20240304193549/https://github.com/citra-emu/citra/wiki/Citra-Android-user-data-and-storage</string>
<string name="set_up_theme_settings">Theme Settings</string>
<string name="setup_theme_settings_description">Configure your theme preferences for Azahar.</string>

View File

@ -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.
@ -150,6 +150,20 @@ std::vector<std::string> GetFilesName(const std::string& filepath) {
return vector;
}
std::optional<std::string> GetUserDirectory() {
if (get_user_directory == nullptr)
throw std::runtime_error(
"Unable to locate user directory: Function with ID 'get_user_directory' is missing");
auto env = GetEnvForThread();
auto j_user_directory =
(jstring)(env->CallStaticObjectMethod(native_library, get_user_directory, nullptr));
auto result = env->GetStringUTFChars(j_user_directory, nullptr);
if (result == "") {
return std::nullopt;
}
return result;
}
bool CopyFile(const std::string& source, const std::string& destination_path,
const std::string& destination_filename) {
if (copy_file == nullptr)
@ -162,13 +176,13 @@ bool CopyFile(const std::string& source, const std::string& destination_path,
j_destination_path, j_destination_filename);
}
bool RenameFile(const std::string& source, const std::string& filename) {
if (rename_file == nullptr)
bool UpdateDocumentLocation(const std::string& source_path, const std::string& destination_path) {
if (update_document_location == nullptr)
return false;
auto env = GetEnvForThread();
jstring j_source_path = env->NewStringUTF(source.c_str());
jstring j_destination_path = env->NewStringUTF(filename.c_str());
return env->CallStaticBooleanMethod(native_library, rename_file, j_source_path,
jstring j_source_path = env->NewStringUTF(source_path.c_str());
jstring j_destination_path = env->NewStringUTF(destination_path.c_str());
return env->CallStaticBooleanMethod(native_library, update_document_location, j_source_path,
j_destination_path);
}

View File

@ -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.
@ -19,12 +19,16 @@
open_content_uri, "openContentUri", "(Ljava/lang/String;Ljava/lang/String;)I") \
V(GetFilesName, std::vector<std::string>, (const std::string& filepath), get_files_name, \
"getFilesName", "(Ljava/lang/String;)[Ljava/lang/String;") \
V(GetUserDirectory, std::optional<std::string>, (), get_user_directory, "getUserDirectory", \
"(Landroid/net/Uri;)Ljava/lang/String;") \
V(CopyFile, bool, \
(const std::string& source, const std::string& destination_path, \
const std::string& destination_filename), \
copy_file, "copyFile", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z") \
V(RenameFile, bool, (const std::string& source, const std::string& filename), rename_file, \
"renameFile", "(Ljava/lang/String;Ljava/lang/String;)Z")
V(UpdateDocumentLocation, bool, \
(const std::string& source_path, const std::string& destination_path), \
update_document_location, "updateDocumentLocation", \
"(Ljava/lang/String;Ljava/lang/String;)Z")
#define ANDROID_SINGLE_PATH_DETERMINE_FUNCTIONS(V) \
V(IsDirectory, bool, is_directory, CallStaticBooleanMethod, "isDirectory", \
"(Ljava/lang/String;)Z") \

View File

@ -311,8 +311,13 @@ bool Rename(const std::string& srcFilename, const std::string& destFilename) {
Common::UTF8ToUTF16W(destFilename).c_str()) == 0)
return true;
#elif ANDROID
if (AndroidStorage::RenameFile(srcFilename, std::string(GetFilename(destFilename))))
std::optional<std::string> userDirLocation = AndroidStorage::GetUserDirectory();
if (userDirLocation && rename((*userDirLocation + srcFilename).c_str(),
(*userDirLocation + destFilename).c_str()) == 0) {
AndroidStorage::UpdateDocumentLocation(srcFilename, destFilename);
// ^ TODO: This shouldn't fail, but what should we do if it somehow does?
return true;
}
#else
if (rename(srcFilename.c_str(), destFilename.c_str()) == 0)
return true;