@@ -0,0 +1,327 @@
// SPDX-License-Identifier: GPL-2.0-or-later

package org.dolphinemu.dolphinemu.activities

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.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.color.MaterialColors
import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.databinding.ActivityUserDataBinding
import org.dolphinemu.dolphinemu.dialogs.NotificationDialog
import org.dolphinemu.dolphinemu.dialogs.TaskDialog
import org.dolphinemu.dolphinemu.dialogs.UserDataImportWarningDialog
import org.dolphinemu.dolphinemu.model.TaskViewModel
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

class UserDataActivity : AppCompatActivity() {
private lateinit var taskViewModel: TaskViewModel

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)

taskViewModel = ViewModelProvider(this)[TaskViewModel::class.java]
if (requestCode == REQUEST_CODE_IMPORT && resultCode == RESULT_OK) {
val arguments = Bundle()
arguments.putString(
UserDataImportWarningDialog.KEY_URI_RESULT,
data!!.data!!.toString()
)

val dialog = UserDataImportWarningDialog()
dialog.arguments = arguments
dialog.show(supportFragmentManager, UserDataImportWarningDialog.TAG)
} else if (requestCode == REQUEST_CODE_EXPORT && resultCode == RESULT_OK) {
taskViewModel.clear()
taskViewModel.task = {
val resultResource = exportUserData(data!!.data!!)
taskViewModel.setResult(resultResource)
}

val arguments = Bundle()
arguments.putInt(TaskDialog.KEY_TITLE, R.string.export_in_progress)
arguments.putInt(TaskDialog.KEY_MESSAGE, 0)
arguments.putBoolean(TaskDialog.KEY_CANCELLABLE, true)

val dialog = TaskDialog()
dialog.arguments = arguments
dialog.show(supportFragmentManager, TaskDialog.TAG)
}
}

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.
val arguments = Bundle()
arguments.putInt(
NotificationDialog.KEY_MESSAGE,
R.string.user_data_open_system_file_manager_failed
)

val dialog = NotificationDialog()
dialog.arguments = arguments
dialog.show(supportFragmentManager, NotificationDialog.TAG)
}
}
}

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

fun importUserData(source: Uri): Int {
try {
if (!isDolphinUserDataBackup(source))
return R.string.user_data_import_invalid_file

taskViewModel.mustRestartApp = true

contentResolver.openInputStream(source).use { `is` ->
ZipInputStream(`is`).use { zis ->
val userDirectory = File(DirectoryInitialization.getUserDirectory())
val userDirectoryCanonicalized = userDirectory.canonicalPath + '/'

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")

// Check if the coroutine was cancelled
if (!taskViewModel.cancelled) {
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)
}
}
}
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-or-later

package org.dolphinemu.dolphinemu.dialogs

import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder

class NotificationDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = MaterialAlertDialogBuilder(requireContext())
.setMessage(requireArguments().getInt(KEY_MESSAGE))
.setPositiveButton(android.R.string.ok, null)
return dialog.create()
}

companion object {
const val TAG = "NotificationDialog"
const val KEY_MESSAGE = "message"
}
}
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-2.0-or-later

package org.dolphinemu.dolphinemu.dialogs

import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.dolphinemu.dolphinemu.model.TaskViewModel

class TaskCompleteDialog : DialogFragment() {
private lateinit var viewModel: TaskViewModel

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
viewModel = ViewModelProvider(requireActivity())[TaskViewModel::class.java]

val dialog = MaterialAlertDialogBuilder(requireContext())
.setMessage(requireArguments().getInt(KEY_MESSAGE))
.setPositiveButton(android.R.string.ok, null)
return dialog.create()
}

override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (viewModel.onResultDismiss != null)
viewModel.onResultDismiss!!.invoke()

viewModel.clear()
}

companion object {
const val TAG = "TaskCompleteDialog"
const val KEY_MESSAGE = "message"
}
}
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: GPL-2.0-or-later

package org.dolphinemu.dolphinemu.dialogs

import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.model.TaskViewModel

class TaskDialog : DialogFragment() {
private lateinit var viewModel: TaskViewModel

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
viewModel = ViewModelProvider(requireActivity())[TaskViewModel::class.java]

val dialogBuilder = MaterialAlertDialogBuilder(requireContext())
.setTitle(requireArguments().getInt(KEY_TITLE))
.setView(R.layout.dialog_indeterminate_progress)
if (requireArguments().getBoolean(KEY_CANCELLABLE)) {
dialogBuilder.setCancelable(true)
.setNegativeButton(android.R.string.cancel) { dialog: DialogInterface, _: Int ->
viewModel.cancelled = true
dialog.dismiss()
}
}

val dialog = dialogBuilder.create()
dialog.setCanceledOnTouchOutside(false)

val progressMessage = requireArguments().getInt(KEY_MESSAGE)
if (progressMessage != 0) dialog.setMessage(resources.getString(progressMessage))

viewModel.isComplete.observe(this) { complete: Boolean ->
if (complete && viewModel.result.value != null) {
dialog.dismiss()
val notificationArguments = Bundle()
notificationArguments.putInt(
TaskCompleteDialog.KEY_MESSAGE,
viewModel.result.value!!
)

val taskCompleteDialog = TaskCompleteDialog()
taskCompleteDialog.arguments = notificationArguments
taskCompleteDialog.show(
requireActivity().supportFragmentManager,
TaskCompleteDialog.TAG
)
}
}

viewModel.runTask()
return dialog
}

