diff --git a/app/src/main/java/com/futo/platformplayer/activities/IWithResultLauncher.kt b/app/src/main/java/com/futo/platformplayer/activities/IWithResultLauncher.kt new file mode 100644 index 00000000..89f48a87 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/IWithResultLauncher.kt @@ -0,0 +1,9 @@ +package com.futo.platformplayer.activities + +import android.content.Intent +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher + +interface IWithResultLauncher { + fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 95ae8a9d..ba7f9473 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -10,6 +10,9 @@ import android.os.Bundle import android.util.TypedValue import android.view.View import android.widget.FrameLayout +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.motion.widget.MotionLayout import androidx.core.view.WindowCompat @@ -24,6 +27,7 @@ import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.main.* import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment @@ -48,7 +52,7 @@ import java.io.StringWriter import java.lang.reflect.InvocationTargetException import java.util.* -class MainActivity : AppCompatActivity { +class MainActivity : AppCompatActivity, IWithResultLauncher { //TODO: Move to dimensions private val HEIGHT_MENU_DP = 48f; @@ -364,6 +368,7 @@ class MainActivity : AppCompatActivity { //startActivity(Intent(this, TestActivity::class.java)); } + /* override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); @@ -892,6 +897,28 @@ class MainActivity : AppCompatActivity { _fragContainerMain.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom, resources.displayMetrics).toInt()); } + + + + private var resultLauncherMap = mutableMapOfUnit>(); + private var requestCode: Int? = -1; + private val resultLauncher: ActivityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> + val handler = synchronized(resultLauncherMap) { + resultLauncherMap.remove(requestCode); + } + if(handler != null) + handler(result); + }; + override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) { + synchronized(resultLauncherMap) { + resultLauncherMap[code] = handler; + } + requestCode = code; + resultLauncher.launch(intent); + } + companion object { private val TAG = "MainActivity" diff --git a/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt index e3c4bed6..c24c6cef 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt @@ -6,13 +6,16 @@ import android.os.Bundle import android.view.View import android.widget.ImageButton import android.widget.LinearLayout +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import com.futo.platformplayer.* import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.ReadOnlyTextField import com.google.android.material.button.MaterialButton -class SettingsActivity : AppCompatActivity() { +class SettingsActivity : AppCompatActivity(), IWithResultLauncher { private lateinit var _form: FieldForm; private lateinit var _buttonBack: ImageButton; @@ -78,6 +81,28 @@ class SettingsActivity : AppCompatActivity() { overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up) } + + + + private var resultLauncherMap = mutableMapOfUnit>(); + private var requestCode: Int? = -1; + private val resultLauncher: ActivityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> + val handler = synchronized(resultLauncherMap) { + resultLauncherMap.remove(requestCode); + } + if(handler != null) + handler(result); + }; + override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) { + synchronized(resultLauncherMap) { + resultLauncherMap[code] = handler; + } + requestCode = code; + resultLauncher.launch(intent); + } + companion object { //TODO: Temporary for solving Settings issues @SuppressLint("StaticFieldLeak") diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 444eafa4..446cc684 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -2,7 +2,9 @@ package com.futo.platformplayer.states import android.Manifest import android.annotation.SuppressLint +import android.app.Activity import android.content.Context +import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.media.AudioManager @@ -10,12 +12,20 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest +import android.net.Uri import android.os.Environment +import android.provider.DocumentsContract import android.util.DisplayMetrics +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.work.* import com.futo.platformplayer.* +import com.futo.platformplayer.R +import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.casting.StateCasting @@ -43,6 +53,9 @@ class StateApp { val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active private val externalRootDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "Grayjay"); + + + fun getExternalRootDirectory(): File? { if(!externalRootDirectory.exists()) { val result = externalRootDirectory.mkdirs(); @@ -158,6 +171,32 @@ class StateApp { return state; } + fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, path: Uri?, handle: (Uri?)->Unit) + { + if(activity is Context) + { + UIDialogs.showDialog(activity, R.drawable.ic_security, "Missing Access", "Please grant access to ${name}", null, 0, + UIDialogs.Action("Cancel", {}), + UIDialogs.Action("Ok", { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + if(path != null) + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path); + intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION + .and(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .and(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + .and(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); + + activity.launchForResult(intent, 99) { + if(it.resultCode == Activity.RESULT_OK) { + handle(it.data?.data); + } + else + UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted"); + }; + }, UIDialogs.ActionStyle.PRIMARY)); + } + } + //Lifecycle fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null) { _context = context; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt index c8d35f21..87bacdcd 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateBackup.kt @@ -1,11 +1,20 @@ package com.futo.platformplayer.states +import android.app.Activity import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract.EXTRA_INITIAL_URI +import androidx.activity.ComponentActivity import androidx.core.app.ShareCompat import androidx.core.content.FileProvider +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.IWithResultLauncher +import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.encryption.EncryptionProvider import com.futo.platformplayer.getNowDiffHours @@ -22,6 +31,9 @@ import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.InputStream import java.time.OffsetDateTime import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @@ -34,6 +46,14 @@ class StateBackup { private val _autoBackupLock = Object(); + private fun getAutomaticBackupDocumentFiles(context: Context, root: Uri, create: Boolean = false): Pair { + val dir = DocumentFile.fromTreeUri(context, root); + if(dir == null) + throw IllegalStateException("Can't access external document files"); + val mainBackupFile = dir.findFile("GrayjayBackup.ezip") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip") else null; + val secondaryBackupFile = dir.findFile("GrayjayBackup.ezip.old") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip.old") else null; + return Pair(mainBackupFile, secondaryBackupFile); + } private fun getAutomaticBackupFiles(): Pair { val dir = StateApp.instance.getExternalRootDirectory(); if(dir == null) @@ -97,7 +117,13 @@ class StateBackup { } } } - fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) { + + //TODO: This contains a temporary workaround to make it semi-compatible with > Android 11. By mixing "File" and "DocumentFile" usage. + //TODO: For now this is used to at least recover and gain temporary access to docs after losing access (due to permission lost after reinstall) + //TODO: Should be replaced with a more re-usable system that leverages OPEN_DOCUMENT_TREE once, and somehow persist this content after uninstall + //TODO: DocumentFiles are not compatible with normal files and require its own system. + //TODO: Investigate persistence of DOCUMENT_TREE files after uninstall... + fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false, withStream: InputStream? = null) { if(ifExists && !hasAutomaticBackup()) { Logger.i(TAG, "No AutoBackup exists, not restoring"); return; @@ -110,14 +136,33 @@ class StateBackup { val backupFiles = getAutomaticBackupFiles(); try { - if (!backupFiles.first.exists()) + if (!backupFiles.first.exists() && withStream == null) throw IllegalStateException("Backup file does not exist"); - val backupBytesEncrypted = backupFiles.first.readBytes(); + val backupBytesEncrypted = if(withStream != null) withStream.readBytes() else backupFiles.first.readBytes(); val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password)); importZipBytes(context, scope, backupBytes); Logger.i(TAG, "Finished AutoBackup restore"); - } catch (ex: Throwable) { + } + catch (exSec: FileNotFoundException) { + Logger.e(TAG, "Failed to access backup file", exSec); + val activity = if(SettingsActivity.getActivity() != null) + SettingsActivity.getActivity(); + else if(StateApp.instance.isMainActive) + StateApp.instance.contextOrNull; + else null; + if(activity != null) { + if(activity is IWithResultLauncher) + StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", backupFiles.first.parent?.toUri()) { + if(it != null) { + val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity, it); + if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead()) + restoreAutomaticBackup(context, scope, password, ifExists, activity.contentResolver.openInputStream(customFiles.first!!.uri)); + } + }; + } + } + catch (ex: Throwable) { Logger.e(TAG, "Failed main AutoBackup restore", ex) if (!backupFiles.second.exists()) throw ex;