mirror of
https://github.com/Lime3DS/Lime3DS.git
synced 2026-04-02 11:03:52 -06:00
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:
parent
ba5215242f
commit
37dd01fd51
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 = {})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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") \
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user