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.
This commit is contained in:
JosJuice 2022-01-30 19:17:12 +01:00
parent ecd4ee57a7
commit bf5cd90088
8 changed files with 466 additions and 53 deletions

View file

@ -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);
}
}
}
}
}

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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();
}
}

View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/text_type"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_medlarge"
tools:text="@string/user_data_new_location"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/barrier_buttons"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/text_path"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintWidth_max="400dp" />
<TextView
android:id="@+id/text_path"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_medlarge"
tools:text="/storage/emulated/0/Android/data/org.dolphinemu.dolphinemu/files"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/barrier_buttons"
app:layout_constraintTop_toBottomOf="@id/text_type"
app:layout_constraintBottom_toTopOf="@id/text_android_11"
app:layout_constraintWidth_max="400dp" />
<TextView
android:id="@+id/text_android_11"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_medlarge"
android:text="@string/user_data_new_location_android_11"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/barrier_buttons"
app:layout_constraintTop_toBottomOf="@id/text_path"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintWidth_max="400dp" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="end"
app:constraint_referenced_ids="text_type,text_path,text_android_11" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_buttons"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="start"
app:constraint_referenced_ids="button_open_system_file_manager,button_import_user_data,button_export_user_data" />
<Button
android:id="@+id/button_open_system_file_manager"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_small"
android:text="@string/user_data_open_system_file_manager"
android:textColor="@color/dolphin_white"
app:layout_constraintStart_toEndOf="@id/barrier_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/button_import_user_data"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintWidth_max="400dp" />
<Button
android:id="@+id/button_import_user_data"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_small"
android:text="@string/user_data_import"
android:textColor="@color/dolphin_white"
app:layout_constraintStart_toEndOf="@id/barrier_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_open_system_file_manager"
app:layout_constraintBottom_toTopOf="@id/button_export_user_data"
app:layout_constraintWidth_max="400dp" />
<Button
android:id="@+id/button_export_user_data"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_small"
android:text="@string/user_data_export"
android:textColor="@color/dolphin_white"
app:layout_constraintStart_toEndOf="@id/barrier_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_import_user_data"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintWidth_max="400dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -16,7 +16,7 @@
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/text_path"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintWidth_max="400dp"/>
app:layout_constraintWidth_max="400dp" />
<TextView
android:id="@+id/text_path"
@ -34,7 +34,9 @@
android:id="@+id/text_android_11"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_medlarge"
android:layout_marginHorizontal="@dimen/spacing_medlarge"
android:layout_marginTop="@dimen/spacing_medlarge"
android:layout_marginBottom="24dp"
android:text="@string/user_data_new_location_android_11"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
@ -46,11 +48,36 @@
android:id="@+id/button_open_system_file_manager"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_medlarge"
android:layout_margin="@dimen/spacing_small"
android:text="@string/user_data_open_system_file_manager"
android:textColor="@color/dolphin_white"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_android_11"
app:layout_constraintBottom_toTopOf="@id/button_import_user_data" />
<Button
android:id="@+id/button_import_user_data"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_small"
android:text="@string/user_data_import"
android:textColor="@color/dolphin_white"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_open_system_file_manager"
app:layout_constraintBottom_toTopOf="@id/button_export_user_data" />
<Button
android:id="@+id/button_export_user_data"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_small"
android:text="@string/user_data_export"
android:textColor="@color/dolphin_white"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_import_user_data"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -388,9 +388,17 @@
<string name="user_data_new_location">Your user data is stored in a location which <b>will be deleted</b> when you uninstall the app:</string>
<!-- Android 10 and up support android:hasFragileUserData -->
<string name="user_data_new_location_android_10">Your user data is stored in a location which by default <b>will be deleted</b> when you uninstall the app:</string>
<string name="user_data_new_location_android_11">Because you\'re using Android 11 or newer, not all file manager apps can access this location. However, you can access it using the system file manager (if present on your device), or by connecting your device to a PC.</string>
<string name="user_data_new_location_android_11">Because you\'re using Android 11 or newer, file manager apps can\'t access this folder in the same way as regular folders. You might be able to access the folder using the system file manager (if present on your device), or by connecting your device to a PC.</string>
<string name="user_data_open_system_file_manager">Open System File Manager</string>
<string name="user_data_import">Import User Data</string>
<string name="user_data_export">Export User Data</string>
<string name="user_data_open_system_file_manager_failed">Sorry, Dolphin couldn\'t find the system file manager on your device.</string>
<string name="user_data_import_warning">Are you sure you want to replace your user data with the data in this file? All existing user data will be deleted!</string>
<string name="user_data_import_invalid_file">This file doesn\'t seem to contain Dolphin user data.</string>
<string name="user_data_import_success">The user data has been imported.</string>
<string name="user_data_import_failure">Failed to import user data.</string>
<string name="user_data_export_success">Your user data has been exported.</string>
<string name="user_data_export_failure">Failed to export user data.</string>
<!-- Miscellaneous -->
<string name="yes">Yes</string>
@ -418,6 +426,7 @@
<string name="grid_menu_load_wii_system_menu">Load Wii System Menu</string>
<string name="grid_menu_load_wii_system_menu_installed">Load Wii System Menu (%s)</string>
<string name="import_in_progress">Importing...</string>
<string name="export_in_progress">Exporting...</string>
<string name="do_not_close_app">Do not close the app!</string>
<string name="wad_install_success">Successfully installed this title to the NAND.</string>
<string name="wad_install_failure">Failed to install this title to the NAND.</string>