From 8d16aed581851ee336069d3bdfd1f9273a097b44 Mon Sep 17 00:00:00 2001 From: Charles Lombardo Date: Thu, 26 Jan 2023 22:53:41 -0500 Subject: [PATCH 1/3] Android: Convert UserDataActivity to Kotlin --- .../activities/UserDataActivity.java | 379 ------------------ .../dolphinemu/activities/UserDataActivity.kt | 314 +++++++++++++++ 2 files changed, 314 insertions(+), 379 deletions(-) delete mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.java create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.kt 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..f110efe2eb --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.kt @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.activities + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.DialogInterface +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 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.* +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 +import kotlin.system.exitProcess + +class UserDataActivity : AppCompatActivity() { + private var sMustRestartApp = false + + 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) + + if (requestCode == REQUEST_CODE_IMPORT && resultCode == RESULT_OK) { + + MaterialAlertDialogBuilder(this) + .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() + + ThreadUtil.runOnThreadAndShowResult( + this, + R.string.import_in_progress, + R.string.do_not_close_app, + { resources.getString(importUserData(data!!.data!!)) }) { + if (sMustRestartApp) { + exitProcess(0) + } + } + } + .show() + } else if (requestCode == REQUEST_CODE_EXPORT && resultCode == RESULT_OK) { + ThreadUtil.runOnThreadAndShowResult( + this, + R.string.export_in_progress, + 0 + ) { resources.getString(exportUserData(data!!.data!!)) } + } + } + + 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. + MaterialAlertDialogBuilder(this) + .setMessage(R.string.user_data_open_system_file_manager_failed) + .setPositiveButton(R.string.ok, null) + .show() + } + } + } + + 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) + } + + private fun importUserData(source: Uri): Int { + try { + if (!isDolphinUserDataBackup(source)) + return R.string.user_data_import_invalid_file + + contentResolver.openInputStream(source).use { `is` -> + ZipInputStream(`is`).use { zis -> + val userDirectory = File(DirectoryInitialization.getUserDirectory()) + val userDirectoryCanonicalized = userDirectory.canonicalPath + '/' + + sMustRestartApp = true + 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") + 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) + } + } +} From 8d1cf14565bc1a21951b1fece67d1a43790e6413 Mon Sep 17 00:00:00 2001 From: Charles Lombardo Date: Tue, 28 Feb 2023 22:21:18 -0500 Subject: [PATCH 2/3] Android: Convert ThreadUtil to Kotlin --- .../dolphinemu/utils/ThreadUtil.java | 61 ------------------- .../dolphinemu/dolphinemu/utils/ThreadUtil.kt | 43 +++++++++++++ 2 files changed, 43 insertions(+), 61 deletions(-) delete mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.java create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.kt 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 f) - { - runOnThreadAndShowResult(activity, progressTitle, progressMessage, f, null); - } - - public static void runOnThreadAndShowResult(Activity activity, int progressTitle, - int progressMessage, @NonNull Supplier 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, + 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() + } +} From c904e068f0d52c3cdb8a659bb98a2c87da238d39 Mon Sep 17 00:00:00 2001 From: Charles Lombardo Date: Fri, 27 Jan 2023 00:46:00 -0500 Subject: [PATCH 3/3] Android: Use DialogFragments to direct UserData actions --- .../dolphinemu/activities/UserDataActivity.kt | 81 +++++++++++-------- .../dolphinemu/dialogs/NotificationDialog.kt | 22 +++++ .../dolphinemu/dialogs/TaskCompleteDialog.kt | 37 +++++++++ .../dolphinemu/dialogs/TaskDialog.kt | 70 ++++++++++++++++ .../dialogs/UserDataImportWarningDialog.kt | 59 ++++++++++++++ .../dolphinemu/model/TaskViewModel.kt | 56 +++++++++++++ 6 files changed, 291 insertions(+), 34 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/NotificationDialog.kt create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/TaskCompleteDialog.kt create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/TaskDialog.kt create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/UserDataImportWarningDialog.kt create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/TaskViewModel.kt 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 index f110efe2eb..5046e7c7c0 100644 --- 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 @@ -4,7 +4,6 @@ package org.dolphinemu.dolphinemu.activities import android.content.ActivityNotFoundException import android.content.Context -import android.content.DialogInterface import android.content.Intent import android.net.Uri import android.os.Build @@ -14,10 +13,14 @@ 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 com.google.android.material.dialog.MaterialAlertDialogBuilder 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 @@ -28,10 +31,9 @@ import java.io.IOException import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream -import kotlin.system.exitProcess class UserDataActivity : AppCompatActivity() { - private var sMustRestartApp = false + private lateinit var taskViewModel: TaskViewModel private lateinit var mBinding: ActivityUserDataBinding @@ -79,31 +81,32 @@ class UserDataActivity : AppCompatActivity() { 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() + ) - MaterialAlertDialogBuilder(this) - .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() - - ThreadUtil.runOnThreadAndShowResult( - this, - R.string.import_in_progress, - R.string.do_not_close_app, - { resources.getString(importUserData(data!!.data!!)) }) { - if (sMustRestartApp) { - exitProcess(0) - } - } - } - .show() + val dialog = UserDataImportWarningDialog() + dialog.arguments = arguments + dialog.show(supportFragmentManager, UserDataImportWarningDialog.TAG) } else if (requestCode == REQUEST_CODE_EXPORT && resultCode == RESULT_OK) { - ThreadUtil.runOnThreadAndShowResult( - this, - R.string.export_in_progress, - 0 - ) { resources.getString(exportUserData(data!!.data!!)) } + 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) } } @@ -118,10 +121,15 @@ class UserDataActivity : AppCompatActivity() { } 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. - MaterialAlertDialogBuilder(this) - .setMessage(R.string.user_data_open_system_file_manager_failed) - .setPositiveButton(R.string.ok, null) - .show() + 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) } } } @@ -140,17 +148,18 @@ class UserDataActivity : AppCompatActivity() { startActivityForResult(intent, REQUEST_CODE_IMPORT) } - private fun importUserData(source: Uri): Int { + 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 + '/' - sMustRestartApp = true deleteChildrenRecursively(userDirectory) DirectoryInitialization.getGameListCache(this).delete() @@ -262,8 +271,12 @@ class UserDataActivity : AppCompatActivity() { private fun exportUserData(zos: ZipOutputStream, input: File, pathRelativeToRoot: File?) { if (input.isDirectory) { val children = input.listFiles() ?: throw IOException("Could not find directory $input") - for (child in children) { - exportUserData(zos, child, File(pathRelativeToRoot, child.name)) + + // 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 + '/')) 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() + val result: LiveData get() = _result + + private val _isComplete = MutableLiveData() + val isComplete: LiveData get() = _isComplete + + private val _isRunning = MutableLiveData() + val isRunning: LiveData 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) + } +}