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) + } + } +}