From efb3b774afbc8895c5b5ff1a927040efd87f038d Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Mon, 21 Oct 2024 00:59:47 -0400 Subject: [PATCH] Edit remotes in a new activity instead of a dialog This has a much nicer user experience since we can now have proper switches for toggleable options and include descriptions for all the actions. Most of the annoying "success" confirmation snackbar messages have also been removed now that the UI can more intuitively show the effects of the successful operations. Signed-off-by: Andrew Gunnerson --- app/src/main/AndroidManifest.xml | 4 + .../chiller3/rsaf/PreferenceBaseActivity.kt | 90 ++++ .../chiller3/rsaf/PreferenceBaseFragment.kt | 145 +++++++ .../java/com/chiller3/rsaf/Preferences.kt | 12 +- .../rsaf/dialog/EditRemoteDialogFragment.kt | 111 ----- .../rsaf/dialog/RemoteNameDialogFragment.kt | 34 +- .../rsaf/dialog/TextInputDialogFragment.kt | 2 - .../rsaf/settings/EditRemoteActivity.kt | 38 ++ .../chiller3/rsaf/settings/EditRemoteAlert.kt | 30 ++ .../rsaf/settings/EditRemoteFragment.kt | 300 +++++++++++++ .../rsaf/settings/EditRemoteViewModel.kt | 199 +++++++++ .../rsaf/settings/SettingsActivity.kt | 55 +-- .../chiller3/rsaf/settings/SettingsAlert.kt | 32 ++ .../rsaf/settings/SettingsFragment.kt | 398 +++--------------- .../rsaf/settings/SettingsViewModel.kt | 239 +++-------- app/src/main/res/values/strings.xml | 53 +-- .../main/res/xml/preferences_edit_remote.xml | 64 +++ 17 files changed, 1079 insertions(+), 727 deletions(-) create mode 100644 app/src/main/java/com/chiller3/rsaf/PreferenceBaseActivity.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/PreferenceBaseFragment.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/dialog/EditRemoteDialogFragment.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/EditRemoteActivity.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/EditRemoteAlert.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/EditRemoteFragment.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/EditRemoteViewModel.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/SettingsAlert.kt create mode 100644 app/src/main/res/xml/preferences_edit_remote.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf5320a..256d761 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,6 +49,10 @@ + + + setResult(RESULT_OK, Intent().apply { putExtras(result) }) + } + + ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() + or WindowInsetsCompat.Type.displayCutout() + ) + + v.updateLayoutParams { + leftMargin = insets.left + topMargin = insets.top + rightMargin = insets.right + } + + WindowInsetsCompat.CONSUMED + } + + setSupportActionBar(binding.toolbar) + supportActionBar!!.setDisplayHomeAsUpEnabled(showUpButton) + + actionBarTitle?.let { + setTitle(it) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } +} diff --git a/app/src/main/java/com/chiller3/rsaf/PreferenceBaseFragment.kt b/app/src/main/java/com/chiller3/rsaf/PreferenceBaseFragment.kt new file mode 100644 index 0000000..3e40f78 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/PreferenceBaseFragment.kt @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.biometric.BiometricManager.Authenticators +import androidx.biometric.BiometricPrompt +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.preference.PreferenceFragmentCompat +import androidx.recyclerview.widget.RecyclerView + +abstract class PreferenceBaseFragment : PreferenceFragmentCompat() { + companion object { + private const val INACTIVE_TIMEOUT_NS = 60_000_000_000L + + // These are intentionally global to ensure that the prompt does not appear when navigating + // within the app. + private var bioAuthenticated = false + private var lastPause = 0L + } + + abstract val requestTag: String + + protected lateinit var prefs: Preferences + private lateinit var bioPrompt: BiometricPrompt + + override fun onCreate(savedInstanceState: Bundle?) { + val activity = requireActivity() + + prefs = Preferences(activity) + + bioPrompt = BiometricPrompt( + this, + activity.mainExecutor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + Toast.makeText( + activity, + getString(R.string.biometric_error, errString), + Toast.LENGTH_LONG, + ).show() + activity.finish() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + bioAuthenticated = true + refreshGlobalVisibility() + } + + override fun onAuthenticationFailed() { + Toast.makeText( + activity, + R.string.biometric_failure, + Toast.LENGTH_LONG, + ).show() + activity.finish() + } + }, + ) + + super.onCreate(savedInstanceState) + } + + override fun onCreateRecyclerView( + inflater: LayoutInflater, + parent: ViewGroup, + savedInstanceState: Bundle? + ): RecyclerView { + val view = super.onCreateRecyclerView(inflater, parent, savedInstanceState) + + view.clipToPadding = false + + ViewCompat.setOnApplyWindowInsetsListener(view) { v, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() + or WindowInsetsCompat.Type.displayCutout() + ) + + // This is a little bit ugly in landscape mode because the divider lines for categories + // extend into the inset area. However, it's worth applying the left/right padding here + // anyway because it allows the inset area to be used for scrolling instead of just + // being a useless dead zone. + v.updatePadding( + bottom = insets.bottom, + left = insets.left, + right = insets.right, + ) + + WindowInsetsCompat.CONSUMED + } + + return view + } + + override fun onResume() { + super.onResume() + + if (bioAuthenticated && (System.nanoTime() - lastPause) >= INACTIVE_TIMEOUT_NS) { + bioAuthenticated = false + } + + if (!bioAuthenticated) { + if (!prefs.requireAuth) { + bioAuthenticated = true + } else { + startBiometricAuth() + } + } + + refreshGlobalVisibility() + } + + override fun onPause() { + super.onPause() + + lastPause = System.nanoTime() + } + + private fun startBiometricAuth() { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL) + .setTitle(getString(R.string.biometric_title)) + .build() + + bioPrompt.authenticate(promptInfo) + } + + private fun refreshGlobalVisibility() { + view?.visibility = if (bioAuthenticated) { + View.VISIBLE + } else { + // Using View.GONE causes noticeable scrolling jank due to relayout. + View.INVISIBLE + } + } +} diff --git a/app/src/main/java/com/chiller3/rsaf/Preferences.kt b/app/src/main/java/com/chiller3/rsaf/Preferences.kt index 1e27eeb..71bd964 100644 --- a/app/src/main/java/com/chiller3/rsaf/Preferences.kt +++ b/app/src/main/java/com/chiller3/rsaf/Preferences.kt @@ -17,6 +17,7 @@ class Preferences(private val context: Context) { const val CATEGORY_DEBUG = "debug" const val CATEGORY_REMOTES = "remotes" + // Main preferences const val PREF_ADD_FILE_EXTENSION = "add_file_extension" const val PREF_ALLOW_BACKUP = "allow_backup" const val PREF_DIALOGS_AT_BOTTOM = "dialogs_at_bottom" @@ -26,7 +27,7 @@ class Preferences(private val context: Context) { const val PREF_REQUIRE_AUTH = "require_auth" const val PREF_VERBOSE_RCLONE_LOGS = "verbose_rclone_logs" - // UI actions only + // Main UI actions only const val PREF_INHIBIT_BATTERY_OPT = "inhibit_battery_opt" const val PREF_MISSING_NOTIFICATIONS = "missing_notifications" const val PREF_ADD_REMOTE = "add_remote" @@ -36,6 +37,15 @@ class Preferences(private val context: Context) { const val PREF_SAVE_LOGS = "save_logs" const val PREF_VERSION = "version" + // Edit remote UI actions + const val PREF_OPEN_REMOTE = "open_remote" + const val PREF_CONFIGURE_REMOTE = "configure_remote" + const val PREF_RENAME_REMOTE = "rename_remote" + const val PREF_DUPLICATE_REMOTE = "duplicate_remote" + const val PREF_DELETE_REMOTE = "delete_remote" + const val PREF_ALLOW_EXTERNAL_ACCESS = "allow_external_access" + const val PREF_DYNAMIC_SHORTCUT = "dynamic_shortcut" + // Not associated with a UI preference const val PREF_DEBUG_MODE = "debug_mode" private const val PREF_NEXT_NOTIFICATION_ID = "next_notification_id" diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/EditRemoteDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/EditRemoteDialogFragment.kt deleted file mode 100644 index efa14f5..0000000 --- a/app/src/main/java/com/chiller3/rsaf/dialog/EditRemoteDialogFragment.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.chiller3.rsaf.dialog - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.view.Gravity -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import com.chiller3.rsaf.Preferences -import com.chiller3.rsaf.R -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -open class EditRemoteDialogFragment : DialogFragment() { - companion object { - private const val ARG_REMOTE = "remote" - private const val ARG_IS_BLOCKED = "is_blocked" - private const val ARG_HAS_SHORTCUT = "has_shortcut" - const val RESULT_ACTION = "action" - const val RESULT_REMOTE = "remote" - - fun newInstance( - remote: String, - remoteBlocked: Boolean, - hasShortcut: Boolean - ): EditRemoteDialogFragment = - EditRemoteDialogFragment().apply { - arguments = bundleOf( - ARG_REMOTE to remote, - ARG_IS_BLOCKED to remoteBlocked, - ARG_HAS_SHORTCUT to hasShortcut, - ) - } - } - - enum class Action { - OPEN, - BLOCK, - UNBLOCK, - ADD_SHORTCUT, - REMOVE_SHORTCUT, - CONFIGURE, - RENAME, - DUPLICATE, - DELETE, - } - - private lateinit var remote: String - private val remoteBlocked by lazy { - requireArguments().getBoolean(ARG_IS_BLOCKED) - } - private val hasShortcut by lazy { - requireArguments().getBoolean(ARG_HAS_SHORTCUT) - } - private var action: Action? = null - - private val items by lazy { - mutableListOf>().apply { - if (remoteBlocked) { - add(Action.UNBLOCK to R.string.dialog_edit_remote_unblock_external_access) - } else { - add(Action.OPEN to R.string.dialog_edit_remote_open_in_documentsui) - add(Action.BLOCK to R.string.dialog_edit_remote_block_external_access) - if (hasShortcut) { - add(Action.REMOVE_SHORTCUT to R.string.dialog_edit_remote_remove_dynamic_shortcut) - } else { - add(Action.ADD_SHORTCUT to R.string.dialog_edit_remote_add_dynamic_shortcut) - } - } - add(Action.CONFIGURE to R.string.dialog_edit_remote_action_configure) - add(Action.RENAME to R.string.dialog_edit_remote_action_rename) - add(Action.DUPLICATE to R.string.dialog_edit_remote_action_duplicate) - add(Action.DELETE to R.string.dialog_edit_remote_action_delete) - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val arguments = requireArguments() - remote = arguments.getString(ARG_REMOTE)!! - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(remote) - .setItems(items.map { getString(it.second) }.toTypedArray()) { _, i -> - action = items[i].first - dismiss() - } - .setNegativeButton(R.string.dialog_action_cancel) { _, _ -> - dismiss() - } - .create() - .apply { - if (Preferences(requireContext()).dialogsAtBottom) { - window!!.attributes.gravity = Gravity.BOTTOM - } - } - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - - setFragmentResult(tag!!, bundleOf( - RESULT_ACTION to action, - RESULT_REMOTE to remote, - )) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/RemoteNameDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/RemoteNameDialogFragment.kt index c95cf87..c8784d8 100644 --- a/app/src/main/java/com/chiller3/rsaf/dialog/RemoteNameDialogFragment.kt +++ b/app/src/main/java/com/chiller3/rsaf/dialog/RemoteNameDialogFragment.kt @@ -1,25 +1,49 @@ /* - * SPDX-FileCopyrightText: 2023 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ package com.chiller3.rsaf.dialog import android.app.Dialog +import android.content.Context import android.os.Bundle +import com.chiller3.rsaf.R import com.chiller3.rsaf.rclone.RcloneConfig +sealed interface RemoteNameDialogAction { + fun getTitle(context: Context): String + + data object Add : RemoteNameDialogAction { + override fun getTitle(context: Context): String = + context.getString(R.string.dialog_add_remote_title) + } + + data class Rename(val remote: String) : RemoteNameDialogAction { + override fun getTitle(context: Context): String = + context.getString(R.string.dialog_rename_remote_title, remote) + } + + data class Duplicate(val remote: String) : RemoteNameDialogAction { + override fun getTitle(context: Context): String = + context.getString(R.string.dialog_duplicate_remote_title, remote) + } +} + class RemoteNameDialogFragment : TextInputDialogFragment() { companion object { private const val ARG_REMOTE_NAMES = "blacklist" - const val RESULT_ARGS = TextInputDialogFragment.RESULT_ARGS const val RESULT_SUCCESS = TextInputDialogFragment.RESULT_SUCCESS const val RESULT_INPUT = TextInputDialogFragment.RESULT_INPUT - fun newInstance(title: String, message: String, hint: String, remoteNames: Array): - RemoteNameDialogFragment = + fun newInstance(context: Context, action: RemoteNameDialogAction, remoteNames: Array) = RemoteNameDialogFragment().apply { - arguments = toArgs(title, message, hint, false).apply { + arguments = toArgs( + action.getTitle(context), + context.getString(R.string.dialog_remote_name_message), + context.getString(R.string.dialog_remote_name_hint), + false, + ).apply { putStringArray(ARG_REMOTE_NAMES, remoteNames) } } diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/TextInputDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/TextInputDialogFragment.kt index 45b6a1c..d011b99 100644 --- a/app/src/main/java/com/chiller3/rsaf/dialog/TextInputDialogFragment.kt +++ b/app/src/main/java/com/chiller3/rsaf/dialog/TextInputDialogFragment.kt @@ -26,7 +26,6 @@ open class TextInputDialogFragment : DialogFragment() { private const val ARG_MESSAGE = "message" private const val ARG_HINT = "hint" private const val ARG_IS_PASSWORD = "is_password" - const val RESULT_ARGS = "args" const val RESULT_SUCCESS = "success" const val RESULT_INPUT = "input" @@ -91,7 +90,6 @@ open class TextInputDialogFragment : DialogFragment() { super.onDismiss(dialog) setFragmentResult(tag!!, bundleOf( - RESULT_ARGS to arguments, RESULT_SUCCESS to success, RESULT_INPUT to input, )) diff --git a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteActivity.kt b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteActivity.kt new file mode 100644 index 0000000..11c5d8c --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteActivity.kt @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.content.Context +import android.content.Intent +import androidx.core.os.bundleOf +import com.chiller3.rsaf.PreferenceBaseActivity +import com.chiller3.rsaf.PreferenceBaseFragment + +class EditRemoteActivity : PreferenceBaseActivity() { + companion object { + private const val EXTRA_REMOTE = "remote" + + const val RESULT_NEW_REMOTE = "new_remote" + + fun createIntent(context: Context, remote: String) = + Intent(context, EditRemoteActivity::class.java).apply { + putExtra(EXTRA_REMOTE, remote) + } + } + + private val remote: String by lazy { + intent.getStringExtra(EXTRA_REMOTE)!! + } + + override val actionBarTitle: CharSequence + get() = remote + + override val showUpButton: Boolean = true + + override fun createFragment(): PreferenceBaseFragment = EditRemoteFragment().apply { + arguments = bundleOf(EditRemoteFragment.ARG_REMOTE to remote) + } +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteAlert.kt b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteAlert.kt new file mode 100644 index 0000000..931fe25 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteAlert.kt @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +sealed interface EditRemoteAlert { + data class ListRemotesFailed(val error: String) : EditRemoteAlert + + data class RemoteEditSucceeded(val remote: String) : EditRemoteAlert + + data class RemoteDeleteFailed(val remote: String, val error: String) : EditRemoteAlert + + data class RemoteRenameFailed( + val oldRemote: String, + val newRemote: String, + val error: String, + ) : EditRemoteAlert + + data class RemoteDuplicateFailed( + val oldRemote: String, + val newRemote: String, + val error: String, + ) : EditRemoteAlert + + data class UpdateExternalAccessFailed(val remote: String, val error: String) : EditRemoteAlert + + data class UpdateDynamicShortcutFailed(val remote: String, val error: String) : EditRemoteAlert +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteFragment.kt b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteFragment.kt new file mode 100644 index 0000000..288e7af --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteFragment.kt @@ -0,0 +1,300 @@ +/* + * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.os.Bundle +import android.util.Log +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentResultListener +import androidx.fragment.app.clearFragmentResult +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.preference.Preference +import androidx.preference.SwitchPreferenceCompat +import com.chiller3.rsaf.PreferenceBaseFragment +import com.chiller3.rsaf.Preferences +import com.chiller3.rsaf.R +import com.chiller3.rsaf.dialog.InteractiveConfigurationDialogFragment +import com.chiller3.rsaf.dialog.RemoteNameDialogAction +import com.chiller3.rsaf.dialog.RemoteNameDialogFragment +import com.chiller3.rsaf.rclone.RcloneProvider +import com.chiller3.rsaf.rclone.RcloneRpc +import com.chiller3.rsaf.settings.SettingsFragment.Companion.documentsUiIntent +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch + +class EditRemoteFragment : PreferenceBaseFragment(), FragmentResultListener, + Preference.OnPreferenceClickListener, Preference.OnPreferenceChangeListener { + companion object { + private val TAG = EditRemoteFragment::class.java.simpleName + + internal const val ARG_REMOTE = "remote" + + private val TAG_EDIT_REMOTE = "$TAG.edit_remote" + private val TAG_RENAME_REMOTE = "$TAG.rename_remote" + private val TAG_DUPLICATE_REMOTE = "$TAG.duplicate_remote" + } + + override val requestTag: String = TAG + + private val viewModel: EditRemoteViewModel by viewModels() + + private lateinit var prefOpenRemote: Preference + private lateinit var prefConfigureRemote: Preference + private lateinit var prefRenameRemote: Preference + private lateinit var prefDuplicateRemote: Preference + private lateinit var prefDeleteRemote: Preference + private lateinit var prefAllowExternalAccess: SwitchPreferenceCompat + private lateinit var prefDynamicShortcut: SwitchPreferenceCompat + + private lateinit var remote: String + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.preferences_edit_remote, rootKey) + + prefOpenRemote = findPreference(Preferences.PREF_OPEN_REMOTE)!! + prefOpenRemote.onPreferenceClickListener = this + + prefConfigureRemote = findPreference(Preferences.PREF_CONFIGURE_REMOTE)!! + prefConfigureRemote.onPreferenceClickListener = this + + prefRenameRemote = findPreference(Preferences.PREF_RENAME_REMOTE)!! + prefRenameRemote.onPreferenceClickListener = this + + prefDuplicateRemote = findPreference(Preferences.PREF_DUPLICATE_REMOTE)!! + prefDuplicateRemote.onPreferenceClickListener = this + + prefDeleteRemote = findPreference(Preferences.PREF_DELETE_REMOTE)!! + prefDeleteRemote.onPreferenceClickListener = this + + prefAllowExternalAccess = findPreference(Preferences.PREF_ALLOW_EXTERNAL_ACCESS)!! + prefAllowExternalAccess.onPreferenceChangeListener = this + + prefDynamicShortcut = findPreference(Preferences.PREF_DYNAMIC_SHORTCUT)!! + prefDynamicShortcut.onPreferenceChangeListener = this + + remote = requireArguments().getString(ARG_REMOTE)!! + viewModel.setRemote(remote) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel.remotes.collect { + Log.d(TAG, "Updating dynamic shortcuts") + updateShortcuts(it) + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel.remoteConfig.collect { + if (it != null) { + prefOpenRemote.isEnabled = it.allowExternalAccess + prefAllowExternalAccess.isEnabled = true + prefAllowExternalAccess.isChecked = it.allowExternalAccess + prefDynamicShortcut.isEnabled = it.allowExternalAccess + prefDynamicShortcut.isChecked = it.dynamicShortcut + } else { + prefOpenRemote.isEnabled = false + prefAllowExternalAccess.isEnabled = false + prefDynamicShortcut.isEnabled = false + } + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.alerts.collect { + it.firstOrNull()?.let { alert -> + onAlert(alert) + } + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.activityActions.collect { + if (it.refreshRoots) { + Log.d(TAG, "Notifying system of new SAF roots") + RcloneProvider.notifyRootsChanged(requireContext().contentResolver) + } + it.editNewRemote?.let { newRemote -> + Log.d(TAG, "Editing new remote: $newRemote") + setFragmentResult(requestTag, bundleOf( + EditRemoteActivity.RESULT_NEW_REMOTE to newRemote, + )) + } + if (it.finish) { + Log.d(TAG, "Finishing edit remote activity for: $remote") + requireActivity().finish() + } + viewModel.activityActionCompleted() + } + } + } + + for (key in arrayOf( + TAG_EDIT_REMOTE, + TAG_RENAME_REMOTE, + TAG_DUPLICATE_REMOTE, + InteractiveConfigurationDialogFragment.TAG, + )) { + parentFragmentManager.setFragmentResultListener(key, this, this) + } + } + + override fun onFragmentResult(requestKey: String, bundle: Bundle) { + clearFragmentResult(requestKey) + + when (requestKey) { + TAG_RENAME_REMOTE -> { + if (bundle.getBoolean(RemoteNameDialogFragment.RESULT_SUCCESS)) { + val newRemote = bundle.getString(RemoteNameDialogFragment.RESULT_INPUT)!! + + viewModel.renameRemote(remote, newRemote) + } + } + TAG_DUPLICATE_REMOTE -> { + if (bundle.getBoolean(RemoteNameDialogFragment.RESULT_SUCCESS)) { + val newRemote = bundle.getString(RemoteNameDialogFragment.RESULT_INPUT)!! + + viewModel.duplicateRemote(remote, newRemote) + } + } + InteractiveConfigurationDialogFragment.TAG -> { + viewModel.interactiveConfigurationCompleted( + bundle.getString(InteractiveConfigurationDialogFragment.RESULT_REMOTE)!!, + ) + } + } + } + + override fun onPreferenceClick(preference: Preference): Boolean { + when (preference) { + prefOpenRemote -> { + startActivity(documentsUiIntent(remote)) + return true + } + prefConfigureRemote -> { + InteractiveConfigurationDialogFragment.newInstance(remote, false) + .show(parentFragmentManager.beginTransaction(), + InteractiveConfigurationDialogFragment.TAG) + return true + } + prefRenameRemote -> { + RemoteNameDialogFragment.newInstance( + requireContext(), + RemoteNameDialogAction.Rename(remote), + viewModel.remotes.value.keys.toTypedArray(), + ).show(parentFragmentManager.beginTransaction(), TAG_RENAME_REMOTE) + return true + } + prefDuplicateRemote -> { + RemoteNameDialogFragment.newInstance( + requireContext(), + RemoteNameDialogAction.Duplicate(remote), + viewModel.remotes.value.keys.toTypedArray(), + ).show(parentFragmentManager.beginTransaction(), TAG_DUPLICATE_REMOTE) + return true + } + prefDeleteRemote -> { + viewModel.deleteRemote(remote) + return true + } + } + + return false + } + + override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean { + // These all return false because the state is updated when the change actually happens. + + when (preference) { + prefAllowExternalAccess -> { + viewModel.setExternalAccess(remote, newValue as Boolean) + } + prefDynamicShortcut -> { + viewModel.setDynamicShortcut(remote, newValue as Boolean) + } + } + + return false + } + + private fun onAlert(alert: EditRemoteAlert) { + val msg = when (alert) { + is EditRemoteAlert.ListRemotesFailed -> + getString(R.string.alert_list_remotes_failure, alert.error) + is EditRemoteAlert.RemoteEditSucceeded -> + getString(R.string.alert_edit_remote_success, alert.remote) + is EditRemoteAlert.RemoteDeleteFailed -> + getString(R.string.alert_delete_remote_failure, alert.remote, alert.error) + is EditRemoteAlert.RemoteRenameFailed -> + getString(R.string.alert_rename_remote_failure, alert.oldRemote, alert.newRemote, + alert.error) + is EditRemoteAlert.RemoteDuplicateFailed -> + getString(R.string.alert_duplicate_remote_failure, alert.oldRemote, alert.newRemote, + alert.error) + is EditRemoteAlert.UpdateExternalAccessFailed -> + getString(R.string.alert_update_external_access_failure, alert.remote, alert.error) + is EditRemoteAlert.UpdateDynamicShortcutFailed -> + getString(R.string.alert_update_dynamic_shortcut_failure, alert.remote, alert.error) + } + + Snackbar.make(requireView(), msg, Snackbar.LENGTH_LONG) + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + viewModel.acknowledgeFirstAlert() + } + }) + .show() + } + + private fun updateShortcuts(remotes: Map>) { + val context = requireContext() + + val icon = IconCompat.createWithResource(context, R.mipmap.ic_launcher) + val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) + val shortcuts = mutableListOf() + var rank = 0 + + for ((remote, config) in remotes) { + if (config[RcloneRpc.CUSTOM_OPT_BLOCKED] == "true" + || config[RcloneRpc.CUSTOM_OPT_DYNAMIC_SHORTCUT] != "true") { + continue + } + + if (rank < maxShortcuts) { + val shortcut = ShortcutInfoCompat.Builder(context, remote) + .setShortLabel(remote) + .setIcon(icon) + .setIntent(documentsUiIntent(remote)) + .setRank(rank) + .build() + + shortcuts.add(shortcut) + } + + rank += 1 + } + + if (rank > maxShortcuts) { + Log.w(TAG, "Truncating dynamic shortcuts from $rank to $maxShortcuts") + } + + if (!ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)) { + Log.w(TAG, "Failed to update dynamic shortcuts") + } + } +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteViewModel.kt b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteViewModel.kt new file mode 100644 index 0000000..ca7e405 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteViewModel.kt @@ -0,0 +1,199 @@ +/* + * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.chiller3.rsaf.rclone.RcloneConfig +import com.chiller3.rsaf.rclone.RcloneRpc +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class EditRemoteActivityActions( + val refreshRoots: Boolean, + val editNewRemote: String?, + val finish: Boolean, +) + +data class RemoteConfigState( + val allowExternalAccess: Boolean, + val dynamicShortcut: Boolean, +) + +class EditRemoteViewModel : ViewModel() { + companion object { + private val TAG = EditRemoteViewModel::class.java.simpleName + } + + private lateinit var remote: String + + private val _remotes = MutableStateFlow>>(emptyMap()) + val remotes = _remotes.asStateFlow() + + private val _remoteConfig = MutableStateFlow(null) + val remoteConfig = _remoteConfig.asStateFlow() + + private val _alerts = MutableStateFlow>(emptyList()) + val alerts = _alerts.asStateFlow() + + private val _activityActions = MutableStateFlow(EditRemoteActivityActions(false, null, false)) + val activityActions = _activityActions.asStateFlow() + + fun setRemote(remote: String) { + this.remote = remote + refreshRemotes(false) + } + + private suspend fun refreshRemotesInternal(force: Boolean) { + try { + if (_remotes.value.isEmpty() || force) { + val r = withContext(Dispatchers.IO) { + RcloneRpc.remotes + } + + _remotes.update { r } + } + + val config = remotes.value[remote] + + if (config != null) { + _remoteConfig.update { + RemoteConfigState( + allowExternalAccess = config[RcloneRpc.CUSTOM_OPT_BLOCKED] != "true", + dynamicShortcut = config[RcloneRpc.CUSTOM_OPT_DYNAMIC_SHORTCUT] == "true", + ) + } + } else { + // This will happen after renaming or deleting the remote. + _remoteConfig.update { null } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to refresh remotes", e) + _alerts.update { it + EditRemoteAlert.ListRemotesFailed(e.toString()) } + } + } + + private fun refreshRemotes(@Suppress("SameParameterValue") force: Boolean) { + viewModelScope.launch { + refreshRemotesInternal(force) + } + } + + fun setExternalAccess(remote: String, allow: Boolean) { + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + RcloneRpc.setRemoteOptions( + remote, mapOf( + RcloneRpc.CUSTOM_OPT_BLOCKED to (!allow).toString(), + ) + ) + } + refreshRemotesInternal(true) + _activityActions.update { it.copy(refreshRoots = true) } + } catch (e: Exception) { + Log.w(TAG, "Failed to set $remote external access to $allow", e) + _alerts.update { it + EditRemoteAlert.UpdateExternalAccessFailed(remote, e.toString()) } + } + } + } + + fun setDynamicShortcut(remote: String, enabled: Boolean) { + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + RcloneRpc.setRemoteOptions( + remote, mapOf( + RcloneRpc.CUSTOM_OPT_DYNAMIC_SHORTCUT to enabled.toString(), + ) + ) + } + refreshRemotesInternal(true) + } catch (e: Exception) { + Log.w(TAG, "Failed to set remote $remote shortcut state to $enabled", e) + _alerts.update { it + EditRemoteAlert.UpdateDynamicShortcutFailed(remote, e.toString()) } + } + } + } + + private fun copyRemote(oldRemote: String, newRemote: String, delete: Boolean) { + if (oldRemote == newRemote) { + throw IllegalStateException("Old and new remote names are the same") + } + + val failure = if (delete) { + EditRemoteAlert::RemoteRenameFailed + } else { + EditRemoteAlert::RemoteDuplicateFailed + } + + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + RcloneConfig.copyRemote(oldRemote, newRemote) + if (delete) { + RcloneRpc.deleteRemote(oldRemote) + } + } + refreshRemotesInternal(true) + _activityActions.update { + it.copy( + refreshRoots = true, + editNewRemote = newRemote, + finish = true, + ) + } + } catch (e: Exception) { + val action = if (delete) { "rename" } else { "duplicate" } + Log.e(TAG, "Failed to $action remote $oldRemote to $newRemote", e) + _alerts.update { it + failure(oldRemote, newRemote, e.toString()) } + } + } + } + + fun renameRemote(oldRemote: String, newRemote: String) { + copyRemote(oldRemote, newRemote, true) + } + + fun duplicateRemote(oldRemote: String, newRemote: String) { + copyRemote(oldRemote, newRemote, false) + } + + fun deleteRemote(remote: String) { + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + RcloneRpc.deleteRemote(remote) + } + refreshRemotesInternal(true) + _activityActions.update { it.copy(refreshRoots = true, finish = true) } + } catch (e: Exception) { + Log.e(TAG, "Failed to delete remote $remote", e) + _alerts.update { it + EditRemoteAlert.RemoteDeleteFailed(remote, e.toString()) } + } + } + } + + fun acknowledgeFirstAlert() { + _alerts.update { it.drop(1) } + } + + fun interactiveConfigurationCompleted(remote: String) { + viewModelScope.launch { + refreshRemotesInternal(true) + _alerts.update { it + EditRemoteAlert.RemoteEditSucceeded(remote) } + } + } + + fun activityActionCompleted() { + _activityActions.update { EditRemoteActivityActions(false, null, false) } + } +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/SettingsActivity.kt b/app/src/main/java/com/chiller3/rsaf/settings/SettingsActivity.kt index d50433c..69f19c0 100644 --- a/app/src/main/java/com/chiller3/rsaf/settings/SettingsActivity.kt +++ b/app/src/main/java/com/chiller3/rsaf/settings/SettingsActivity.kt @@ -5,54 +5,13 @@ package com.chiller3.rsaf.settings -import android.os.Bundle -import android.view.ViewGroup -import androidx.activity.enableEdgeToEdge -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updateLayoutParams -import com.chiller3.rsaf.R -import com.chiller3.rsaf.databinding.SettingsActivityBinding +import com.chiller3.rsaf.PreferenceBaseActivity +import com.chiller3.rsaf.PreferenceBaseFragment -class SettingsActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() - super.onCreate(savedInstanceState) +class SettingsActivity : PreferenceBaseActivity() { + override val actionBarTitle: CharSequence? = null - val binding = SettingsActivityBinding.inflate(layoutInflater) - setContentView(binding.root) + override val showUpButton: Boolean = false - val transaction = supportFragmentManager.beginTransaction() - - // https://issuetracker.google.com/issues/181805603 - val bioFragment = supportFragmentManager - .findFragmentByTag("androidx.biometric.BiometricFragment") - if (bioFragment != null) { - transaction.remove(bioFragment) - } - - if (savedInstanceState == null) { - transaction.replace(R.id.settings, SettingsFragment()) - } - - transaction.commit() - - ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, windowInsets -> - val insets = windowInsets.getInsets( - WindowInsetsCompat.Type.systemBars() - or WindowInsetsCompat.Type.displayCutout() - ) - - v.updateLayoutParams { - leftMargin = insets.left - topMargin = insets.top - rightMargin = insets.right - } - - WindowInsetsCompat.CONSUMED - } - - setSupportActionBar(binding.toolbar) - } -} \ No newline at end of file + override fun createFragment(): PreferenceBaseFragment = SettingsFragment() +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/SettingsAlert.kt b/app/src/main/java/com/chiller3/rsaf/settings/SettingsAlert.kt new file mode 100644 index 0000000..289e4be --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/SettingsAlert.kt @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.net.Uri + +sealed interface SettingsAlert { + data class ListRemotesFailed(val error: String) : SettingsAlert + + data class RemoteAddSucceeded(val remote: String) : SettingsAlert + + data class RemoteAddPartiallySucceeded(val remote: String) : SettingsAlert + + data object ImportSucceeded : SettingsAlert + + data object ExportSucceeded : SettingsAlert + + data class ImportFailed(val error: String) : SettingsAlert + + data class ExportFailed(val error: String) : SettingsAlert + + data object ImportCancelled : SettingsAlert + + data object ExportCancelled : SettingsAlert + + data class LogcatSucceeded(val uri: Uri) : SettingsAlert + + data class LogcatFailed(val uri: Uri, val error: String) : SettingsAlert +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/SettingsFragment.kt b/app/src/main/java/com/chiller3/rsaf/settings/SettingsFragment.kt index 13fd2e5..fc68571 100644 --- a/app/src/main/java/com/chiller3/rsaf/settings/SettingsFragment.kt +++ b/app/src/main/java/com/chiller3/rsaf/settings/SettingsFragment.kt @@ -13,19 +13,7 @@ import android.os.Environment import android.provider.DocumentsContract import android.provider.Settings import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.biometric.BiometricManager.Authenticators -import androidx.biometric.BiometricPrompt -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding import androidx.fragment.app.FragmentResultListener import androidx.fragment.app.clearFragmentResult import androidx.fragment.app.viewModels @@ -34,49 +22,37 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.preference.Preference import androidx.preference.PreferenceCategory -import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import androidx.preference.get import androidx.preference.size -import androidx.recyclerview.widget.RecyclerView import com.chiller3.rsaf.BuildConfig -import com.chiller3.rsaf.dialog.EditRemoteDialogFragment import com.chiller3.rsaf.Logcat import com.chiller3.rsaf.Permissions +import com.chiller3.rsaf.PreferenceBaseFragment import com.chiller3.rsaf.Preferences import com.chiller3.rsaf.R -import com.chiller3.rsaf.rclone.RcloneConfig -import com.chiller3.rsaf.rclone.RcloneProvider -import com.chiller3.rsaf.rclone.RcloneRpc -import com.chiller3.rsaf.dialog.RemoteNameDialogFragment -import com.chiller3.rsaf.dialog.TextInputDialogFragment import com.chiller3.rsaf.binding.rcbridge.Rcbridge import com.chiller3.rsaf.dialog.InteractiveConfigurationDialogFragment +import com.chiller3.rsaf.dialog.RemoteNameDialogAction +import com.chiller3.rsaf.dialog.RemoteNameDialogFragment +import com.chiller3.rsaf.dialog.TextInputDialogFragment import com.chiller3.rsaf.extension.formattedString +import com.chiller3.rsaf.rclone.RcloneConfig +import com.chiller3.rsaf.rclone.RcloneProvider import com.chiller3.rsaf.view.LongClickablePreference import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.launch -class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, +class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener, Preference.OnPreferenceClickListener, LongClickablePreference.OnPreferenceLongClickListener, Preference.OnPreferenceChangeListener { companion object { private val TAG = SettingsFragment::class.java.simpleName private val TAG_ADD_REMOTE_NAME = "$TAG.add_remote_name" - private val TAG_EDIT_REMOTE = "$TAG.edit_remote" - private val TAG_RENAME_REMOTE = "$TAG.rename_remote" - private val TAG_DUPLICATE_REMOTE = "$TAG.duplicate_remote" private val TAG_IMPORT_EXPORT_PASSWORD = "$TAG.import_export_password" - private const val ARG_OLD_REMOTE_NAME = "old_remote_name" - - private const val STATE_AUTHENTICATED = "authenticated" - private const val STATE_LAST_PAUSE = "last_pause" - - private const val INACTIVE_TIMEOUT_NS = 60_000_000_000L - - private fun documentsUiIntent(remote: String): Intent = + fun documentsUiIntent(remote: String): Intent = Intent(Intent.ACTION_VIEW).apply { val uri = DocumentsContract.buildRootUri( BuildConfig.DOCUMENTS_AUTHORITY, remote) @@ -84,9 +60,10 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, } } + override val requestTag: String = TAG + private val viewModel: SettingsViewModel by viewModels() - private lateinit var prefs: Preferences private lateinit var categoryPermissions: PreferenceCategory private lateinit var categoryRemotes: PreferenceCategory private lateinit var categoryConfiguration: PreferenceCategory @@ -99,10 +76,14 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, private lateinit var prefExportConfiguration: Preference private lateinit var prefVersion: LongClickablePreference private lateinit var prefSaveLogs: Preference - private lateinit var bioPrompt: BiometricPrompt - private var bioAuthenticated = false - private var lastPause = 0L + private val requestEditRemote = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + it.data?.extras?.getString(EditRemoteActivity.RESULT_NEW_REMOTE)?.let { newRemote -> + editRemote(newRemote) + } + viewModel.refreshRemotes() + } private val requestInhibitBatteryOpt = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { refreshPermissions() @@ -134,48 +115,10 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, } } - override fun onCreateRecyclerView( - inflater: LayoutInflater, - parent: ViewGroup, - savedInstanceState: Bundle? - ): RecyclerView { - val view = super.onCreateRecyclerView(inflater, parent, savedInstanceState) - - view.clipToPadding = false - - ViewCompat.setOnApplyWindowInsetsListener(view) { v, windowInsets -> - val insets = windowInsets.getInsets( - WindowInsetsCompat.Type.systemBars() - or WindowInsetsCompat.Type.displayCutout() - ) - - // This is a little bit ugly in landscape mode because the divider lines for categories - // extend into the inset area. However, it's worth applying the left/right padding here - // anyway because it allows the inset area to be used for scrolling instead of just - // being a useless dead zone. - v.updatePadding( - bottom = insets.bottom, - left = insets.left, - right = insets.right, - ) - - WindowInsetsCompat.CONSUMED - } - - return view - } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.preferences_root, rootKey) - if (savedInstanceState != null) { - bioAuthenticated = savedInstanceState.getBoolean(STATE_AUTHENTICATED) - lastPause = savedInstanceState.getLong(STATE_LAST_PAUSE) - } - - val activity = requireActivity() - - prefs = Preferences(activity) + val context = requireContext() categoryPermissions = findPreference(Preferences.CATEGORY_PERMISSIONS)!! categoryRemotes = findPreference(Preferences.CATEGORY_REMOTES)!! @@ -210,35 +153,6 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, refreshVersion() refreshDebugPrefs() - bioPrompt = BiometricPrompt( - this, - activity.mainExecutor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - Toast.makeText( - activity, - getString(R.string.biometric_error, errString), - Toast.LENGTH_LONG, - ).show() - activity.finish() - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - bioAuthenticated = true - refreshGlobalVisibility() - } - - override fun onAuthenticationFailed() { - Toast.makeText( - activity, - R.string.biometric_failure, - Toast.LENGTH_LONG, - ).show() - activity.finish() - } - }, - ) - lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.alerts.collect { @@ -269,7 +183,7 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, continue } - val p = Preference(activity).apply { + val p = Preference(context).apply { key = Preferences.PREF_EDIT_REMOTE_PREFIX + remote.name isPersistent = false title = remote.name @@ -279,8 +193,6 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, } categoryRemotes.addPreference(p) } - - updateShortcuts(remotes) } } } @@ -318,11 +230,20 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, } } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.activityActions.collect { + if (it.refreshRoots) { + Log.d(TAG, "Notifying system of new SAF roots") + RcloneProvider.notifyRootsChanged(requireContext().contentResolver) + } + viewModel.activityActionCompleted() + } + } + } + for (key in arrayOf( TAG_ADD_REMOTE_NAME, - TAG_EDIT_REMOTE, - TAG_RENAME_REMOTE, - TAG_DUPLICATE_REMOTE, TAG_IMPORT_EXPORT_PASSWORD, InteractiveConfigurationDialogFragment.TAG, )) { @@ -330,13 +251,6 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, } } - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - - outState.putBoolean(STATE_AUTHENTICATED, bioAuthenticated) - outState.putLong(STATE_LAST_PAUSE, lastPause) - } - override fun onResume() { super.onResume() @@ -346,46 +260,9 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, prefLocalStorageAccess.isVisible = false } - if (bioAuthenticated && (System.nanoTime() - lastPause) >= INACTIVE_TIMEOUT_NS) { - bioAuthenticated = false - } - - if (!bioAuthenticated) { - if (!prefs.requireAuth) { - bioAuthenticated = true - } else { - startBiometricAuth() - } - } - - refreshGlobalVisibility() refreshPermissions() } - override fun onPause() { - super.onPause() - - lastPause = System.nanoTime() - } - - private fun startBiometricAuth() { - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL) - .setTitle(getString(R.string.biometric_title)) - .build() - - bioPrompt.authenticate(promptInfo) - } - - private fun refreshGlobalVisibility() { - view?.visibility = if (bioAuthenticated) { - View.VISIBLE - } else { - // Using View.GONE causes noticeable scrolling jank due to relayout. - View.INVISIBLE - } - } - private fun refreshPermissions() { val context = requireContext() @@ -435,80 +312,6 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, InteractiveConfigurationDialogFragment.TAG) } } - TAG_EDIT_REMOTE -> { - val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - bundle.getSerializable(EditRemoteDialogFragment.RESULT_ACTION, - EditRemoteDialogFragment.Action::class.java) - } else { - @Suppress("DEPRECATION") - bundle.getSerializable(EditRemoteDialogFragment.RESULT_ACTION) - as EditRemoteDialogFragment.Action? - } - val remote = bundle.getString(EditRemoteDialogFragment.RESULT_REMOTE)!! - - when (action) { - EditRemoteDialogFragment.Action.OPEN -> { - startActivity(documentsUiIntent(remote)) - } - EditRemoteDialogFragment.Action.BLOCK -> { - viewModel.blockRemote(remote, true) - } - EditRemoteDialogFragment.Action.UNBLOCK -> { - viewModel.blockRemote(remote, false) - } - EditRemoteDialogFragment.Action.ADD_SHORTCUT -> { - viewModel.setShortcut(remote, true) - } - EditRemoteDialogFragment.Action.REMOVE_SHORTCUT -> { - viewModel.setShortcut(remote, true) - } - EditRemoteDialogFragment.Action.CONFIGURE -> { - InteractiveConfigurationDialogFragment.newInstance(remote, false) - .show(parentFragmentManager.beginTransaction(), - InteractiveConfigurationDialogFragment.TAG) - } - EditRemoteDialogFragment.Action.RENAME -> { - showRemoteNameDialog( - TAG_RENAME_REMOTE, - getString(R.string.dialog_rename_remote_title, remote), - ) { - it.putString(ARG_OLD_REMOTE_NAME, remote) - } - } - EditRemoteDialogFragment.Action.DUPLICATE -> { - showRemoteNameDialog( - TAG_DUPLICATE_REMOTE, - getString(R.string.dialog_duplicate_remote_title, remote), - ) { - it.putString(ARG_OLD_REMOTE_NAME, remote) - } - } - EditRemoteDialogFragment.Action.DELETE -> { - viewModel.deleteRemote(remote) - } - null -> { - // Cancelled - } - } - } - TAG_RENAME_REMOTE -> { - if (bundle.getBoolean(RemoteNameDialogFragment.RESULT_SUCCESS)) { - val newRemote = bundle.getString(RemoteNameDialogFragment.RESULT_INPUT)!! - val oldRemote = bundle.getBundle(RemoteNameDialogFragment.RESULT_ARGS)!! - .getString(ARG_OLD_REMOTE_NAME)!! - - viewModel.renameRemote(oldRemote, newRemote) - } - } - TAG_DUPLICATE_REMOTE -> { - if (bundle.getBoolean(RemoteNameDialogFragment.RESULT_SUCCESS)) { - val newRemote = bundle.getString(RemoteNameDialogFragment.RESULT_INPUT)!! - val oldRemote = bundle.getBundle(RemoteNameDialogFragment.RESULT_ARGS)!! - .getString(ARG_OLD_REMOTE_NAME)!! - - viewModel.duplicateRemote(oldRemote, newRemote) - } - } TAG_IMPORT_EXPORT_PASSWORD -> { if (bundle.getBoolean(TextInputDialogFragment.RESULT_SUCCESS)) { val password = bundle.getString(TextInputDialogFragment.RESULT_INPUT)!! @@ -520,7 +323,6 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, InteractiveConfigurationDialogFragment.TAG -> { viewModel.interactiveConfigurationCompleted( bundle.getString(InteractiveConfigurationDialogFragment.RESULT_REMOTE)!!, - bundle.getBoolean(InteractiveConfigurationDialogFragment.RESULT_NEW), bundle.getBoolean(InteractiveConfigurationDialogFragment.RESULT_CANCELLED), ) } @@ -539,18 +341,16 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, return true } preference === prefAddRemote -> { - showRemoteNameDialog(TAG_ADD_REMOTE_NAME, - getString(R.string.dialog_add_remote_title)) + RemoteNameDialogFragment.newInstance( + requireContext(), + RemoteNameDialogAction.Add, + viewModel.remotes.value.map { it.name }.toTypedArray(), + ).show(parentFragmentManager.beginTransaction(), TAG_ADD_REMOTE_NAME) return true } preference.key.startsWith(Preferences.PREF_EDIT_REMOTE_PREFIX) -> { val remote = preference.key.substring(Preferences.PREF_EDIT_REMOTE_PREFIX.length) - val config = viewModel.remotes.value.find { it.name == remote }?.config - val isBlocked = config?.get(RcloneRpc.CUSTOM_OPT_BLOCKED) == "true" - val hasShortcut = config?.get(RcloneRpc.CUSTOM_OPT_DYNAMIC_SHORTCUT) == "true" - - EditRemoteDialogFragment.newInstance(remote, isBlocked, hasShortcut) - .show(parentFragmentManager.beginTransaction(), TAG_EDIT_REMOTE) + editRemote(remote) return true } preference === prefImportConfiguration -> { @@ -611,59 +411,24 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, return false } - private fun notifyRootsChanged() { - RcloneProvider.notifyRootsChanged(requireContext().contentResolver) - } - - private fun onAlert(alert: Alert) { + private fun onAlert(alert: SettingsAlert) { val msg = when (alert) { - is ListRemotesFailed -> getString(R.string.alert_list_remotes_failure, alert.error) - is RemoteAddSucceeded -> getString(R.string.alert_add_remote_success, alert.remote) - is RemoteAddPartiallySucceeded -> getString(R.string.alert_add_remote_partial, - alert.remote) - is RemoteEditSucceeded -> getString(R.string.alert_edit_remote_success, alert.remote) - is RemoteDeleteSucceeded -> getString(R.string.alert_delete_remote_success, - alert.remote) - is RemoteDeleteFailed -> getString(R.string.alert_delete_remote_failure, - alert.remote, alert.error) - is RemoteRenameSucceeded -> getString(R.string.alert_rename_remote_success, - alert.oldRemote, alert.newRemote) - is RemoteRenameFailed -> getString(R.string.alert_rename_remote_failure, - alert.oldRemote, alert.newRemote, alert.error) - is RemoteDuplicateSucceeded -> getString(R.string.alert_duplicate_remote_success, - alert.oldRemote, alert.newRemote) - is RemoteDuplicateFailed -> getString(R.string.alert_duplicate_remote_failure, - alert.oldRemote, alert.newRemote, alert.error) - is RemoteBlockUnblockSucceeded -> if (alert.blocked) { - getString(R.string.alert_block_remote_success, alert.remote) - } else { - getString(R.string.alert_unblock_remote_success, alert.remote) - } - is RemoteBlockUnblockFailed -> if (alert.block) { - getString(R.string.alert_block_remote_failure, alert.remote, alert.error) - } else { - getString(R.string.alert_unblock_remote_failure, alert.remote, alert.error) - } - is RemoteShortcutChangeSucceeded -> if (alert.enabled) { - getString(R.string.alert_add_shortcut_success, alert.remote) - } else { - getString(R.string.alert_remove_shortcut_success, alert.remote) - } - is RemoteShortcutChangeFailed -> if (alert.enable) { - getString(R.string.alert_add_shortcut_failure, alert.remote, alert.error) - } else { - getString(R.string.alert_remove_shortcut_failure, alert.remote, alert.error) - } - ImportSucceeded -> getString(R.string.alert_import_success) - ExportSucceeded -> getString(R.string.alert_export_success) - is ImportFailed -> getString(R.string.alert_import_failure, alert.error) - is ExportFailed -> getString(R.string.alert_export_failure, alert.error) - ImportCancelled -> getString(R.string.alert_import_cancelled) - ExportCancelled -> getString(R.string.alert_export_cancelled) - is LogcatSucceeded -> getString(R.string.alert_logcat_success, - alert.uri.formattedString) - is LogcatFailed -> getString(R.string.alert_logcat_failure, - alert.uri.formattedString, alert.error) + is SettingsAlert.ListRemotesFailed -> + getString(R.string.alert_list_remotes_failure, alert.error) + is SettingsAlert.RemoteAddSucceeded -> + getString(R.string.alert_add_remote_success, alert.remote) + is SettingsAlert.RemoteAddPartiallySucceeded -> + getString(R.string.alert_add_remote_partial, alert.remote) + SettingsAlert.ImportSucceeded -> getString(R.string.alert_import_success) + SettingsAlert.ExportSucceeded -> getString(R.string.alert_export_success) + is SettingsAlert.ImportFailed -> getString(R.string.alert_import_failure, alert.error) + is SettingsAlert.ExportFailed -> getString(R.string.alert_export_failure, alert.error) + SettingsAlert.ImportCancelled -> getString(R.string.alert_import_cancelled) + SettingsAlert.ExportCancelled -> getString(R.string.alert_export_cancelled) + is SettingsAlert.LogcatSucceeded -> + getString(R.string.alert_logcat_success, alert.uri.formattedString) + is SettingsAlert.LogcatFailed -> + getString(R.string.alert_logcat_failure, alert.uri.formattedString, alert.error) } Snackbar.make(requireView(), msg, Snackbar.LENGTH_LONG) @@ -673,60 +438,9 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, } }) .show() - - if (alert.requireNotifyRootsChanged) { - notifyRootsChanged() - } - } - - private fun showRemoteNameDialog(tag: String, title: String, - argModifier: ((Bundle) -> Unit)? = null) { - RemoteNameDialogFragment.newInstance( - title, - getString(R.string.dialog_remote_name_message), - getString(R.string.dialog_remote_name_hint), - viewModel.remotes.value.map { it.name }.toTypedArray(), - ).apply { - if (argModifier != null) { - argModifier(requireArguments()) - } - }.show(parentFragmentManager.beginTransaction(), tag) } - private fun updateShortcuts(remotes: List) { - val context = requireContext() - - val icon = IconCompat.createWithResource(context, R.mipmap.ic_launcher) - val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) - val shortcuts = mutableListOf() - var rank = 0 - - for (remote in remotes) { - if (remote.config[RcloneRpc.CUSTOM_OPT_BLOCKED] == "true" - || remote.config[RcloneRpc.CUSTOM_OPT_DYNAMIC_SHORTCUT] != "true") { - continue - } - - if (rank < maxShortcuts) { - val shortcut = ShortcutInfoCompat.Builder(context, remote.name) - .setShortLabel(remote.name) - .setIcon(icon) - .setIntent(documentsUiIntent(remote.name)) - .setRank(rank) - .build() - - shortcuts.add(shortcut) - } - - rank += 1 - } - - if (rank > maxShortcuts) { - Log.w(TAG, "Truncating dynamic shortcuts from $rank to $maxShortcuts") - } - - if (!ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)) { - Log.w(TAG, "Failed to update dynamic shortcuts") - } + private fun editRemote(remote: String) { + requestEditRemote.launch(EditRemoteActivity.createIntent(requireContext(), remote)) } } diff --git a/app/src/main/java/com/chiller3/rsaf/settings/SettingsViewModel.kt b/app/src/main/java/com/chiller3/rsaf/settings/SettingsViewModel.kt index 45549ec..5492124 100644 --- a/app/src/main/java/com/chiller3/rsaf/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/chiller3/rsaf/settings/SettingsViewModel.kt @@ -14,88 +14,11 @@ import com.chiller3.rsaf.rclone.RcloneConfig import com.chiller3.rsaf.rclone.RcloneRpc import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -sealed interface Alert { - val requireNotifyRootsChanged: Boolean -} - -data class ListRemotesFailed(val error: String) : Alert { - override val requireNotifyRootsChanged: Boolean = false -} -data class RemoteAddSucceeded(val remote: String) : Alert { - override val requireNotifyRootsChanged: Boolean = true -} -data class RemoteAddPartiallySucceeded(val remote: String) : Alert { - override val requireNotifyRootsChanged: Boolean = true -} -data class RemoteEditSucceeded(val remote: String) : Alert { - override val requireNotifyRootsChanged: Boolean = false -} -data class RemoteDeleteSucceeded(val remote: String) : Alert { - override val requireNotifyRootsChanged: Boolean = true -} -data class RemoteDeleteFailed(val remote: String, val error: String) : Alert { - override val requireNotifyRootsChanged: Boolean = true -} -data class RemoteRenameSucceeded(val oldRemote: String, val newRemote: String) : Alert { - override val requireNotifyRootsChanged: Boolean = true -} -data class RemoteRenameFailed(val oldRemote: String, val newRemote: String, val error: String) : - Alert { - // In case the failure occurred after creating the new remote and before deleting the old one - override val requireNotifyRootsChanged: Boolean = true -} -data class RemoteDuplicateSucceeded(val oldRemote: String, val newRemote: String) : Alert { - override val requireNotifyRootsChanged: Boolean = true -} -data class RemoteDuplicateFailed(val oldRemote: String, val newRemote: String, val error: String) : - Alert { - override val requireNotifyRootsChanged: Boolean = true -} -data class RemoteBlockUnblockSucceeded(val remote: String, val blocked: Boolean) : Alert { - override val requireNotifyRootsChanged: Boolean = true -} -data class RemoteBlockUnblockFailed(val remote: String, val block: Boolean, val error: String) : - Alert { - override val requireNotifyRootsChanged: Boolean = false -} -data class RemoteShortcutChangeSucceeded(val remote: String, val enabled: Boolean) : Alert { - override val requireNotifyRootsChanged: Boolean = false -} -data class RemoteShortcutChangeFailed(val remote: String, val enable: Boolean, val error: String) : - Alert { - override val requireNotifyRootsChanged: Boolean = false -} -data object ImportSucceeded : Alert { - override val requireNotifyRootsChanged: Boolean = true -} -data object ExportSucceeded : Alert { - override val requireNotifyRootsChanged: Boolean = false -} -data class ImportFailed(val error: String) : Alert { - // In case reloading the original config didn't work - override val requireNotifyRootsChanged: Boolean = true -} -data class ExportFailed(val error: String) : Alert { - override val requireNotifyRootsChanged: Boolean = false -} -data object ImportCancelled : Alert { - override val requireNotifyRootsChanged: Boolean = false -} -data object ExportCancelled : Alert { - override val requireNotifyRootsChanged: Boolean = false -} -data class LogcatSucceeded(val uri: Uri) : Alert { - override val requireNotifyRootsChanged: Boolean = false -} -data class LogcatFailed(val uri: Uri, val error: String) : Alert { - override val requireNotifyRootsChanged: Boolean = false -} - data class Remote( val name: String, val config: Map, @@ -119,19 +42,26 @@ data class ImportExportState( } } +data class SettingsActivityActions( + val refreshRoots: Boolean, +) + class SettingsViewModel : ViewModel() { companion object { private val TAG = SettingsViewModel::class.java.simpleName } private val _remotes = MutableStateFlow>(emptyList()) - val remotes: StateFlow> = _remotes + val remotes = _remotes.asStateFlow() - private val _alerts = MutableStateFlow>(emptyList()) - val alerts: StateFlow> = _alerts + private val _alerts = MutableStateFlow>(emptyList()) + val alerts = _alerts.asStateFlow() private val _importExportState = MutableStateFlow(null) - val importExportState: StateFlow = _importExportState + val importExportState = _importExportState.asStateFlow() + + private val _activityActions = MutableStateFlow(SettingsActivityActions(false)) + val activityActions = _activityActions.asStateFlow() init { refreshRemotes() @@ -150,105 +80,16 @@ class SettingsViewModel : ViewModel() { _remotes.update { r } } catch (e: Exception) { Log.e(TAG, "Failed to refresh remotes", e) - _alerts.update { it + ListRemotesFailed(e.toString()) } + _alerts.update { it + SettingsAlert.ListRemotesFailed(e.toString()) } } } - private fun refreshRemotes() { + fun refreshRemotes() { viewModelScope.launch { refreshRemotesInternal() } } - fun blockRemote(remote: String, block: Boolean) { - viewModelScope.launch { - try { - withContext(Dispatchers.IO) { - RcloneRpc.setRemoteOptions( - remote, mapOf( - RcloneRpc.CUSTOM_OPT_BLOCKED to block.toString(), - ) - ) - } - refreshRemotesInternal() - _alerts.update { it + RemoteBlockUnblockSucceeded(remote, block) } - } catch (e: Exception) { - Log.w(TAG, "Failed to set remote $remote block state to $block", e) - _alerts.update { it + RemoteBlockUnblockFailed(remote, block, e.toString()) } - } - } - } - - fun setShortcut(remote: String, enabled: Boolean) { - viewModelScope.launch { - try { - withContext(Dispatchers.IO) { - RcloneRpc.setRemoteOptions( - remote, mapOf( - RcloneRpc.CUSTOM_OPT_DYNAMIC_SHORTCUT to enabled.toString(), - ) - ) - } - refreshRemotesInternal() - _alerts.update { it + RemoteShortcutChangeSucceeded(remote, enabled) } - } catch (e: Exception) { - Log.w(TAG, "Failed to set remote $remote shortcut state to $enabled", e) - _alerts.update { it + RemoteShortcutChangeFailed(remote, enabled, e.toString()) } - } - } - } - - fun deleteRemote(remote: String) { - viewModelScope.launch { - try { - withContext(Dispatchers.IO) { - RcloneRpc.deleteRemote(remote) - } - refreshRemotesInternal() - _alerts.update { it + RemoteDeleteSucceeded(remote) } - } catch (e: Exception) { - Log.e(TAG, "Failed to delete remote $remote", e) - _alerts.update { it + RemoteDeleteFailed(remote, e.toString()) } - } - } - } - - private fun copyRemote(oldRemote: String, newRemote: String, delete: Boolean) { - if (oldRemote == newRemote) { - throw IllegalStateException("Old and new remote names are the same") - } - - val (success, failure) = if (delete) { - Pair(::RemoteRenameSucceeded, ::RemoteRenameFailed) - } else { - Pair(::RemoteDuplicateSucceeded, ::RemoteDuplicateFailed) - } - - viewModelScope.launch { - try { - withContext(Dispatchers.IO) { - RcloneConfig.copyRemote(oldRemote, newRemote) - if (delete) { - RcloneRpc.deleteRemote(oldRemote) - } - } - refreshRemotesInternal() - _alerts.update { it + success(oldRemote, newRemote) } - } catch (e: Exception) { - Log.e(TAG, "Failed to rename remote $oldRemote to $newRemote", e) - _alerts.update { it + failure(oldRemote, newRemote, e.toString()) } - } - } - } - - fun renameRemote(oldRemote: String, newRemote: String) { - copyRemote(oldRemote, newRemote, true) - } - - fun duplicateRemote(oldRemote: String, newRemote: String) { - copyRemote(oldRemote, newRemote, false) - } - fun startImportExport(mode: ImportExportMode, uri: Uri) { if (importExportState.value != null) { throw IllegalStateException("Import/export already started") @@ -289,8 +130,8 @@ class SettingsViewModel : ViewModel() { } val alert = when (state.mode) { - ImportExportMode.IMPORT -> ImportCancelled - ImportExportMode.EXPORT -> ExportCancelled + ImportExportMode.IMPORT -> SettingsAlert.ImportCancelled + ImportExportMode.EXPORT -> SettingsAlert.ExportCancelled } _alerts.update { it + alert } @@ -305,10 +146,16 @@ class SettingsViewModel : ViewModel() { } val (operation, success, failure) = when (state.mode) { - ImportExportMode.IMPORT -> - Triple(RcloneConfig::importConfigurationUri, ImportSucceeded, ::ImportFailed) - ImportExportMode.EXPORT -> - Triple(RcloneConfig::exportConfigurationUri, ExportSucceeded, ::ExportFailed) + ImportExportMode.IMPORT -> Triple( + RcloneConfig::importConfigurationUri, + SettingsAlert.ImportSucceeded, + SettingsAlert::ImportFailed, + ) + ImportExportMode.EXPORT -> Triple( + RcloneConfig::exportConfigurationUri, + SettingsAlert.ExportSucceeded, + SettingsAlert::ExportFailed, + ) } viewModelScope.launch { @@ -319,6 +166,7 @@ class SettingsViewModel : ViewModel() { _alerts.update { it + success } _importExportState.update { null } + _activityActions.update { it.copy(refreshRoots = true) } if (state.mode == ImportExportMode.IMPORT) { refreshRemotes() @@ -332,6 +180,11 @@ class SettingsViewModel : ViewModel() { Log.e(TAG, "Failed to perform import/export", e) _alerts.update { it + failure(e.toString()) } _importExportState.update { null } + + if (state.mode == ImportExportMode.IMPORT) { + // In case reloading the original config didn't work. + _activityActions.update { it.copy(refreshRoots = true) } + } } } } @@ -340,23 +193,21 @@ class SettingsViewModel : ViewModel() { _alerts.update { it.drop(1) } } - fun interactiveConfigurationCompleted(remote: String, new: Boolean, cancelled: Boolean) { + fun interactiveConfigurationCompleted(remote: String, cancelled: Boolean) { viewModelScope.launch { refreshRemotesInternal() - if (new) { - if (cancelled) { - if (remotes.value.any { it.name == remote }) { - _alerts.update { it + RemoteAddPartiallySucceeded(remote) } - } else { - // No need to notify if cancelled prior to the remote being created - } + if (cancelled) { + if (remotes.value.any { it.name == remote }) { + _alerts.update { it + SettingsAlert.RemoteAddPartiallySucceeded(remote) } } else { - _alerts.update { it + RemoteAddSucceeded(remote) } + // No need to notify if cancelled prior to the remote being created } } else { - _alerts.update { it + RemoteEditSucceeded(remote) } + _alerts.update { it + SettingsAlert.RemoteAddSucceeded(remote) } } + + _activityActions.update { it.copy(refreshRoots = true) } } } @@ -366,11 +217,15 @@ class SettingsViewModel : ViewModel() { withContext(Dispatchers.IO) { Logcat.dump(uri) } - _alerts.update { it + LogcatSucceeded(uri) } + _alerts.update { it + SettingsAlert.LogcatSucceeded(uri) } } catch (e: Exception) { Log.e(TAG, "Failed to dump logs to $uri", e) - _alerts.update { it + LogcatFailed(uri, e.toString()) } + _alerts.update { it + SettingsAlert.LogcatFailed(uri, e.toString()) } } } } -} \ No newline at end of file + + fun activityActionCompleted() { + _activityActions.update { SettingsActivityActions(false) } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 470e97e..5ed800d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Permissions Remotes + Remote Configuration Behavior UI @@ -13,7 +14,7 @@ About Debug - + Disable battery optimization Needed to upload files in the background. Missing notification permission @@ -45,25 +46,26 @@ Save logs Save logcat logs to a file. Note that the logs may contain names of remote files that have been accessed. - + + Open remote + Open this remote in the system file manager. + Configure remote + Rerun the rclone configuration wizard. + Rename remote + Change the name of the remote. If other remotes depends on this one, they will need to be manually updated with the new name. + Duplicate remote + Create a copy of this remote with identical configuration. + Delete remote + Remove this remote from the configuration. + Allow external app access + Allow external apps to access this remote via the system file manager. Access is not needed if this remote is just a backend for another remote. + Show in launcher shortcuts + Include this remote in the list of shortcuts when long pressing RSAF\'s launcher icon. + + Failed to get list of remotes: %1$s Successfully added new remote %1$s Partially added new remote %1$s due to interruption - Successfully edited remote %1$s - Successfully deleted remote %1$s - Failed to delete %1$s: %2$s - Successfully renamed remote %1$s to %2$s - Failed to rename remote %1$s to %2$s: %3$s - Successfully duplicated remote %1$s to %2$s - Failed to duplicate remote %1$s to %2$s: %3$s - Successfully blocked external app access to remote %1$s - Failed to block external app access to remote %1$s: %2$s - Successfully unblocked external app access to remote %1$s - Failed to unblock external app access to remote %1$s: %2$s - Successfully added launcher shortcut for remote: %1$s - Failed to add launcher shortcut for remote %1$s: %2$s - Successfully removed launcher shortcut for remote: %1$s - Failed to remove launcher shortcut for remote %1$s: %2$s Successfully imported configuration Failed to import configuration: %1$s Configuration import cancelled @@ -73,6 +75,14 @@ Successfully saved logs to %1$s Failed to save logs to %1$s: %2$s + + Successfully edited remote %1$s + Failed to delete %1$s: %2$s + Failed to rename remote %1$s to %2$s: %3$s + Failed to duplicate remote %1$s to %2$s: %3$s + Failed to update external app access to remote %1$s: %2$s + Failed to update launcher shortcut for remote %1$s: %2$s + Unlock configuration Biometric authentication error: %1$s @@ -92,15 +102,6 @@ Waiting for authorization Waiting for rclone webserver to start. Open the following link to authorize rclone for access to the backend. Once authorized, the token will be automatically inserted in the previous screen. - Open in DocumentsUI - Block external app access - Unblock external app access - Add to launcher shortcuts - Remove from launcher shortcuts - Configure remote - Rename remote - Duplicate remote - Delete remote Config import The selected config file is encrypted. Enter the decryption password to import it. Decryption password diff --git a/app/src/main/res/xml/preferences_edit_remote.xml b/app/src/main/res/xml/preferences_edit_remote.xml new file mode 100644 index 0000000..0ca4490 --- /dev/null +++ b/app/src/main/res/xml/preferences_edit_remote.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + +