summaryrefslogtreecommitdiff
path: root/Source/Android/app/src/main/java
diff options
context:
space:
mode:
authorJosJuice <josjuice@gmail.com>2022-01-30 19:17:12 +0100
committerJosJuice <josjuice@gmail.com>2022-01-30 22:47:55 +0100
commitbf5cd900888d774fded524b882cab9fa3c2a5344 (patch)
tree31a79c00654b2fe513e36b944e333077678e2c95 /Source/Android/app/src/main/java
parentecd4ee57a7450ef70ced87305ddd58f3863e286d (diff)
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.
Diffstat (limited to 'Source/Android/app/src/main/java')
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/UserDataActivity.java258
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.java7
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.java49
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/DirectoryInitialization.java5
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThreadUtil.java57
5 files changed, 327 insertions, 49 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
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<Boolean> 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<String> 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<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 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();
+ }
+}