summaryrefslogtreecommitdiff
path: root/Source/Android/app/src/main/java
diff options
context:
space:
mode:
authorCharles Lombardo <clombardo169@gmail.com>2023-02-28 23:47:00 -0500
committerGitHub <noreply@github.com>2023-02-28 23:47:00 -0500
commit211be4698f11b620ba213f74f4018bf7de0d913d (patch)
tree9c7ec442dfdd1133b8bed781bd62110cc101fbc6 /Source/Android/app/src/main/java
parent73aaa01648dba5f9d13db9e21bfb29d41b1b8f81 (diff)
parentc904e068f0d52c3cdb8a659bb98a2c87da238d39 (diff)
Merge pull request #11515 from t895/user-data-kotlin
Android: Rewrite User Data Activity in Kotlin
Diffstat (limited to 'Source/Android/app/src/main/java')
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.java379
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.kt327
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/NotificationDialog.kt22
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/TaskCompleteDialog.kt37
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/TaskDialog.kt70
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/UserDataImportWarningDialog.kt59
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/TaskViewModel.kt56
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.java61
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.kt43
9 files changed, 614 insertions, 440 deletions
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.java
deleted file mode 100644
index be73e4fd74..0000000000
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.java
+++ /dev/null
@@ -1,379 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-package org.dolphinemu.dolphinemu.activities;
-
-import android.app.Activity;
-import android.content.ActivityNotFoundException;
-import android.content.Context;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Bundle;
-import android.view.View;
-
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.core.graphics.Insets;
-import androidx.core.view.ViewCompat;
-import androidx.core.view.WindowCompat;
-import androidx.core.view.WindowInsetsCompat;
-
-import com.google.android.material.color.MaterialColors;
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-
-import org.dolphinemu.dolphinemu.R;
-import org.dolphinemu.dolphinemu.databinding.ActivityUserDataBinding;
-import org.dolphinemu.dolphinemu.utils.DirectoryInitialization;
-import org.dolphinemu.dolphinemu.utils.InsetsHelper;
-import org.dolphinemu.dolphinemu.utils.Log;
-import org.dolphinemu.dolphinemu.utils.ThemeHelper;
-import org.dolphinemu.dolphinemu.utils.ThreadUtil;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-import java.util.zip.ZipOutputStream;
-
-public class UserDataActivity extends AppCompatActivity
-{
- private static final int REQUEST_CODE_IMPORT = 0;
- private static final int REQUEST_CODE_EXPORT = 1;
-
- private static final int BUFFER_SIZE = 64 * 1024;
-
- private boolean sMustRestartApp = false;
-
- private ActivityUserDataBinding mBinding;
-
- public static void launch(Context context)
- {
- Intent launcher = new Intent(context, UserDataActivity.class);
- context.startActivity(launcher);
- }
-
- @Override
- protected void onCreate(Bundle savedInstanceState)
- {
- ThemeHelper.setTheme(this);
-
- super.onCreate(savedInstanceState);
-
- mBinding = ActivityUserDataBinding.inflate(getLayoutInflater());
- setContentView(mBinding.getRoot());
-
- WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
-
- boolean android_10 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
- boolean android_11 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R;
- boolean legacy = DirectoryInitialization.isUsingLegacyUserDirectory();
-
- int user_data_new_location = android_10 ?
- R.string.user_data_new_location_android_10 : R.string.user_data_new_location;
- mBinding.textType.setText(legacy ? R.string.user_data_old_location : user_data_new_location);
-
- mBinding.textPath.setText(DirectoryInitialization.getUserDirectory());
-
- mBinding.textAndroid11.setVisibility(android_11 && !legacy ? View.VISIBLE : View.GONE);
-
- mBinding.buttonOpenSystemFileManager.setVisibility(android_11 ? View.VISIBLE : View.GONE);
- mBinding.buttonOpenSystemFileManager.setOnClickListener(view -> openFileManager());
-
- mBinding.buttonImportUserData.setOnClickListener(view -> importUserData());
-
- mBinding.buttonExportUserData.setOnClickListener(view -> exportUserData());
-
- setSupportActionBar(mBinding.toolbarUserData);
- getSupportActionBar().setDisplayHomeAsUpEnabled(true);
-
- setInsets();
- ThemeHelper.enableScrollTint(this, mBinding.toolbarUserData, mBinding.appbarUserData);
- }
-
- @Override
- public boolean onSupportNavigateUp()
- {
- onBackPressed();
- return true;
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data)
- {
- super.onActivityResult(requestCode, resultCode, data);
-
- if (requestCode == REQUEST_CODE_IMPORT && resultCode == Activity.RESULT_OK)
- {
- Uri uri = data.getData();
-
- new MaterialAlertDialogBuilder(this)
- .setMessage(R.string.user_data_import_warning)
- .setNegativeButton(R.string.no, (dialog, i) -> dialog.dismiss())
- .setPositiveButton(R.string.yes, (dialog, i) ->
- {
- dialog.dismiss();
-
- ThreadUtil.runOnThreadAndShowResult(this, R.string.import_in_progress,
- R.string.do_not_close_app,
- () -> getResources().getString(importUserData(uri)),
- (dialogInterface) ->
- {
- if (sMustRestartApp)
- {
- System.exit(0);
- }
- });
- })
- .show();
- }
- else if (requestCode == REQUEST_CODE_EXPORT && resultCode == Activity.RESULT_OK)
- {
- Uri uri = data.getData();
-
- ThreadUtil.runOnThreadAndShowResult(this, R.string.export_in_progress, 0,
- () -> getResources().getString(exportUserData(uri)));
- }
- }
-
- private void openFileManager()
- {
- try
- {
- // First, try the package name used on "normal" phones
- startActivity(getFileManagerIntent("com.google.android.documentsui"));
- }
- catch (ActivityNotFoundException e)
- {
- try
- {
- // Next, try the AOSP package name
- startActivity(getFileManagerIntent("com.android.documentsui"));
- }
- catch (ActivityNotFoundException e2)
- {
- // Activity not found. Perhaps it was removed by the OEM, or by some new Android version
- // that didn't exist at the time of writing. Not much we can do other than tell the user
- new MaterialAlertDialogBuilder(this)
- .setMessage(R.string.user_data_open_system_file_manager_failed)
- .setPositiveButton(R.string.ok, null)
- .show();
- }
- }
- }
-
- private Intent getFileManagerIntent(String packageName)
- {
- // Fragile, but some phones don't expose the system file manager in any better way
- Intent intent = new Intent(Intent.ACTION_MAIN);
- intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity");
- intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- return intent;
- }
-
- private void importUserData()
- {
- Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
- intent.setType("application/zip");
- startActivityForResult(intent, REQUEST_CODE_IMPORT);
- }
-
- private int importUserData(Uri source)
- {
- try
- {
- if (!isDolphinUserDataBackup(source))
- {
- return R.string.user_data_import_invalid_file;
- }
-
- try (InputStream is = getContentResolver().openInputStream(source))
- {
- try (ZipInputStream zis = new ZipInputStream(is))
- {
- File userDirectory = new File(DirectoryInitialization.getUserDirectory());
- String userDirectoryCanonicalized = userDirectory.getCanonicalPath() + '/';
-
- sMustRestartApp = true;
- deleteChildrenRecursively(userDirectory);
-
- DirectoryInitialization.getGameListCache(this).delete();
-
- ZipEntry ze;
- byte[] buffer = new byte[BUFFER_SIZE];
- while ((ze = zis.getNextEntry()) != null)
- {
- File destFile = new File(userDirectory, ze.getName());
- File destDirectory = ze.isDirectory() ? destFile : destFile.getParentFile();
-
- if (!destFile.getCanonicalPath().startsWith(userDirectoryCanonicalized))
- {
- Log.error("Zip file attempted path traversal! " + ze.getName());
- return R.string.user_data_import_failure;
- }
-
- if (!destDirectory.isDirectory() && !destDirectory.mkdirs())
- {
- throw new IOException("Failed to create directory " + destDirectory);
- }
-
- if (!ze.isDirectory())
- {
- try (FileOutputStream fos = new FileOutputStream(destFile))
- {
- int count;
- while ((count = zis.read(buffer)) != -1)
- {
- fos.write(buffer, 0, count);
- }
- }
-
- long time = ze.getTime();
- if (time > 0)
- {
- destFile.setLastModified(time);
- }
- }
- }
- }
- }
- }
- catch (IOException | NullPointerException e)
- {
- e.printStackTrace();
- return R.string.user_data_import_failure;
- }
-
- return R.string.user_data_import_success;
- }
-
- private boolean isDolphinUserDataBackup(Uri uri) throws IOException
- {
- try (InputStream is = getContentResolver().openInputStream(uri))
- {
- try (ZipInputStream zis = new ZipInputStream(is))
- {
- ZipEntry ze;
- while ((ze = zis.getNextEntry()) != null)
- {
- String name = ze.getName();
- if (name.equals("Config/Dolphin.ini"))
- {
- return true;
- }
- }
- }
- }
-
- return false;
- }
-
- private void deleteChildrenRecursively(File directory) throws IOException
- {
- File[] children = directory.listFiles();
- if (children == null)
- {
- throw new IOException("Could not find directory " + directory);
- }
- for (File child : children)
- {
- deleteRecursively(child);
- }
- }
-
- private void deleteRecursively(File file) throws IOException
- {
- if (file.isDirectory())
- {
- deleteChildrenRecursively(file);
- }
-
- if (!file.delete())
- {
- throw new IOException("Failed to delete " + file);
- }
- }
-
- private void exportUserData()
- {
- Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
- intent.setType("application/zip");
- intent.putExtra(Intent.EXTRA_TITLE, "dolphin-emu.zip");
- startActivityForResult(intent, REQUEST_CODE_EXPORT);
- }
-
- private int exportUserData(Uri destination)
- {
- try (OutputStream os = getContentResolver().openOutputStream(destination))
- {
- try (ZipOutputStream zos = new ZipOutputStream(os))
- {
- exportUserData(zos, new File(DirectoryInitialization.getUserDirectory()), null);
- }
- }
- catch (IOException e)
- {
- e.printStackTrace();
- return R.string.user_data_export_failure;
- }
-
- return R.string.user_data_export_success;
- }
-
- private void exportUserData(ZipOutputStream zos, File input, @Nullable File pathRelativeToRoot)
- throws IOException
- {
- if (input.isDirectory())
- {
- File[] children = input.listFiles();
- if (children == null)
- {
- throw new IOException("Could not find directory " + input);
- }
- for (File child : children)
- {
- exportUserData(zos, child, new File(pathRelativeToRoot, child.getName()));
- }
- if (children.length == 0 && pathRelativeToRoot != null)
- {
- zos.putNextEntry(new ZipEntry(pathRelativeToRoot.getPath() + '/'));
- }
- }
- else
- {
- try (FileInputStream fis = new FileInputStream(input))
- {
- byte[] buffer = new byte[BUFFER_SIZE];
- ZipEntry entry = new ZipEntry(pathRelativeToRoot.getPath());
- entry.setTime(input.lastModified());
- zos.putNextEntry(entry);
- int count;
- while ((count = fis.read(buffer, 0, buffer.length)) != -1)
- {
- zos.write(buffer, 0, count);
- }
- }
- }
- }
-
- private void setInsets()
- {
- ViewCompat.setOnApplyWindowInsetsListener(mBinding.appbarUserData, (v, windowInsets) ->
- {
- Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
-
- InsetsHelper.insetAppBar(insets, mBinding.appbarUserData);
-
- mBinding.scrollViewUserData.setPadding(insets.left, 0, insets.right, insets.bottom);
-
- InsetsHelper.applyNavbarWorkaround(insets.bottom, mBinding.workaroundView);
- ThemeHelper.setNavigationBarColor(this,
- MaterialColors.getColor(mBinding.appbarUserData, R.attr.colorSurface));
-
- return windowInsets;
- });
- }
-}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.kt
new file mode 100644
index 0000000000..5046e7c7c0
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.kt
@@ -0,0 +1,327 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.activities
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.color.MaterialColors
+import org.dolphinemu.dolphinemu.R
+import org.dolphinemu.dolphinemu.databinding.ActivityUserDataBinding
+import org.dolphinemu.dolphinemu.dialogs.NotificationDialog
+import org.dolphinemu.dolphinemu.dialogs.TaskDialog
+import org.dolphinemu.dolphinemu.dialogs.UserDataImportWarningDialog
+import org.dolphinemu.dolphinemu.model.TaskViewModel
+import org.dolphinemu.dolphinemu.utils.*
+import org.dolphinemu.dolphinemu.utils.ThemeHelper.enableScrollTint
+import org.dolphinemu.dolphinemu.utils.ThemeHelper.setNavigationBarColor
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+import java.util.zip.ZipOutputStream
+
+class UserDataActivity : AppCompatActivity() {
+ private lateinit var taskViewModel: TaskViewModel
+
+ private lateinit var mBinding: ActivityUserDataBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ ThemeHelper.setTheme(this)
+
+ super.onCreate(savedInstanceState)
+
+ mBinding = ActivityUserDataBinding.inflate(layoutInflater)
+ setContentView(mBinding.root)
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ val android10 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ val android11 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+ val legacy = DirectoryInitialization.isUsingLegacyUserDirectory()
+
+ val userDataNewLocation =
+ if (android10) R.string.user_data_new_location_android_10 else R.string.user_data_new_location
+ mBinding.textType.setText(if (legacy) R.string.user_data_old_location else userDataNewLocation)
+
+ mBinding.textPath.text = DirectoryInitialization.getUserDirectory()
+
+ mBinding.textAndroid11.visibility = if (android11 && !legacy) View.VISIBLE else View.GONE
+
+ mBinding.buttonOpenSystemFileManager.visibility = if (android11) View.VISIBLE else View.GONE
+ mBinding.buttonOpenSystemFileManager.setOnClickListener { openFileManager() }
+
+ mBinding.buttonImportUserData.setOnClickListener { importUserData() }
+
+ mBinding.buttonExportUserData.setOnClickListener { exportUserData() }
+
+ setSupportActionBar(mBinding.toolbarUserData)
+ supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+
+ setInsets()
+ enableScrollTint(this, mBinding.toolbarUserData, mBinding.appbarUserData)
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ onBackPressed()
+ return true
+ }
+
+ public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+
+ taskViewModel = ViewModelProvider(this)[TaskViewModel::class.java]
+ if (requestCode == REQUEST_CODE_IMPORT && resultCode == RESULT_OK) {
+ val arguments = Bundle()
+ arguments.putString(
+ UserDataImportWarningDialog.KEY_URI_RESULT,
+ data!!.data!!.toString()
+ )
+
+ val dialog = UserDataImportWarningDialog()
+ dialog.arguments = arguments
+ dialog.show(supportFragmentManager, UserDataImportWarningDialog.TAG)
+ } else if (requestCode == REQUEST_CODE_EXPORT && resultCode == RESULT_OK) {
+ taskViewModel.clear()
+ taskViewModel.task = {
+ val resultResource = exportUserData(data!!.data!!)
+ taskViewModel.setResult(resultResource)
+ }
+
+ val arguments = Bundle()
+ arguments.putInt(TaskDialog.KEY_TITLE, R.string.export_in_progress)
+ arguments.putInt(TaskDialog.KEY_MESSAGE, 0)
+ arguments.putBoolean(TaskDialog.KEY_CANCELLABLE, true)
+
+ val dialog = TaskDialog()
+ dialog.arguments = arguments
+ dialog.show(supportFragmentManager, TaskDialog.TAG)
+ }
+ }
+
+ private fun openFileManager() {
+ try {
+ // First, try the package name used on "normal" phones
+ startActivity(getFileManagerIntent("com.google.android.documentsui"))
+ } catch (e: ActivityNotFoundException) {
+ try {
+ // Next, try the AOSP package name
+ startActivity(getFileManagerIntent("com.android.documentsui"))
+ } catch (e2: ActivityNotFoundException) {
+ // Activity not found. Perhaps it was removed by the OEM, or by some new Android version
+ // that didn't exist at the time of writing. Not much we can do other than tell the user.
+ val arguments = Bundle()
+ arguments.putInt(
+ NotificationDialog.KEY_MESSAGE,
+ R.string.user_data_open_system_file_manager_failed
+ )
+
+ val dialog = NotificationDialog()
+ dialog.arguments = arguments
+ dialog.show(supportFragmentManager, NotificationDialog.TAG)
+ }
+ }
+ }
+
+ private fun getFileManagerIntent(packageName: String): Intent {
+ // Fragile, but some phones don't expose the system file manager in any better way
+ val intent = Intent(Intent.ACTION_MAIN)
+ intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity")
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ return intent
+ }
+
+ private fun importUserData() {
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
+ intent.type = "application/zip"
+ startActivityForResult(intent, REQUEST_CODE_IMPORT)
+ }
+
+ fun importUserData(source: Uri): Int {
+ try {
+ if (!isDolphinUserDataBackup(source))
+ return R.string.user_data_import_invalid_file
+
+ taskViewModel.mustRestartApp = true
+
+ contentResolver.openInputStream(source).use { `is` ->
+ ZipInputStream(`is`).use { zis ->
+ val userDirectory = File(DirectoryInitialization.getUserDirectory())
+ val userDirectoryCanonicalized = userDirectory.canonicalPath + '/'
+
+ deleteChildrenRecursively(userDirectory)
+
+ DirectoryInitialization.getGameListCache(this).delete()
+
+ var ze: ZipEntry? = zis.nextEntry
+ val buffer = ByteArray(BUFFER_SIZE)
+ while (ze != null) {
+ val destFile = File(userDirectory, ze.name)
+ val destDirectory = if (ze.isDirectory) destFile else destFile.parentFile
+
+ if (!destFile.canonicalPath.startsWith(userDirectoryCanonicalized)) {
+ Log.error("Zip file attempted path traversal! " + ze.name)
+ return R.string.user_data_import_failure
+ }
+
+ if (!destDirectory.isDirectory && !destDirectory.mkdirs()) {
+ throw IOException("Failed to create directory $destDirectory")
+ }
+
+ if (!ze.isDirectory) {
+ FileOutputStream(destFile).use { fos ->
+ var count: Int
+ while (zis.read(buffer).also { count = it } != -1) {
+ fos.write(buffer, 0, count)
+ }
+ }
+
+ val time = ze.time
+ if (time > 0) {
+ destFile.setLastModified(time)
+ }
+ }
+ ze = zis.nextEntry
+ }
+ }
+ }
+ } catch (e: IOException) {
+ e.printStackTrace()
+ return R.string.user_data_import_failure
+ } catch (e: NullPointerException) {
+ e.printStackTrace()
+ return R.string.user_data_import_failure
+ }
+ return R.string.user_data_import_success
+ }
+
+ @Throws(IOException::class)
+ private fun isDolphinUserDataBackup(uri: Uri): Boolean {
+ contentResolver.openInputStream(uri).use { `is` ->
+ ZipInputStream(`is`).use { zis ->
+ var ze: ZipEntry
+ while (zis.nextEntry.also { ze = it } != null) {
+ val name = ze.name
+ if (name == "Config/Dolphin.ini") {
+ return true
+ }
+ }
+ }
+ }
+ return false
+ }
+
+ @Throws(IOException::class)
+ private fun deleteChildrenRecursively(directory: File) {
+ val children =
+ directory.listFiles() ?: throw IOException("Could not find directory $directory")
+ for (child in children) {
+ deleteRecursively(child)
+ }
+ }
+
+ @Throws(IOException::class)
+ private fun deleteRecursively(file: File) {
+ if (file.isDirectory) {
+ deleteChildrenRecursively(file)
+ }
+
+ if (!file.delete()) {
+ throw IOException("Failed to delete $file")
+ }
+ }
+
+ private fun exportUserData() {
+ val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
+ intent.type = "application/zip"
+ intent.putExtra(Intent.EXTRA_TITLE, "dolphin-emu.zip")
+ startActivityForResult(intent, REQUEST_CODE_EXPORT)
+ }
+
+ private fun exportUserData(destination: Uri): Int {
+ try {
+ contentResolver.openOutputStream(destination).use { os ->
+ ZipOutputStream(os).use { zos ->
+ exportUserData(
+ zos,
+ File(DirectoryInitialization.getUserDirectory()),
+ null
+ )
+ }
+ }
+ } catch (e: IOException) {
+ e.printStackTrace()
+ return R.string.user_data_export_failure
+ }
+ return R.string.user_data_export_success
+ }
+
+ @Throws(IOException::class)
+ private fun exportUserData(zos: ZipOutputStream, input: File, pathRelativeToRoot: File?) {
+ if (input.isDirectory) {
+ val children = input.listFiles() ?: throw IOException("Could not find directory $input")
+
+ // Check if the coroutine was cancelled
+ if (!taskViewModel.cancelled) {
+ for (child in children) {
+ exportUserData(zos, child, File(pathRelativeToRoot, child.name))
+ }
+ }
+ if (children.isEmpty() && pathRelativeToRoot != null) {
+ zos.putNextEntry(ZipEntry(pathRelativeToRoot.path + '/'))
+ }
+ } else {
+ FileInputStream(input).use { fis ->
+ val buffer = ByteArray(BUFFER_SIZE)
+ val entry = ZipEntry(pathRelativeToRoot!!.path)
+ entry.time = input.lastModified()
+ zos.putNextEntry(entry)
+ var count: Int
+ while (fis.read(buffer, 0, buffer.size).also { count = it } != -1) {
+ zos.write(buffer, 0, count)
+ }
+ }
+ }
+ }
+
+ private fun setInsets() {
+ ViewCompat.setOnApplyWindowInsetsListener(mBinding.appbarUserData) { _: View?, windowInsets: WindowInsetsCompat ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+
+ InsetsHelper.insetAppBar(insets, mBinding.appbarUserData)
+
+ mBinding.scrollViewUserData.setPadding(insets.left, 0, insets.right, insets.bottom)
+
+ InsetsHelper.applyNavbarWorkaround(insets.bottom, mBinding.workaroundView)
+ setNavigationBarColor(
+ this,
+ MaterialColors.getColor(mBinding.appbarUserData, R.attr.colorSurface)
+ )
+ windowInsets
+ }
+ }
+
+ companion object {
+ private const val REQUEST_CODE_IMPORT = 0
+ private const val REQUEST_CODE_EXPORT = 1
+
+ private const val BUFFER_SIZE = 64 * 1024
+
+ @JvmStatic
+ fun launch(context: Context) {
+ val launcher = Intent(context, UserDataActivity::class.java)
+ context.startActivity(launcher)
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/NotificationDialog.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/NotificationDialog.kt
new file mode 100644
index 0000000000..a8026a1a78
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/NotificationDialog.kt
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.dialogs
+
+import android.app.Dialog
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+
+class NotificationDialog : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val dialog = MaterialAlertDialogBuilder(requireContext())
+ .setMessage(requireArguments().getInt(KEY_MESSAGE))
+ .setPositiveButton(android.R.string.ok, null)
+ return dialog.create()
+ }
+
+ companion object {
+ const val TAG = "NotificationDialog"
+ const val KEY_MESSAGE = "message"
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/TaskCompleteDialog.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/TaskCompleteDialog.kt
new file mode 100644
index 0000000000..734921bf0f
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/TaskCompleteDialog.kt
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.dialogs
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.dolphinemu.dolphinemu.model.TaskViewModel
+
+class TaskCompleteDialog : DialogFragment() {
+ private lateinit var viewModel: TaskViewModel
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ viewModel = ViewModelProvider(requireActivity())[TaskViewModel::class.java]
+
+ val dialog = MaterialAlertDialogBuilder(requireContext())
+ .setMessage(requireArguments().getInt(KEY_MESSAGE))
+ .setPositiveButton(android.R.string.ok, null)
+ return dialog.create()
+ }
+
+ override fun onDismiss(dialog: DialogInterface) {
+ super.onDismiss(dialog)
+ if (viewModel.onResultDismiss != null)
+ viewModel.onResultDismiss!!.invoke()
+
+ viewModel.clear()
+ }
+
+ companion object {
+ const val TAG = "TaskCompleteDialog"
+ const val KEY_MESSAGE = "message"
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/TaskDialog.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/TaskDialog.kt
new file mode 100644
index 0000000000..f28ff70884
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/TaskDialog.kt
@@ -0,0 +1,70 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.dialogs
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.dolphinemu.dolphinemu.R
+import org.dolphinemu.dolphinemu.model.TaskViewModel
+
+class TaskDialog : DialogFragment() {
+ private lateinit var viewModel: TaskViewModel
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ viewModel = ViewModelProvider(requireActivity())[TaskViewModel::class.java]
+
+ val dialogBuilder = MaterialAlertDialogBuilder(requireContext())
+ .setTitle(requireArguments().getInt(KEY_TITLE))
+ .setView(R.layout.dialog_indeterminate_progress)
+ if (requireArguments().getBoolean(KEY_CANCELLABLE)) {
+ dialogBuilder.setCancelable(true)
+ .setNegativeButton(android.R.string.cancel) { dialog: DialogInterface, _: Int ->
+ viewModel.cancelled = true
+ dialog.dismiss()
+ }
+ }
+
+ val dialog = dialogBuilder.create()
+ dialog.setCanceledOnTouchOutside(false)
+
+ val progressMessage = requireArguments().getInt(KEY_MESSAGE)
+ if (progressMessage != 0) dialog.setMessage(resources.getString(progressMessage))
+
+ viewModel.isComplete.observe(this) { complete: Boolean ->
+ if (complete && viewModel.result.value != null) {
+ dialog.dismiss()
+ val notificationArguments = Bundle()
+ notificationArguments.putInt(
+ TaskCompleteDialog.KEY_MESSAGE,
+ viewModel.result.value!!
+ )
+
+ val taskCompleteDialog = TaskCompleteDialog()
+ taskCompleteDialog.arguments = notificationArguments
+ taskCompleteDialog.show(
+ requireActivity().supportFragmentManager,
+ TaskCompleteDialog.TAG
+ )
+ }
+ }
+
+ viewModel.runTask()
+ return dialog
+ }
+
+ override fun onCancel(dialog: DialogInterface) {
+ super.onCancel(dialog)
+ viewModel.cancelled = true
+ }
+
+ companion object {
+ const val TAG = "TaskDialog"
+ const val KEY_TITLE = "title"
+ const val KEY_MESSAGE = "message"
+ const val KEY_CANCELLABLE = "cancellable"
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/UserDataImportWarningDialog.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/UserDataImportWarningDialog.kt
new file mode 100644
index 0000000000..4ae469bb17
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/UserDataImportWarningDialog.kt
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.dialogs
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import androidx.core.net.toUri
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.dolphinemu.dolphinemu.R
+import org.dolphinemu.dolphinemu.activities.UserDataActivity
+import org.dolphinemu.dolphinemu.model.TaskViewModel
+import kotlin.system.exitProcess
+
+class UserDataImportWarningDialog : DialogFragment() {
+ private lateinit var taskViewModel: TaskViewModel
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ taskViewModel = ViewModelProvider(requireActivity())[TaskViewModel::class.java]
+
+ val dialog = MaterialAlertDialogBuilder(requireContext())
+ .setMessage(R.string.user_data_import_warning)
+ .setNegativeButton(R.string.no) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
+ .setPositiveButton(R.string.yes) { dialog: DialogInterface, _: Int ->
+ dialog.dismiss()
+
+ val taskArguments = Bundle()
+ taskArguments.putInt(TaskDialog.KEY_TITLE, R.string.import_in_progress)
+ taskArguments.putInt(TaskDialog.KEY_MESSAGE, R.string.do_not_close_app)
+ taskArguments.putBoolean(TaskDialog.KEY_CANCELLABLE, false)
+
+ taskViewModel.task = {
+ taskViewModel.setResult(
+ (requireActivity() as UserDataActivity).importUserData(
+ requireArguments().getString(KEY_URI_RESULT)!!.toUri()
+ )
+ )
+ }
+
+ taskViewModel.onResultDismiss = {
+ if (taskViewModel.mustRestartApp) {
+ exitProcess(0)
+ }
+ }
+
+ val taskDialog = TaskDialog()
+ taskDialog.arguments = taskArguments
+ taskDialog.show(requireActivity().supportFragmentManager, TaskDialog.TAG)
+ }
+ return dialog.create()
+ }
+
+ companion object {
+ const val TAG = "UserDataImportWarningDialog"
+ const val KEY_URI_RESULT = "uri"
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/TaskViewModel.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/TaskViewModel.kt
new file mode 100644
index 0000000000..084ee0d512
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/TaskViewModel.kt
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.model
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.*
+
+class TaskViewModel : ViewModel() {
+ var cancelled = false
+ var mustRestartApp = false
+
+ private val _result = MutableLiveData<Int>()
+ val result: LiveData<Int> get() = _result
+
+ private val _isComplete = MutableLiveData<Boolean>()
+ val isComplete: LiveData<Boolean> get() = _isComplete
+
+ private val _isRunning = MutableLiveData<Boolean>()
+ val isRunning: LiveData<Boolean> get() = _isRunning
+
+ lateinit var task: () -> Unit
+ var onResultDismiss: (() -> Unit)? = null
+
+ init {
+ clear()
+ }
+
+ fun clear() {
+ _result.value = 0
+ _isComplete.value = false
+ cancelled = false
+ mustRestartApp = false
+ onResultDismiss = null
+ _isRunning.value = false
+ }
+
+ fun runTask() {
+ if (isRunning.value == true) return
+ _isRunning.value = true
+
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ task.invoke()
+ _isRunning.postValue(false)
+ _isComplete.postValue(true)
+ }
+ }
+ }
+
+ fun setResult(result: Int) {
+ _result.postValue(result)
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.java
deleted file mode 100644
index af464cf94b..0000000000
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-package org.dolphinemu.dolphinemu.utils;
-
-import android.app.Activity;
-import android.content.DialogInterface;
-import android.content.res.Resources;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.AlertDialog;
-
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-
-import org.dolphinemu.dolphinemu.R;
-
-import java.util.function.Supplier;
-
-public class ThreadUtil
-{
- public static void runOnThreadAndShowResult(Activity activity, int progressTitle,
- int progressMessage, @NonNull Supplier<String> f)
- {
- runOnThreadAndShowResult(activity, progressTitle, progressMessage, f, null);
- }
-
- public static void runOnThreadAndShowResult(Activity activity, int progressTitle,
- int progressMessage, @NonNull Supplier<String> f,
- @Nullable DialogInterface.OnDismissListener onResultDismiss)
- {
- Resources resources = activity.getResources();
- AlertDialog progressDialog = new MaterialAlertDialogBuilder(activity)
- .setTitle(progressTitle)
- .setView(R.layout.dialog_indeterminate_progress)
- .setCancelable(false)
- .create();
-
- if (progressMessage != 0)
- progressDialog.setMessage(resources.getString(progressMessage));
-
- progressDialog.show();
-
- new Thread(() ->
- {
- String result = f.get();
- activity.runOnUiThread(() ->
- {
- progressDialog.dismiss();
-
- if (result != null)
- {
- new MaterialAlertDialogBuilder(activity)
- .setMessage(result)
- .setPositiveButton(R.string.ok, (dialog, i) -> dialog.dismiss())
- .setOnDismissListener(onResultDismiss)
- .show();
- }
- });
- }, resources.getString(progressTitle)).start();
- }
-}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.kt
new file mode 100644
index 0000000000..12f93a8ab4
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.kt
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.utils
+
+import android.app.Activity
+import android.content.DialogInterface
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.dolphinemu.dolphinemu.R
+import java.util.function.Supplier
+
+object ThreadUtil {
+ @JvmStatic
+ @JvmOverloads
+ fun runOnThreadAndShowResult(
+ activity: Activity,
+ progressTitle: Int,
+ progressMessage: Int,
+ f: Supplier<String?>,
+ onResultDismiss: DialogInterface.OnDismissListener? = null
+ ) {
+ val resources = activity.resources
+ val progressDialog = MaterialAlertDialogBuilder(activity)
+ .setTitle(progressTitle)
+ .setView(R.layout.dialog_indeterminate_progress)
+ .setCancelable(false)
+ .create()
+ if (progressMessage != 0) progressDialog.setMessage(resources.getString(progressMessage))
+ progressDialog.show()
+ Thread({
+ val result = f.get()
+ activity.runOnUiThread {
+ progressDialog.dismiss()
+ if (result != null) {
+ MaterialAlertDialogBuilder(activity)
+ .setMessage(result)
+ .setPositiveButton(R.string.ok) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
+ .setOnDismissListener(onResultDismiss)
+ .show()
+ }
+ }
+ }, resources.getString(progressTitle)).start()
+ }
+}