override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
viewModel.cancelled = true
}

companion object {
const val TAG = "TaskDialog"
const val KEY_TITLE = "title"
const val KEY_MESSAGE = "message"
const val KEY_CANCELLABLE = "cancellable"
}
}
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: GPL-2.0-or-later

package org.dolphinemu.dolphinemu.dialogs

import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import androidx.core.net.toUri
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.activities.UserDataActivity
import org.dolphinemu.dolphinemu.model.TaskViewModel
import kotlin.system.exitProcess

class UserDataImportWarningDialog : DialogFragment() {
private lateinit var taskViewModel: TaskViewModel

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
taskViewModel = ViewModelProvider(requireActivity())[TaskViewModel::class.java]

val dialog = MaterialAlertDialogBuilder(requireContext())
.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()

val taskArguments = Bundle()
taskArguments.putInt(TaskDialog.KEY_TITLE, R.string.import_in_progress)
taskArguments.putInt(TaskDialog.KEY_MESSAGE, R.string.do_not_close_app)
taskArguments.putBoolean(TaskDialog.KEY_CANCELLABLE, false)

taskViewModel.task = {
taskViewModel.setResult(
(requireActivity() as UserDataActivity).importUserData(
requireArguments().getString(KEY_URI_RESULT)!!.toUri()
)
)
}

taskViewModel.onResultDismiss = {
if (taskViewModel.mustRestartApp) {
exitProcess(0)
}
}

val taskDialog = TaskDialog()
taskDialog.arguments = taskArguments
taskDialog.show(requireActivity().supportFragmentManager, TaskDialog.TAG)
}
return dialog.create()
}

companion object {
const val TAG = "UserDataImportWarningDialog"
const val KEY_URI_RESULT = "uri"
}
}
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-2.0-or-later

package org.dolphinemu.dolphinemu.model

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*

class TaskViewModel : ViewModel() {
var cancelled = false
var mustRestartApp = false

private val _result = MutableLiveData<Int>()
val result: LiveData<Int> get() = _result

private val _isComplete = MutableLiveData<Boolean>()
val isComplete: LiveData<Boolean> get() = _isComplete

private val _isRunning = MutableLiveData<Boolean>()
val isRunning: LiveData<Boolean> get() = _isRunning

lateinit var task: () -> Unit
var onResultDismiss: (() -> Unit)? = null

init {
clear()
}

fun clear() {
_result.value = 0
_isComplete.value = false
cancelled = false
mustRestartApp = false
onResultDismiss = null
_isRunning.value = false
}

fun runTask() {
if (isRunning.value == true) return
_isRunning.value = true

viewModelScope.launch {
withContext(Dispatchers.IO) {
task.invoke()
_isRunning.postValue(false)
_isComplete.postValue(true)
}
}
}

fun setResult(result: Int) {
_result.postValue(result)
}
}

This file was deleted.

@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-2.0-or-later

package org.dolphinemu.dolphinemu.utils

import android.app.Activity
import android.content.DialogInterface
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.dolphinemu.dolphinemu.R
import java.util.function.Supplier

object ThreadUtil {
@JvmStatic
@JvmOverloads
fun runOnThreadAndShowResult(
activity: Activity,
progressTitle: Int,
progressMessage: Int,
f: Supplier<String?>,
onResultDismiss: DialogInterface.OnDismissListener? = null
) {
val resources = activity.resources
val progressDialog = MaterialAlertDialogBuilder(activity)
.setTitle(progressTitle)
.setView(R.layout.dialog_indeterminate_progress)
.setCancelable(false)
.create()
if (progressMessage != 0) progressDialog.setMessage(resources.getString(progressMessage))
progressDialog.show()
Thread({
val result = f.get()
activity.runOnUiThread {
progressDialog.dismiss()
if (result != null) {
MaterialAlertDialogBuilder(activity)
.setMessage(result)
.setPositiveButton(R.string.ok) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
.setOnDismissListener(onResultDismiss)
.show()
}
}
}, resources.getString(progressTitle)).start()
}
}