android: Add import/export buttons for user data
This commit is contained in:
		| @@ -49,6 +49,7 @@ class HomeSettingAdapter( | ||||
|             holder.option.onClick.invoke() | ||||
|         } else { | ||||
|             MessageDialogFragment.newInstance( | ||||
|                 activity, | ||||
|                 titleId = holder.option.disabledTitleId, | ||||
|                 descriptionId = holder.option.disabledMessageId | ||||
|             ).show(activity.supportFragmentManager, MessageDialogFragment.TAG) | ||||
|   | ||||
| @@ -26,6 +26,7 @@ import org.yuzu.yuzu_emu.BuildConfig | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding | ||||
| import org.yuzu.yuzu_emu.model.HomeViewModel | ||||
| import org.yuzu.yuzu_emu.ui.main.MainActivity | ||||
|  | ||||
| class AboutFragment : Fragment() { | ||||
|     private var _binding: FragmentAboutBinding? = null | ||||
| @@ -92,6 +93,12 @@ class AboutFragment : Fragment() { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         val mainActivity = requireActivity() as MainActivity | ||||
|         binding.buttonExport.setOnClickListener { mainActivity.exportUserData.launch("export.zip") } | ||||
|         binding.buttonImport.setOnClickListener { | ||||
|             mainActivity.importUserData.launch(arrayOf("application/zip")) | ||||
|         } | ||||
|  | ||||
|         binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) } | ||||
|         binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) } | ||||
|         binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) } | ||||
|   | ||||
| @@ -187,6 +187,7 @@ class ImportExportSavesFragment : DialogFragment() { | ||||
|                 withContext(Dispatchers.Main) { | ||||
|                     if (!validZip) { | ||||
|                         MessageDialogFragment.newInstance( | ||||
|                             requireActivity(), | ||||
|                             titleId = R.string.save_file_invalid_zip_structure, | ||||
|                             descriptionId = R.string.save_file_invalid_zip_structure_description | ||||
|                         ).show(activity.supportFragmentManager, MessageDialogFragment.TAG) | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
| package org.yuzu.yuzu_emu.fragments | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.content.DialogInterface | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| @@ -18,6 +19,7 @@ import androidx.lifecycle.lifecycleScope | ||||
| import androidx.lifecycle.repeatOnLifecycle | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import kotlinx.coroutines.launch | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding | ||||
| import org.yuzu.yuzu_emu.model.TaskViewModel | ||||
|  | ||||
| @@ -28,19 +30,27 @@ class IndeterminateProgressDialogFragment : DialogFragment() { | ||||
|  | ||||
|     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { | ||||
|         val titleId = requireArguments().getInt(TITLE) | ||||
|         val cancellable = requireArguments().getBoolean(CANCELLABLE) | ||||
|  | ||||
|         binding = DialogProgressBarBinding.inflate(layoutInflater) | ||||
|         binding.progressBar.isIndeterminate = true | ||||
|         val dialog = MaterialAlertDialogBuilder(requireContext()) | ||||
|             .setTitle(titleId) | ||||
|             .setView(binding.root) | ||||
|             .create() | ||||
|         dialog.setCanceledOnTouchOutside(false) | ||||
|  | ||||
|         if (cancellable) { | ||||
|             dialog.setNegativeButton(android.R.string.cancel) { _: DialogInterface, _: Int -> | ||||
|                 taskViewModel.setCancelled(true) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         val alertDialog = dialog.create() | ||||
|         alertDialog.setCanceledOnTouchOutside(false) | ||||
|  | ||||
|         if (!taskViewModel.isRunning.value) { | ||||
|             taskViewModel.runTask() | ||||
|         } | ||||
|         return dialog | ||||
|         return alertDialog | ||||
|     } | ||||
|  | ||||
|     override fun onCreateView( | ||||
| @@ -53,21 +63,35 @@ class IndeterminateProgressDialogFragment : DialogFragment() { | ||||
|  | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|         viewLifecycleOwner.lifecycleScope.launch { | ||||
|             repeatOnLifecycle(Lifecycle.State.CREATED) { | ||||
|                 taskViewModel.isComplete.collect { | ||||
|                     if (it) { | ||||
|                         dismiss() | ||||
|                         when (val result = taskViewModel.result.value) { | ||||
|                             is String -> Toast.makeText(requireContext(), result, Toast.LENGTH_LONG) | ||||
|                                 .show() | ||||
|         viewLifecycleOwner.lifecycleScope.apply { | ||||
|             launch { | ||||
|                 repeatOnLifecycle(Lifecycle.State.CREATED) { | ||||
|                     taskViewModel.isComplete.collect { | ||||
|                         if (it) { | ||||
|                             dismiss() | ||||
|                             when (val result = taskViewModel.result.value) { | ||||
|                                 is String -> Toast.makeText( | ||||
|                                     requireContext(), | ||||
|                                     result, | ||||
|                                     Toast.LENGTH_LONG | ||||
|                                 ).show() | ||||
|  | ||||
|                             is MessageDialogFragment -> result.show( | ||||
|                                 requireActivity().supportFragmentManager, | ||||
|                                 MessageDialogFragment.TAG | ||||
|                             ) | ||||
|                                 is MessageDialogFragment -> result.show( | ||||
|                                     requireActivity().supportFragmentManager, | ||||
|                                     MessageDialogFragment.TAG | ||||
|                                 ) | ||||
|                             } | ||||
|                             taskViewModel.clear() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             launch { | ||||
|                 repeatOnLifecycle(Lifecycle.State.CREATED) { | ||||
|                     taskViewModel.cancelled.collect { | ||||
|                         if (it) { | ||||
|                             dialog?.setTitle(R.string.cancelling) | ||||
|                         } | ||||
|                         taskViewModel.clear() | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| @@ -78,16 +102,19 @@ class IndeterminateProgressDialogFragment : DialogFragment() { | ||||
|         const val TAG = "IndeterminateProgressDialogFragment" | ||||
|  | ||||
|         private const val TITLE = "Title" | ||||
|         private const val CANCELLABLE = "Cancellable" | ||||
|  | ||||
|         fun newInstance( | ||||
|             activity: AppCompatActivity, | ||||
|             titleId: Int, | ||||
|             cancellable: Boolean = false, | ||||
|             task: () -> Any | ||||
|         ): IndeterminateProgressDialogFragment { | ||||
|             val dialog = IndeterminateProgressDialogFragment() | ||||
|             val args = Bundle() | ||||
|             ViewModelProvider(activity)[TaskViewModel::class.java].task = task | ||||
|             args.putInt(TITLE, titleId) | ||||
|             args.putBoolean(CANCELLABLE, cancellable) | ||||
|             dialog.arguments = args | ||||
|             return dialog | ||||
|         } | ||||
|   | ||||
| @@ -4,14 +4,21 @@ | ||||
| package org.yuzu.yuzu_emu.fragments | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.content.DialogInterface | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import android.os.Bundle | ||||
| import androidx.fragment.app.DialogFragment | ||||
| import androidx.fragment.app.FragmentActivity | ||||
| import androidx.fragment.app.activityViewModels | ||||
| import androidx.lifecycle.ViewModelProvider | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.model.MessageDialogViewModel | ||||
|  | ||||
| class MessageDialogFragment : DialogFragment() { | ||||
|     private val messageDialogViewModel: MessageDialogViewModel by activityViewModels() | ||||
|  | ||||
|     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { | ||||
|         val titleId = requireArguments().getInt(TITLE_ID) | ||||
|         val titleString = requireArguments().getString(TITLE_STRING)!! | ||||
| @@ -37,6 +44,12 @@ class MessageDialogFragment : DialogFragment() { | ||||
|         return dialog.show() | ||||
|     } | ||||
|  | ||||
|     override fun onDismiss(dialog: DialogInterface) { | ||||
|         super.onDismiss(dialog) | ||||
|         messageDialogViewModel.dismissAction.invoke() | ||||
|         messageDialogViewModel.clear() | ||||
|     } | ||||
|  | ||||
|     private fun openLink(link: String) { | ||||
|         val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) | ||||
|         startActivity(intent) | ||||
| @@ -52,11 +65,13 @@ class MessageDialogFragment : DialogFragment() { | ||||
|         private const val HELP_LINK = "Link" | ||||
|  | ||||
|         fun newInstance( | ||||
|             activity: FragmentActivity, | ||||
|             titleId: Int = 0, | ||||
|             titleString: String = "", | ||||
|             descriptionId: Int = 0, | ||||
|             descriptionString: String = "", | ||||
|             helpLinkId: Int = 0 | ||||
|             helpLinkId: Int = 0, | ||||
|             dismissAction: () -> Unit = {} | ||||
|         ): MessageDialogFragment { | ||||
|             val dialog = MessageDialogFragment() | ||||
|             val bundle = Bundle() | ||||
| @@ -67,6 +82,8 @@ class MessageDialogFragment : DialogFragment() { | ||||
|                 putString(DESCRIPTION_STRING, descriptionString) | ||||
|                 putInt(HELP_LINK, helpLinkId) | ||||
|             } | ||||
|             ViewModelProvider(activity)[MessageDialogViewModel::class.java].dismissAction = | ||||
|                 dismissAction | ||||
|             dialog.arguments = bundle | ||||
|             return dialog | ||||
|         } | ||||
|   | ||||
| @@ -0,0 +1,14 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.model | ||||
|  | ||||
| import androidx.lifecycle.ViewModel | ||||
|  | ||||
| class MessageDialogViewModel : ViewModel() { | ||||
|     var dismissAction: () -> Unit = {} | ||||
|  | ||||
|     fun clear() { | ||||
|         dismissAction = {} | ||||
|     } | ||||
| } | ||||
| @@ -20,12 +20,20 @@ class TaskViewModel : ViewModel() { | ||||
|     val isRunning: StateFlow<Boolean> get() = _isRunning | ||||
|     private val _isRunning = MutableStateFlow(false) | ||||
|  | ||||
|     val cancelled: StateFlow<Boolean> get() = _cancelled | ||||
|     private val _cancelled = MutableStateFlow(false) | ||||
|  | ||||
|     lateinit var task: () -> Any | ||||
|  | ||||
|     fun clear() { | ||||
|         _result.value = Any() | ||||
|         _isComplete.value = false | ||||
|         _isRunning.value = false | ||||
|         _cancelled.value = false | ||||
|     } | ||||
|  | ||||
|     fun setCancelled(value: Boolean) { | ||||
|         _cancelled.value = value | ||||
|     } | ||||
|  | ||||
|     fun runTask() { | ||||
|   | ||||
| @@ -46,13 +46,21 @@ import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment | ||||
| import org.yuzu.yuzu_emu.fragments.MessageDialogFragment | ||||
| import org.yuzu.yuzu_emu.model.GamesViewModel | ||||
| import org.yuzu.yuzu_emu.model.HomeViewModel | ||||
| import org.yuzu.yuzu_emu.model.TaskViewModel | ||||
| import org.yuzu.yuzu_emu.utils.* | ||||
| import java.io.BufferedInputStream | ||||
| import java.io.BufferedOutputStream | ||||
| import java.io.FileOutputStream | ||||
| import java.util.zip.ZipEntry | ||||
| import java.util.zip.ZipInputStream | ||||
| import java.util.zip.ZipOutputStream | ||||
|  | ||||
| class MainActivity : AppCompatActivity(), ThemeProvider { | ||||
|     private lateinit var binding: ActivityMainBinding | ||||
|  | ||||
|     private val homeViewModel: HomeViewModel by viewModels() | ||||
|     private val gamesViewModel: GamesViewModel by viewModels() | ||||
|     private val taskViewModel: TaskViewModel by viewModels() | ||||
|  | ||||
|     override var themeId: Int = 0 | ||||
|  | ||||
| @@ -307,6 +315,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { | ||||
|     fun processKey(result: Uri): Boolean { | ||||
|         if (FileUtil.getExtension(result) != "keys") { | ||||
|             MessageDialogFragment.newInstance( | ||||
|                 this, | ||||
|                 titleId = R.string.reading_keys_failure, | ||||
|                 descriptionId = R.string.install_prod_keys_failure_extension_description | ||||
|             ).show(supportFragmentManager, MessageDialogFragment.TAG) | ||||
| @@ -336,6 +345,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { | ||||
|                 return true | ||||
|             } else { | ||||
|                 MessageDialogFragment.newInstance( | ||||
|                     this, | ||||
|                     titleId = R.string.invalid_keys_error, | ||||
|                     descriptionId = R.string.install_keys_failure_description, | ||||
|                     helpLinkId = R.string.dumping_keys_quickstart_link | ||||
| @@ -376,6 +386,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { | ||||
|                     val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 | ||||
|                     messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { | ||||
|                         MessageDialogFragment.newInstance( | ||||
|                             this, | ||||
|                             titleId = R.string.firmware_installed_failure, | ||||
|                             descriptionId = R.string.firmware_installed_failure_description | ||||
|                         ) | ||||
| @@ -395,7 +406,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { | ||||
|             IndeterminateProgressDialogFragment.newInstance( | ||||
|                 this, | ||||
|                 R.string.firmware_installing, | ||||
|                 task | ||||
|                 task = task | ||||
|             ).show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) | ||||
|         } | ||||
|  | ||||
| @@ -407,6 +418,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { | ||||
|  | ||||
|             if (FileUtil.getExtension(result) != "bin") { | ||||
|                 MessageDialogFragment.newInstance( | ||||
|                     this, | ||||
|                     titleId = R.string.reading_keys_failure, | ||||
|                     descriptionId = R.string.install_amiibo_keys_failure_extension_description | ||||
|                 ).show(supportFragmentManager, MessageDialogFragment.TAG) | ||||
| @@ -434,6 +446,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { | ||||
|                     ).show() | ||||
|                 } else { | ||||
|                     MessageDialogFragment.newInstance( | ||||
|                         this, | ||||
|                         titleId = R.string.invalid_keys_error, | ||||
|                         descriptionId = R.string.install_keys_failure_description, | ||||
|                         helpLinkId = R.string.dumping_keys_quickstart_link | ||||
| @@ -583,12 +596,14 @@ class MainActivity : AppCompatActivity(), ThemeProvider { | ||||
|                         installResult.append(separator) | ||||
|                     } | ||||
|                     return@newInstance MessageDialogFragment.newInstance( | ||||
|                         this, | ||||
|                         titleId = R.string.install_game_content_failure, | ||||
|                         descriptionString = installResult.toString().trim(), | ||||
|                         helpLinkId = R.string.install_game_content_help_link | ||||
|                     ) | ||||
|                 } else { | ||||
|                     return@newInstance MessageDialogFragment.newInstance( | ||||
|                         this, | ||||
|                         titleId = R.string.install_game_content_success, | ||||
|                         descriptionString = installResult.toString().trim() | ||||
|                     ) | ||||
| @@ -596,4 +611,110 @@ class MainActivity : AppCompatActivity(), ThemeProvider { | ||||
|             }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     val exportUserData = registerForActivityResult( | ||||
|         ActivityResultContracts.CreateDocument("application/zip") | ||||
|     ) { result -> | ||||
|         if (result == null) { | ||||
|             return@registerForActivityResult | ||||
|         } | ||||
|  | ||||
|         IndeterminateProgressDialogFragment.newInstance( | ||||
|             this, | ||||
|             R.string.exporting_user_data, | ||||
|             true | ||||
|         ) { | ||||
|             val zos = ZipOutputStream( | ||||
|                 BufferedOutputStream(contentResolver.openOutputStream(result)) | ||||
|             ) | ||||
|             zos.use { stream -> | ||||
|                 File(DirectoryInitialization.userDirectory!!).walkTopDown().forEach { file -> | ||||
|                     if (taskViewModel.cancelled.value) { | ||||
|                         return@newInstance R.string.user_data_export_cancelled | ||||
|                     } | ||||
|  | ||||
|                     if (!file.isDirectory) { | ||||
|                         val newPath = file.path.substring( | ||||
|                             DirectoryInitialization.userDirectory!!.length, | ||||
|                             file.path.length | ||||
|                         ) | ||||
|                         stream.putNextEntry(ZipEntry(newPath)) | ||||
|                         stream.write(file.readBytes()) | ||||
|                         stream.closeEntry() | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             return@newInstance getString(R.string.user_data_export_success) | ||||
|         }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) | ||||
|     } | ||||
|  | ||||
|     val importUserData = | ||||
|         registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> | ||||
|             if (result == null) { | ||||
|                 return@registerForActivityResult | ||||
|             } | ||||
|  | ||||
|             IndeterminateProgressDialogFragment.newInstance( | ||||
|                 this, | ||||
|                 R.string.importing_user_data | ||||
|             ) { | ||||
|                 val checkStream = | ||||
|                     ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) | ||||
|                 var isYuzuBackup = false | ||||
|                 checkStream.use { stream -> | ||||
|                     var ze: ZipEntry? = null | ||||
|                     while (stream.nextEntry?.also { ze = it } != null) { | ||||
|                         if (ze!!.name.trim() == "/config/config.ini") { | ||||
|                             isYuzuBackup = true | ||||
|                             return@use | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 if (!isYuzuBackup) { | ||||
|                     return@newInstance getString(R.string.invalid_yuzu_backup) | ||||
|                 } | ||||
|  | ||||
|                 File(DirectoryInitialization.userDirectory!!).deleteRecursively() | ||||
|  | ||||
|                 val zis = | ||||
|                     ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) | ||||
|                 val userDirectory = File(DirectoryInitialization.userDirectory!!) | ||||
|                 val canonicalPath = userDirectory.canonicalPath + '/' | ||||
|                 zis.use { stream -> | ||||
|                     var ze: ZipEntry? = stream.nextEntry | ||||
|                     while (ze != null) { | ||||
|                         val newFile = File(userDirectory, ze!!.name) | ||||
|                         val destinationDirectory = | ||||
|                             if (ze!!.isDirectory) newFile else newFile.parentFile | ||||
|  | ||||
|                         if (!newFile.canonicalPath.startsWith(canonicalPath)) { | ||||
|                             throw SecurityException( | ||||
|                                 "Zip file attempted path traversal! ${ze!!.name}" | ||||
|                             ) | ||||
|                         } | ||||
|  | ||||
|                         if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) { | ||||
|                             throw IOException("Failed to create directory $destinationDirectory") | ||||
|                         } | ||||
|  | ||||
|                         if (!ze!!.isDirectory) { | ||||
|                             val buffer = ByteArray(8096) | ||||
|                             var read: Int | ||||
|                             BufferedOutputStream(FileOutputStream(newFile)).use { bos -> | ||||
|                                 while (zis.read(buffer).also { read = it } != -1) { | ||||
|                                     bos.write(buffer, 0, read) | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         ze = stream.nextEntry | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 // Reinitialize relevant data | ||||
|                 NativeLibrary.initializeEmulation() | ||||
|                 gamesViewModel.reloadGames(false) | ||||
|  | ||||
|                 return@newInstance getString(R.string.user_data_import_success) | ||||
|             }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) | ||||
|         } | ||||
| } | ||||
|   | ||||
							
								
								
									
										9
									
								
								src/android/app/src/main/res/drawable/ic_export.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/android/app/src/main/res/drawable/ic_export.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     android:width="24dp" | ||||
|     android:height="24dp" | ||||
|     android:viewportWidth="24" | ||||
|     android:viewportHeight="24"> | ||||
|     <path | ||||
|         android:fillColor="?attr/colorControlNormal" | ||||
|         android:pathData="M9,16h6v-6h4l-7,-7 -7,7h4zM5,18h14v2L5,20z" /> | ||||
| </vector> | ||||
							
								
								
									
										9
									
								
								src/android/app/src/main/res/drawable/ic_import.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/android/app/src/main/res/drawable/ic_import.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     android:width="24dp" | ||||
|     android:height="24dp" | ||||
|     android:viewportWidth="24" | ||||
|     android:viewportHeight="24"> | ||||
|     <path | ||||
|         android:fillColor="?attr/colorControlNormal" | ||||
|         android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z" /> | ||||
| </vector> | ||||
| @@ -1,24 +1,8 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
| <com.google.android.material.progressindicator.LinearProgressIndicator xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     android:orientation="vertical"> | ||||
|  | ||||
|     <com.google.android.material.progressindicator.LinearProgressIndicator | ||||
|         android:id="@+id/progress_bar" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_margin="24dp" | ||||
|         app:trackCornerRadius="4dp" /> | ||||
|  | ||||
|     <TextView | ||||
|         android:id="@+id/progress_text" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_marginLeft="24dp" | ||||
|         android:layout_marginRight="24dp" | ||||
|         android:layout_marginBottom="24dp" | ||||
|         android:gravity="end" /> | ||||
|  | ||||
| </LinearLayout> | ||||
|     android:id="@+id/progress_bar" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="wrap_content" | ||||
|     android:padding="24dp" | ||||
|     app:trackCornerRadius="4dp" /> | ||||
|   | ||||
| @@ -176,6 +176,67 @@ | ||||
|  | ||||
|             </LinearLayout> | ||||
|  | ||||
|             <com.google.android.material.divider.MaterialDivider | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_marginHorizontal="20dp" /> | ||||
|  | ||||
|             <LinearLayout | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:orientation="horizontal"> | ||||
|  | ||||
|                 <LinearLayout | ||||
|                     android:layout_width="match_parent" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:paddingVertical="16dp" | ||||
|                     android:paddingHorizontal="16dp" | ||||
|                     android:orientation="vertical" | ||||
|                     android:layout_weight="1"> | ||||
|  | ||||
|                     <com.google.android.material.textview.MaterialTextView | ||||
|                         style="@style/TextAppearance.Material3.TitleMedium" | ||||
|                         android:layout_width="match_parent" | ||||
|                         android:layout_height="wrap_content" | ||||
|                         android:layout_marginHorizontal="24dp" | ||||
|                         android:textAlignment="viewStart" | ||||
|                         android:text="@string/user_data" /> | ||||
|  | ||||
|                     <com.google.android.material.textview.MaterialTextView | ||||
|                         style="@style/TextAppearance.Material3.BodyMedium" | ||||
|                         android:layout_width="match_parent" | ||||
|                         android:layout_height="wrap_content" | ||||
|                         android:layout_marginHorizontal="24dp" | ||||
|                         android:layout_marginTop="6dp" | ||||
|                         android:textAlignment="viewStart" | ||||
|                         android:text="@string/user_data_description" /> | ||||
|  | ||||
|                 </LinearLayout> | ||||
|  | ||||
|                 <Button | ||||
|                     android:id="@+id/button_import" | ||||
|                     style="@style/Widget.Material3.Button.IconButton.Filled.Tonal" | ||||
|                     android:layout_width="wrap_content" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:layout_gravity="center_vertical" | ||||
|                     android:contentDescription="@string/string_import" | ||||
|                     android:tooltipText="@string/string_import" | ||||
|                     app:icon="@drawable/ic_import" /> | ||||
|  | ||||
|                 <Button | ||||
|                     android:id="@+id/button_export" | ||||
|                     style="@style/Widget.Material3.Button.IconButton.Filled.Tonal" | ||||
|                     android:layout_width="wrap_content" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:layout_marginStart="12dp" | ||||
|                     android:layout_marginEnd="24dp" | ||||
|                     android:layout_gravity="center_vertical" | ||||
|                     android:contentDescription="@string/export" | ||||
|                     android:tooltipText="@string/export" | ||||
|                     app:icon="@drawable/ic_export" /> | ||||
|  | ||||
|             </LinearLayout> | ||||
|  | ||||
|             <com.google.android.material.divider.MaterialDivider | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|   | ||||
| @@ -128,6 +128,15 @@ | ||||
|     <string name="contributors_link">https://github.com/yuzu-emu/yuzu/graphs/contributors</string> | ||||
|     <string name="licenses_description">Projects that make yuzu for Android possible</string> | ||||
|     <string name="build">Build</string> | ||||
|     <string name="user_data">User data</string> | ||||
|     <string name="user_data_description">Import/export all app data.\n\nWhen importing user data, all existing user data will be deleted!</string> | ||||
|     <string name="exporting_user_data">Exporting user data…</string> | ||||
|     <string name="importing_user_data">Importing user data…</string> | ||||
|     <string name="import_user_data">Import user data</string> | ||||
|     <string name="invalid_yuzu_backup">Invalid yuzu backup</string> | ||||
|     <string name="user_data_export_success">User data exported successfully</string> | ||||
|     <string name="user_data_import_success">User data imported successfully</string> | ||||
|     <string name="user_data_export_cancelled">Export cancelled</string> | ||||
|     <string name="support_link">https://discord.gg/u77vRWY</string> | ||||
|     <string name="website_link">https://yuzu-emu.org/</string> | ||||
|     <string name="github_link">https://github.com/yuzu-emu</string> | ||||
| @@ -215,6 +224,9 @@ | ||||
|     <string name="auto">Auto</string> | ||||
|     <string name="submit">Submit</string> | ||||
|     <string name="string_null">Null</string> | ||||
|     <string name="string_import">Import</string> | ||||
|     <string name="export">Export</string> | ||||
|     <string name="cancelling">Cancelling</string> | ||||
|  | ||||
|     <!-- GPU driver installation --> | ||||
|     <string name="select_gpu_driver">Select GPU driver</string> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user