From bf5cd900888d774fded524b882cab9fa3c2a5344 Mon Sep 17 00:00:00 2001 From: JosJuice Date: Sun, 30 Jan 2022 19:17:12 +0100 Subject: [PATCH] Android: Add import/export options for user data Apparently there are phones where accessing Dolphin's app-specific directory isn't just annoyingly hard but actually impossible. To give users of those phones at least some kind of way to manage their data (even if it's a lot less convenient than if we were allowed to let the user open the app-specific directory in a file manager), I'm adding a way to export the directory to a zip file and then import it back. --- .../activities/UserDataActivity.java | 258 +++++++++++++++++- .../settings/ui/SettingsActivity.java | 7 +- .../dolphinemu/ui/main/MainPresenter.java | 49 +--- .../utils/DirectoryInitialization.java | 5 + .../dolphinemu/utils/ThreadUtil.java | 57 ++++ .../res/layout-land/activity_user_data.xml | 99 +++++++ .../main/res/layout/activity_user_data.xml | 33 ++- .../app/src/main/res/values/strings.xml | 11 +- 8 files changed, 466 insertions(+), 53 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.java create mode 100644 Source/Android/app/src/main/res/layout-land/activity_user_data.xml 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 index 4bacd9edfc..bd3561abad 100644 --- 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 @@ -2,23 +2,44 @@ 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 android.widget.Button; import android.widget.TextView; +import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import org.dolphinemu.dolphinemu.R; import org.dolphinemu.dolphinemu.utils.DirectoryInitialization; +import org.dolphinemu.dolphinemu.utils.ThreadUtil; -public class UserDataActivity extends AppCompatActivity implements View.OnClickListener +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; + public static void launch(Context context) { Intent launcher = new Intent(context, UserDataActivity.class); @@ -36,6 +57,8 @@ public class UserDataActivity extends AppCompatActivity implements View.OnClickL TextView textPath = findViewById(R.id.text_path); TextView textAndroid11 = findViewById(R.id.text_android_11); Button buttonOpenSystemFileManager = findViewById(R.id.button_open_system_file_manager); + Button buttonImportUserData = findViewById(R.id.button_import_user_data); + Button buttonExportUserData = findViewById(R.id.button_export_user_data); boolean android_10 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; boolean android_11 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R; @@ -50,15 +73,63 @@ public class UserDataActivity extends AppCompatActivity implements View.OnClickL textAndroid11.setVisibility(android_11 && !legacy ? View.VISIBLE : View.GONE); buttonOpenSystemFileManager.setVisibility(android_11 ? View.VISIBLE : View.GONE); + buttonOpenSystemFileManager.setOnClickListener(view -> openFileManager()); - buttonOpenSystemFileManager.setOnClickListener(this); + buttonImportUserData.setOnClickListener(view -> importUserData()); + + buttonExportUserData.setOnClickListener(view -> exportUserData()); // show up button getSupportActionBar().setDisplayHomeAsUpEnabled(true); } @Override - public void onClick(View v) + 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(); + + AlertDialog.Builder builder = new AlertDialog.Builder(this, R.style.DolphinDialogBase); + + builder.setMessage(R.string.user_data_import_warning); + builder.setNegativeButton(R.string.no, (dialog, i) -> dialog.dismiss()); + builder.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); + } + }); + }); + + builder.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 { @@ -84,13 +155,6 @@ public class UserDataActivity extends AppCompatActivity implements View.OnClickL } } - @Override - public boolean onSupportNavigateUp() - { - onBackPressed(); - return true; - } - private Intent getFileManagerIntent(String packageName) { // Fragile, but some phones don't expose the system file manager in any better way @@ -99,4 +163,178 @@ public class UserDataActivity extends AppCompatActivity implements View.OnClickL 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()); + + 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 (!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())); + } + } + 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); + } + } + } + } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.java index aa4bb6f10d..43a920c019 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.java @@ -61,7 +61,12 @@ public final class SettingsActivity extends AppCompatActivity implements Setting { super.onCreate(savedInstanceState); - MainPresenter.skipRescanningLibrary(); + // If we came here from the game list, we don't want to rescan when returning to the game list. + // But if we came here after UserDataActivity restarted the app, we do want to rescan. + if (savedInstanceState == null) + { + MainPresenter.skipRescanningLibrary(); + } setContentView(R.layout.activity_settings); diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.java index b95b35331f..1923927af8 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.java @@ -27,6 +27,7 @@ import org.dolphinemu.dolphinemu.utils.BooleanSupplier; import org.dolphinemu.dolphinemu.utils.CompletableFuture; import org.dolphinemu.dolphinemu.utils.ContentHandler; import org.dolphinemu.dolphinemu.utils.FileBrowserHelper; +import org.dolphinemu.dolphinemu.utils.ThreadUtil; import org.dolphinemu.dolphinemu.utils.WiiUtils; import java.util.Arrays; @@ -182,7 +183,7 @@ public final class MainPresenter public void installWAD(String path) { - runOnThreadAndShowResult(R.string.import_in_progress, 0, () -> + ThreadUtil.runOnThreadAndShowResult(mActivity, R.string.import_in_progress, 0, () -> { boolean success = WiiUtils.installWAD(path); int message = success ? R.string.wad_install_success : R.string.wad_install_failure; @@ -194,7 +195,7 @@ public final class MainPresenter { CompletableFuture canOverwriteFuture = new CompletableFuture<>(); - runOnThreadAndShowResult(R.string.import_in_progress, 0, () -> + ThreadUtil.runOnThreadAndShowResult(mActivity, R.string.import_in_progress, 0, () -> { BooleanSupplier canOverwrite = () -> { @@ -255,47 +256,19 @@ public final class MainPresenter { dialog.dismiss(); - runOnThreadAndShowResult(R.string.import_in_progress, R.string.do_not_close_app, () -> - { - // ImportNANDBin doesn't provide any result value, unfortunately... - // It does however show a panic alert if something goes wrong. - WiiUtils.importNANDBin(path); - return null; - }); + ThreadUtil.runOnThreadAndShowResult(mActivity, R.string.import_in_progress, + R.string.do_not_close_app, () -> + { + // ImportNANDBin unfortunately doesn't provide any result value... + // It does however show a panic alert if something goes wrong. + WiiUtils.importNANDBin(path); + return null; + }); }); builder.show(); } - private void runOnThreadAndShowResult(int progressTitle, int progressMessage, Supplier f) - { - AlertDialog progressDialog = new AlertDialog.Builder(mActivity, R.style.DolphinDialogBase) - .create(); - progressDialog.setTitle(progressTitle); - if (progressMessage != 0) - progressDialog.setMessage(mActivity.getResources().getString(progressMessage)); - progressDialog.setCancelable(false); - progressDialog.show(); - - new Thread(() -> - { - String result = f.get(); - mActivity.runOnUiThread(() -> - { - progressDialog.dismiss(); - - if (result != null) - { - AlertDialog.Builder builder = - new AlertDialog.Builder(mActivity, R.style.DolphinDialogBase); - builder.setMessage(result); - builder.setPositiveButton(R.string.ok, (dialog, i) -> dialog.dismiss()); - builder.show(); - } - }); - }, mActivity.getResources().getString(progressTitle)).start(); - } - public static void skipRescanningLibrary() { sShouldRescanLibrary = false; diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/DirectoryInitialization.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/DirectoryInitialization.java index a5264800ee..02352fdb4f 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/DirectoryInitialization.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/DirectoryInitialization.java @@ -236,6 +236,11 @@ public final class DirectoryInitialization return userPath; } + public static File getGameListCache(Context context) + { + return new File(context.getExternalCacheDir(), "gamelist.cache"); + } + private static boolean copyAsset(String asset, File output, Boolean overwrite, Context context) { Log.verbose("[DirectoryInitialization] Copying File " + asset + " to " + output); 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 new file mode 100644 index 0000000000..1c2a3faa53 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.java @@ -0,0 +1,57 @@ +// 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 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 AlertDialog.Builder(activity, R.style.DolphinDialogBase) + .create(); + progressDialog.setTitle(progressTitle); + if (progressMessage != 0) + progressDialog.setMessage(resources.getString(progressMessage)); + progressDialog.setCancelable(false); + progressDialog.show(); + + new Thread(() -> + { + String result = f.get(); + activity.runOnUiThread(() -> + { + progressDialog.dismiss(); + + if (result != null) + { + AlertDialog.Builder builder = + new AlertDialog.Builder(activity, R.style.DolphinDialogBase); + builder.setMessage(result); + builder.setPositiveButton(R.string.ok, (dialog, i) -> dialog.dismiss()); + builder.setOnDismissListener(onResultDismiss); + builder.show(); + } + }); + }, resources.getString(progressTitle)).start(); + } +} diff --git a/Source/Android/app/src/main/res/layout-land/activity_user_data.xml b/Source/Android/app/src/main/res/layout-land/activity_user_data.xml new file mode 100644 index 0000000000..53107b6f98 --- /dev/null +++ b/Source/Android/app/src/main/res/layout-land/activity_user_data.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + +