Android: Convert UserDataActivity to Kotlin

This commit is contained in:
Charles Lombardo 2023-01-26 22:53:41 -05:00
parent 089eab96d7
commit 8d16aed581
2 changed files with 314 additions and 379 deletions

View File

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

View File

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