From e46afbe3dfb4bc85dcf42a469fec408a98a35091 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Mon, 1 Jun 2026 01:13:09 -0400 Subject: [PATCH] Port to Jetpack Compose and adopt Material 3 Expressive Google recently announced that they will be focusing their efforts on Compose and all of the old View-based libraries are now in maintenance mode [0][1]. This commit is mostly a direct 1:1 port of the old UI to Compose. The main difference in UX is that the interactive configuration dialog for rclone remotes is now a regular activity. Compose does not support pinning dialogs to the bottom of the screen, so RSAF's old option for that has been removed. A regular activity with Back/Next buttons at the bottom prevents them from jumping around with each question. Other than that, this is mostly just UI changes to adopt Material 3 Expressive design. A couple of the old libraries are still kept around: * material-components-android is still used for android:colorBackground in the View theme so that the splash screen and the area surrounding the Activity when using predictive back gestures has the same color as the app's background. * preferences is still used just for the PreferenceManager class. The framework version of the class is deprecated. The various screens in the app are still built around activities instead of switching over to the new navigation3 library. This is an intentional choice because navigation3 currently has no good mechanism for passing around results in a locally scoped way (like Activity's setResult()) and its predictive back animations don't look nearly as good as what Android has builtin for activities. Accessibility-wise, the experience should be equivalent to before. I've tested using nearly all of the app's UI elements when navigating using only the Talkback screen reader. The main downside is that Compose makes the APK noticeably larger. Not much can be done about that since we already enable all of R8's optimizations. [0] https://developer.android.com/develop/ui/compose/first [1] https://m3.material.io/blog/material-is-compose-first Signed-off-by: Andrew Gunnerson --- app/build.gradle.kts | 23 +- app/src/main/AndroidManifest.xml | 9 +- ...ferenceBaseActivity.kt => BaseActivity.kt} | 216 ++- .../java/com/chiller3/rsaf/MainApplication.kt | 7 +- .../chiller3/rsaf/PreferenceBaseFragment.kt | 50 - .../java/com/chiller3/rsaf/Preferences.kt | 59 +- .../rsaf/dialog/AuthorizeDialogFragment.kt | 102 -- .../dialog/InactivityTimeoutDialogFragment.kt | 95 -- .../InteractiveConfigurationDialogFragment.kt | 417 ------ .../rsaf/dialog/MessageDialogFragment.kt | 52 - .../rsaf/dialog/PasswordDialogFragment.kt | 56 - .../rsaf/dialog/RemoteNameDialogFragment.kt | 78 -- .../rsaf/dialog/TextInputDialogFragment.kt | 137 -- .../dialog/VfsCacheDeletionDialogFragment.kt | 53 - .../rsaf/dialog/VfsOptionsDialogFragment.kt | 205 --- .../chiller3/rsaf/rclone/RcloneProvider.kt | 6 +- .../chiller3/rsaf/settings/AuthorizeDialog.kt | 94 ++ .../AuthorizeViewModel.kt | 28 +- .../rsaf/settings/EditRemoteActivity.kt | 42 +- .../chiller3/rsaf/settings/EditRemoteAlert.kt | 4 +- .../rsaf/settings/EditRemoteFragment.kt | 356 ----- .../rsaf/settings/EditRemoteScreen.kt | 489 +++++++ .../rsaf/settings/EditRemoteViewModel.kt | 175 ++- .../rsaf/settings/ErrorDetailsDialog.kt | 64 + .../rsaf/settings/InactivityTimeoutDialog.kt | 93 ++ .../InteractiveConfigurationActivity.kt | 57 + .../InteractiveConfigurationScreen.kt | 608 ++++++++ .../InteractiveConfigurationViewModel.kt | 9 +- .../chiller3/rsaf/settings/PasswordDialog.kt | 145 ++ .../rsaf/settings/RemoteNameDialog.kt | 119 ++ .../rsaf/settings/SettingsActivity.kt | 30 +- .../chiller3/rsaf/settings/SettingsAlert.kt | 4 +- .../rsaf/settings/SettingsFragment.kt | 535 ------- .../chiller3/rsaf/settings/SettingsScreen.kt | 761 ++++++++++ .../rsaf/settings/SettingsViewModel.kt | 16 +- .../rsaf/settings/VfsCacheDeletionDialog.kt | 74 + .../rsaf/settings/VfsOptionsDialog.kt | 229 +++ .../main/java/com/chiller3/rsaf/ui/Padding.kt | 30 + .../java/com/chiller3/rsaf/ui/Preferences.kt | 289 ++++ .../main/java/com/chiller3/rsaf/ui/Screen.kt | 89 ++ .../com/chiller3/rsaf/ui/SegmentedList.kt | 55 + .../java/com/chiller3/rsaf/ui/theme/Icons.kt | 328 +++++ .../java/com/chiller3/rsaf/ui/theme/Theme.kt | 39 + .../rsaf/view/LongClickablePreference.kt | 47 - app/src/main/res/layout/dialog_authorize.xml | 26 - .../dialog_interactive_configuration.xml | 63 - app/src/main/res/layout/dialog_text_input.xml | 45 - .../main/res/layout/dialog_vfs_options.xml | 44 - .../res/layout/material_switch_preference.xml | 14 - app/src/main/res/layout/settings_activity.xml | 28 - app/src/main/res/values-zh-rCN/strings.xml | 3 - app/src/main/res/values/strings.xml | 5 +- app/src/main/res/values/styles.xml | 15 - app/src/main/res/values/themes.xml | 17 +- .../main/res/xml/preferences_edit_remote.xml | 95 -- app/src/main/res/xml/preferences_root.xml | 168 --- build.gradle.kts | 4 +- gradle/libs.versions.toml | 39 +- gradle/verification-metadata.xml | 1232 ++++++++++++----- 59 files changed, 4816 insertions(+), 3356 deletions(-) rename app/src/main/java/com/chiller3/rsaf/{PreferenceBaseActivity.kt => BaseActivity.kt} (53%) delete mode 100644 app/src/main/java/com/chiller3/rsaf/PreferenceBaseFragment.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/dialog/AuthorizeDialogFragment.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/dialog/InactivityTimeoutDialogFragment.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/dialog/InteractiveConfigurationDialogFragment.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/dialog/MessageDialogFragment.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/dialog/PasswordDialogFragment.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/dialog/RemoteNameDialogFragment.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/dialog/TextInputDialogFragment.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/dialog/VfsCacheDeletionDialogFragment.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/dialog/VfsOptionsDialogFragment.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/AuthorizeDialog.kt rename app/src/main/java/com/chiller3/rsaf/{dialog => settings}/AuthorizeViewModel.kt (64%) delete mode 100644 app/src/main/java/com/chiller3/rsaf/settings/EditRemoteFragment.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/EditRemoteScreen.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/ErrorDetailsDialog.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/InactivityTimeoutDialog.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/InteractiveConfigurationActivity.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/InteractiveConfigurationScreen.kt rename app/src/main/java/com/chiller3/rsaf/{dialog => settings}/InteractiveConfigurationViewModel.kt (91%) create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/PasswordDialog.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/RemoteNameDialog.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/settings/SettingsFragment.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/VfsCacheDeletionDialog.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/settings/VfsOptionsDialog.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/ui/Padding.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/ui/Preferences.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/ui/Screen.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/ui/SegmentedList.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/ui/theme/Icons.kt create mode 100644 app/src/main/java/com/chiller3/rsaf/ui/theme/Theme.kt delete mode 100644 app/src/main/java/com/chiller3/rsaf/view/LongClickablePreference.kt delete mode 100644 app/src/main/res/layout/dialog_authorize.xml delete mode 100644 app/src/main/res/layout/dialog_interactive_configuration.xml delete mode 100644 app/src/main/res/layout/dialog_text_input.xml delete mode 100644 app/src/main/res/layout/dialog_vfs_options.xml delete mode 100644 app/src/main/res/layout/material_switch_preference.xml delete mode 100644 app/src/main/res/layout/settings_activity.xml delete mode 100644 app/src/main/res/values/styles.xml delete mode 100644 app/src/main/res/xml/preferences_edit_remote.xml delete mode 100644 app/src/main/res/xml/preferences_root.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bb44ecd..b28c1b1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,6 +20,8 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.parcelize) } java { @@ -176,8 +178,8 @@ android { } buildFeatures { buildConfig = true + compose = true resValues = true - viewBinding = true } splits { // Split by ABI because compiled golang code is huge and a universal APK is nearly 200 MiB @@ -228,13 +230,15 @@ kotlin { } dependencies { - implementation(libs.activity.ktx) - implementation(libs.appcompat) - implementation(libs.biometric) - implementation(libs.core.ktx) - implementation(libs.exifinterface) - implementation(libs.fragment.ktx) - implementation(libs.preference.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.biometric.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.preference.ktx) implementation(libs.material) implementation(libs.tink.android) implementation(files(rcbridgeAar)) @@ -243,8 +247,9 @@ dependencies { // the Tink transitive dependency implementation(libs.spotbugs) + debugImplementation(libs.androidx.compose.ui.tooling) androidTestImplementation(libs.junit) - androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.androidx.test.espresso.core) } val archive = tasks.register("archive") { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2e01b65..98f3095 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,12 +5,13 @@ --> - + + tools:ignore="AllFilesAccessPolicy,ScopedStorage" /> + + = Build.VERSION_CODES.R } - protected abstract val actionBarTitle: CharSequence? - - protected abstract val showUpButton: Boolean - - protected abstract fun createFragment(): PreferenceBaseFragment - private val tag = javaClass.simpleName private lateinit var prefs: Preferences - private lateinit var bioPrompt: BiometricPrompt private lateinit var activityManager: ActivityManager private var isCoveredBySafeActivity = false - private val requestLegacyDeviceCredential = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - onAuthenticationSucceeded() - } else { - // We can't know the reason. - onAuthenticationError( - BiometricPrompt.ERROR_USER_CANCELED, - getString(R.string.biometric_error_cancelled), - ) - } - } - override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - val binding = SettingsActivityBinding.inflate(layoutInflater) - setContentView(binding.root) - - val transaction = supportFragmentManager.beginTransaction() - - // https://issuetracker.google.com/issues/181805603 - val bioFragment = supportFragmentManager - .findFragmentByTag("androidx.biometric.BiometricFragment") - if (bioFragment != null) { - transaction.remove(bioFragment) - } - - val fragment: PreferenceBaseFragment - - if (savedInstanceState == null) { - fragment = createFragment() - transaction.replace(R.id.settings, fragment) - } else { - fragment = supportFragmentManager.findFragmentById(R.id.settings) - as PreferenceBaseFragment - } - - transaction.commit() + prefs = Preferences(this) + activityManager = getSystemService(ActivityManager::class.java) - supportFragmentManager.setFragmentResultListener(fragment.requestTag, this) { _, result -> - setResult(RESULT_OK, Intent().apply { putExtras(result) }) - } + setContent { + var startedOnce by rememberSaveable { mutableStateOf(false) } - ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, windowInsets -> - val insets = windowInsets.getInsets( - WindowInsetsCompat.Type.systemBars() - or WindowInsetsCompat.Type.displayCutout() - ) + val modernLauncher = rememberAuthenticationLauncher { authResult -> + startedOnce = false - v.updateLayoutParams { - leftMargin = insets.left - topMargin = insets.top - rightMargin = insets.right + when (authResult) { + is AuthenticationResult.Success -> onAuthenticationSucceeded() + is AuthenticationResult.Error -> { + // Ignore cancellation due to eg. orientation change. + if (authResult.errorCode != BiometricPrompt.ERROR_CANCELED) { + onAuthenticationError(authResult.errorCode, authResult.errString) + } + } + } } - // Consuming the insets here prevents PreferenceBaseFragment's RecyclerView's insets - // callback from being called on older versions of Android, despite it not being a child - // of this view. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - WindowInsetsCompat.CONSUMED - } else { - windowInsets + val legacyLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + startedOnce = false + + if (it.resultCode == RESULT_OK) { + onAuthenticationSucceeded() + } else { + // We can't know the reason. + onAuthenticationError( + BiometricPrompt.ERROR_USER_CANCELED, + getString(R.string.biometric_error_cancelled), + ) + } } - } - setSupportActionBar(binding.toolbar) - supportActionBar!!.setDisplayHomeAsUpEnabled(showUpButton) + LifecycleResumeEffect(Unit) { + Log.d(tag, "onResume()") + AppLock.onAppResume() - actionBarTitle?.let { - title = it - } - - prefs = Preferences(this) - - bioPrompt = BiometricPrompt( - this, - mainExecutor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) = - this@PreferenceBaseActivity.onAuthenticationError(errorCode, errString) - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = - this@PreferenceBaseActivity.onAuthenticationSucceeded() - - override fun onAuthenticationFailed() { - // Ignore. This is called when a single biometric authentication attempt fails, - // but the user is still allowed to retry. + if (AppLock.isLocked && !startedOnce) { + startedOnce = true + startAuth(modernLauncher, legacyLauncher) } - }, - ) - activityManager = getSystemService(ActivityManager::class.java) - } + refreshTaskState() + refreshGlobalVisibility() - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true + onPauseOrDispose { + Log.d(tag, "onPause()") + AppLock.onAppPause() + } } - else -> super.onOptionsItemSelected(item) - } - } - - override fun onResume() { - super.onResume() - Log.d(tag, "onResume()") - - AppLock.onAppResume() - if (AppLock.isLocked) { - startAuth() + ActivityContent() } - - refreshTaskState() - refreshGlobalVisibility() } - override fun onPause() { - super.onPause() - Log.d(tag, "onPause()") - - AppLock.onAppPause() - } + @Composable + abstract fun ActivityContent() override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) @@ -213,26 +152,33 @@ abstract class PreferenceBaseActivity : AppCompatActivity() { super.onWindowAttributesChanged(params) } - private fun startAuth() { + private fun startAuth( + modernLauncher: AuthenticationResultLauncher, + legacyLauncher: ManagedActivityResultLauncher, + ) { if (supportsModernDeviceCredential()) { - startBiometricAuth() + startBiometricAuth(modernLauncher) } else { - startLegacyDeviceCredentialAuth() + startLegacyDeviceCredentialAuth(legacyLauncher) } } - private fun startBiometricAuth() { + private fun startBiometricAuth(launcher: AuthenticationResultLauncher) { Log.d(tag, "Starting biometric authentication") - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL) - .setTitle(getString(R.string.biometric_title)) - .build() - - bioPrompt.authenticate(promptInfo) + launcher.launch( + biometricRequest( + title = getString(R.string.biometric_title), + AuthenticationRequest.Biometric.Fallback.DeviceCredential, + ) { + setMinStrength(AuthenticationRequest.Biometric.Strength.Class3()) + } + ) } - private fun startLegacyDeviceCredentialAuth() { + private fun startLegacyDeviceCredentialAuth( + launcher: ManagedActivityResultLauncher, + ) { Log.d(tag, "Starting legacy device credential authentication") val keyguardManager = getSystemService(KeyguardManager::class.java) @@ -243,7 +189,7 @@ abstract class PreferenceBaseActivity : AppCompatActivity() { ) if (intent != null) { - requestLegacyDeviceCredential.launch(intent) + launcher.launch(intent) } else { onAuthenticationError( BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, diff --git a/app/src/main/java/com/chiller3/rsaf/MainApplication.kt b/app/src/main/java/com/chiller3/rsaf/MainApplication.kt index 124b812..986f11a 100644 --- a/app/src/main/java/com/chiller3/rsaf/MainApplication.kt +++ b/app/src/main/java/com/chiller3/rsaf/MainApplication.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -9,7 +9,6 @@ import android.app.Application import android.app.backup.BackupManager import android.content.SharedPreferences import android.util.Log -import com.google.android.material.color.DynamicColors import java.io.File class MainApplication : Application(), SharedPreferences.OnSharedPreferenceChangeListener { @@ -38,6 +37,7 @@ class MainApplication : Application(), SharedPreferences.OnSharedPreferenceChang } prefs = Preferences(this) + prefs.migrate() prefs.registerListener(this) backupManager = BackupManager(this) @@ -45,9 +45,6 @@ class MainApplication : Application(), SharedPreferences.OnSharedPreferenceChang AppLock.init(this) Notifications(this).updateChannels() - - // Enable Material You colors - DynamicColors.applyToActivitiesIfAvailable(this) } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { diff --git a/app/src/main/java/com/chiller3/rsaf/PreferenceBaseFragment.kt b/app/src/main/java/com/chiller3/rsaf/PreferenceBaseFragment.kt deleted file mode 100644 index 453cf7c..0000000 --- a/app/src/main/java/com/chiller3/rsaf/PreferenceBaseFragment.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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.ViewGroup -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() { - abstract val requestTag: String - - 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 - } -} diff --git a/app/src/main/java/com/chiller3/rsaf/Preferences.kt b/app/src/main/java/com/chiller3/rsaf/Preferences.kt index 35cd3f2..b4b7b70 100644 --- a/app/src/main/java/com/chiller3/rsaf/Preferences.kt +++ b/app/src/main/java/com/chiller3/rsaf/Preferences.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023-2025 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -13,48 +13,14 @@ import kotlin.math.max class Preferences(private val context: Context) { companion object { - const val CATEGORY_PERMISSIONS = "permissions" - const val CATEGORY_CONFIGURATION = "configuration" - 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" - const val PREF_LOCAL_STORAGE_ACCESS = "local_storage_access" + // Keep in the same order as the helper functions below. + const val PREF_DEBUG_MODE = "debug_mode" + private const val PREF_ADD_FILE_EXTENSION = "add_file_extension" const val PREF_PRETEND_LOCAL = "pretend_local" - const val PREF_REQUIRE_AUTH = "require_auth" - const val PREF_INACTIVITY_TIMEOUT = "inactivity_timeout" - const val PREF_LOCK_NOW = "lock_now" + private const val PREF_REQUIRE_AUTH = "require_auth" + private const val PREF_INACTIVITY_TIMEOUT = "inactivity_timeout" + private const val PREF_ALLOW_BACKUP = "allow_backup" const val PREF_VERBOSE_RCLONE_LOGS = "verbose_rclone_logs" - - // 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" - const val PREF_EDIT_REMOTE_PREFIX = "edit_remote_" - const val PREF_IMPORT_CONFIGURATION = "import_configuration" - const val PREF_EXPORT_CONFIGURATION = "export_configuration" - const val PREF_VERSION = "version" - const val PREF_SAVE_LOGS = "save_logs" - const val PREF_ADD_INTERNAL_CACHE_REMOTE = "add_internal_cache_remote" - - // 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_ALLOW_LOCKED_ACCESS = "allow_locked_access" - const val PREF_DYNAMIC_SHORTCUT = "dynamic_shortcut" - const val PREF_THUMBNAILS = "thumbnails" - const val PREF_REPORT_USAGE = "report_usage" - const val PREF_VFS_OPTIONS = "vfs_options" - - // Not associated with a UI preference - const val PREF_DEBUG_MODE = "debug_mode" private const val PREF_NEXT_NOTIFICATION_ID = "next_notification_id" // This needs to be large enough to account for activity transitions, where the lock state @@ -87,11 +53,6 @@ class Preferences(private val context: Context) { get() = prefs.getBoolean(PREF_PRETEND_LOCAL, false) set(enabled) = prefs.edit { putBoolean(PREF_PRETEND_LOCAL, enabled) } - /** Whether to show dialogs at the bottom of the screen. */ - var dialogsAtBottom: Boolean - get() = prefs.getBoolean(PREF_DIALOGS_AT_BOTTOM, false) - set(enabled) = prefs.edit { putBoolean(PREF_DIALOGS_AT_BOTTOM, enabled) } - /** Whether biometric or device credential auth is required. */ var requireAuth: Boolean get() = prefs.getBoolean(PREF_REQUIRE_AUTH, false) @@ -121,4 +82,10 @@ class Preferences(private val context: Context) { prefs.edit { putInt(PREF_NEXT_NOTIFICATION_ID, nextId + 1) } nextId } + + fun migrate() { + prefs.edit { + remove("dialogs_at_bottom") + } + } } diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/AuthorizeDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/AuthorizeDialogFragment.kt deleted file mode 100644 index 6fb59f4..0000000 --- a/app/src/main/java/com/chiller3/rsaf/dialog/AuthorizeDialogFragment.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 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.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.chiller3.rsaf.Preferences -import com.chiller3.rsaf.R -import com.chiller3.rsaf.databinding.DialogAuthorizeBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch - -open class AuthorizeDialogFragment : DialogFragment() { - companion object { - val TAG: String = AuthorizeDialogFragment::class.java.simpleName - - private const val ARG_CMD = "cmd" - const val RESULT_CODE = "code" - - fun newInstance(cmd: String): AuthorizeDialogFragment = - AuthorizeDialogFragment().apply { - arguments = Bundle().apply { putString(ARG_CMD, cmd) } - } - } - - private lateinit var binding: DialogAuthorizeBinding - private val viewModel: AuthorizeViewModel by viewModels() - private var code: String = "" - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val arguments = requireArguments() - - if (savedInstanceState == null) { - viewModel.authorize(arguments.getString(ARG_CMD)!!) - } - - binding = DialogAuthorizeBinding.inflate(layoutInflater) - - isCancelable = false - - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.dialog_authorize_title) - .setView(binding.root) - .setNegativeButton(android.R.string.cancel) { _, _ -> - viewModel.cancel() - } - .create() - .apply { - if (Preferences(requireContext()).dialogsAtBottom) { - window!!.attributes.gravity = Gravity.BOTTOM - } - - setCanceledOnTouchOutside(false) - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.url.collect { - if (it == null) { - binding.message.text = getString(R.string.dialog_authorize_message_loading) - } else { - binding.message.text = buildString { - append(getString(R.string.dialog_authorize_message_url)) - append("\n\n") - append(it) - } - } - } - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.code.collect { - if (it != null) { - code = it - dismiss() - } - } - } - } - - return dialog - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - - setFragmentResult(tag!!, Bundle().apply { putString(RESULT_CODE, code) }) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/InactivityTimeoutDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/InactivityTimeoutDialogFragment.kt deleted file mode 100644 index 3781887..0000000 --- a/app/src/main/java/com/chiller3/rsaf/dialog/InactivityTimeoutDialogFragment.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 Andrew Gunnerson - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.chiller3.rsaf.dialog - -import android.annotation.SuppressLint -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.text.InputType -import android.view.Gravity -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import com.chiller3.rsaf.Preferences -import com.chiller3.rsaf.R -import com.chiller3.rsaf.databinding.DialogTextInputBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class InactivityTimeoutDialogFragment : DialogFragment() { - companion object { - val TAG: String = InactivityTimeoutDialogFragment::class.java.simpleName - - const val RESULT_SUCCESS = "success" - } - - private lateinit var prefs: Preferences - private lateinit var binding: DialogTextInputBinding - private var duration: Int? = null - private var success: Boolean = false - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - prefs = Preferences(requireContext()) - - binding = DialogTextInputBinding.inflate(layoutInflater) - binding.message.text = getString(R.string.dialog_inactivity_timeout_message) - - binding.confirmTextLayout.isVisible = false - - binding.text.inputType = InputType.TYPE_CLASS_NUMBER - binding.text.addTextChangedListener { - duration = try { - val seconds = it.toString().toInt() - if (seconds >= Preferences.MIN_INACTIVITY_TIMEOUT) { - seconds - } else { - null - } - } catch (_: Exception) { - null - } - - refreshOkButtonEnabledState() - } - if (savedInstanceState == null) { - @SuppressLint("SetTextI18n") - binding.text.setText(prefs.inactivityTimeout.toString()) - } - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.dialog_inactivity_timeout_title) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - prefs.inactivityTimeout = duration!! - success = true - } - .setNegativeButton(android.R.string.cancel, null) - .create() - .apply { - if (Preferences(requireContext()).dialogsAtBottom) { - window!!.attributes.gravity = Gravity.BOTTOM - } - } - } - - override fun onStart() { - super.onStart() - refreshOkButtonEnabledState() - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - - setFragmentResult(tag!!, Bundle().apply { putBoolean(RESULT_SUCCESS, success) }) - } - - private fun refreshOkButtonEnabledState() { - (dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = - duration != null - } -} diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/InteractiveConfigurationDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/InteractiveConfigurationDialogFragment.kt deleted file mode 100644 index 2cfc969..0000000 --- a/app/src/main/java/com/chiller3/rsaf/dialog/InteractiveConfigurationDialogFragment.kt +++ /dev/null @@ -1,417 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.chiller3.rsaf.dialog - -import android.annotation.SuppressLint -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.system.ErrnoException -import android.text.InputType -import android.util.Log -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.RadioButton -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import androidx.fragment.app.setFragmentResultListener -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.chiller3.rsaf.Preferences -import com.chiller3.rsaf.R -import com.chiller3.rsaf.databinding.DialogInteractiveConfigurationBinding -import com.chiller3.rsaf.rclone.RcloneConfig -import com.chiller3.rsaf.rclone.RcloneRpc -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputLayout -import kotlinx.coroutines.launch -import kotlin.properties.Delegates - -class InteractiveConfigurationDialogFragment : DialogFragment() { - companion object { - val TAG: String = InteractiveConfigurationDialogFragment::class.java.simpleName - - private const val ARG_REMOTE = "remote" - private const val ARG_NEW = "new" - const val RESULT_REMOTE = ARG_REMOTE - const val RESULT_NEW = ARG_NEW - const val RESULT_CANCELLED = "cancelled" - private const val STATE_LAST_OPTION_NAME = "last_option_name" - private const val STATE_USER_INPUT = "user_input" - - fun newInstance(remote: String, new: Boolean): InteractiveConfigurationDialogFragment = - InteractiveConfigurationDialogFragment().apply { - arguments = Bundle().apply { - putString(ARG_REMOTE, remote) - putBoolean(ARG_NEW, new) - } - } - - /** Replace newlines with spaces unless there are multiple newlines in a row. */ - private fun reflowString(msg: String): String = - msg.replace("([^\\n])\\n([^\\n]|$)".toRegex(), "$1 $2") - - private fun tryReveal(text: String, isPassword: Boolean): String { - var value = text - - if (isPassword) { - try { - value = RcloneConfig.revealPassword(text).value - } catch (e: ErrnoException) { - Log.w(TAG, "Failed to reveal password", e) - } - } - - return value - } - } - - private var cancelled = true - private var isNew by Delegates.notNull() - private val viewModel: InteractiveConfigurationViewModel by viewModels() - private lateinit var binding: DialogInteractiveConfigurationBinding - private var radioToValue = hashMapOf() - private var ignoreRadioChanged = false - private var currentOrDefault = "" - private var userInput = "" - private var lastOptionName: String? = null - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val arguments = requireArguments() - val remote = arguments.getString(ARG_REMOTE)!! - isNew = arguments.getBoolean(ARG_NEW) - - if (savedInstanceState == null) { - viewModel.init(remote) - } else { - lastOptionName = savedInstanceState.getString(STATE_LAST_OPTION_NAME) - userInput = savedInstanceState.getString(STATE_USER_INPUT)!! - } - - binding = DialogInteractiveConfigurationBinding.inflate(layoutInflater) - - binding.text.addTextChangedListener { - userInput = it.toString() - setExampleSelectionFromInput() - refreshNextButtonEnabledState() - } - - binding.examplesGroup.setOnCheckedChangeListener { _, checkedId -> - if (!ignoreRadioChanged) { - // On configuration change, this may run prior to viewModel.question.collect - radioToValue[checkedId]?.let { - userInput = it - setTextToInput() - refreshNextButtonEnabledState() - } - } - } - - binding.authorize.setOnClickListener { - val cmd = viewModel.question.value!!.second.authorizeCmd - - AuthorizeDialogFragment.newInstance(cmd).show( - parentFragmentManager.beginTransaction(), - AuthorizeDialogFragment.TAG, - ) - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - viewModel.run.collect { - if (!it) { - // No more questions. We can just exit because changes are immediately - // committed upon submission. - cancelled = false - dismiss() - } - } - } - } - - val title = if (isNew) { - getString(R.string.ic_title_add_remote, remote) - } else { - getString(R.string.ic_title_edit_remote, remote) - } - - // Don't lose user state unless the user cancels intentionally. - isCancelable = false - - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(title) - .setView(binding.root) - .setPositiveButton(R.string.dialog_action_next, null) - .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.dialog_action_back, null) - .create() - .apply { - if (Preferences(requireContext()).dialogsAtBottom) { - window!!.attributes.gravity = Gravity.BOTTOM - } - - setCanceledOnTouchOutside(false) - - // Set click handlers manually because doing it via the alert dialog builder forces - // the buttons to always dismiss the dialog - setOnShowListener { - getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val (answer, ok) = getSubmission() - if (!ok) { - throw IllegalStateException( - "Next button was able to be pressed with invalid answer") - } - - viewModel.submit(answer) - } - getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { - dismiss() - } - getButton(AlertDialog.BUTTON_NEGATIVE).setOnLongClickListener { - userInput = currentOrDefault - setStateFromInput() - true - } - getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { - viewModel.goBack() - } - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.question.collect { question -> - val (error, option) = question ?: return@collect - - currentOrDefault = if (option.value.isNotEmpty()) { - tryReveal(option.value, option.isPassword) - } else { - tryReveal(option.default, option.isPassword) - } - - updateMessage(error, option) - updateInput(option) - updateExamples(option) - updateAuthorize(option) - - if (lastOptionName != option.name) { - userInput = currentOrDefault - } - // Otherwise, configuration change or repeated question after rejected answer - - setStateFromInput() - - lastOptionName = option.name - } - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.hasPrevious.collect { hasPrevious -> - dialog.getButton(AlertDialog.BUTTON_NEUTRAL).isEnabled = hasPrevious - } - } - } - - setFragmentResultListener(AuthorizeDialogFragment.TAG) { _, bundle: Bundle -> - userInput = bundle.getString(AuthorizeDialogFragment.RESULT_CODE)!! - setTextToInput() - refreshNextButtonEnabledState() - } - - return dialog - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - - val arguments = requireArguments() - - // Just to signal completion to the parent. - setFragmentResult(tag!!, Bundle().apply { - putString(RESULT_REMOTE, arguments.getString(ARG_REMOTE)) - putBoolean(RESULT_NEW, arguments.getBoolean(ARG_NEW)) - putBoolean(RESULT_CANCELLED, cancelled) - }) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putString(STATE_LAST_OPTION_NAME, lastOptionName) - outState.putString(STATE_USER_INPUT, userInput) - } - - private fun setTextToInput() { - if (binding.text.text.toString() != userInput) { - binding.text.setText(userInput) - } - } - - private fun setExampleSelectionFromInput() { - val exampleId = radioToValue.entries.find { it.value == userInput }?.key - if (exampleId != null) { - binding.examplesGroup.check(exampleId) - } else { - // When clearing the checked state, the OnCheckedChangeListener is called twice: once - // for the currently selected item and again for -1. In the listener, there's no way to - // detect and ignore that first invocation, so we'll have to settle for this ugly - // workaround. - ignoreRadioChanged = true - binding.examplesGroup.clearCheck() - ignoreRadioChanged = false - } - } - - /** - * Update UI state for the current user input. - * - * All UI elements must have already been populated with the appropriate data for the current - * question. - */ - private fun setStateFromInput() { - setTextToInput() - setExampleSelectionFromInput() - refreshNextButtonEnabledState() - } - - /** - * Update the main dialog message. - * - * If [error] is specified, it represents an error with the previously submitted answer. In this - * scenario, the question is likely the same as before because the bad answer prevented - * progression. The error message is shown on its own line prior to the new question. - */ - private fun updateMessage(error: String?, option: RcloneRpc.ProviderOption) { - binding.message.text = buildString { - if (error != null) { - append(reflowString(error)) - append("\n\n") - } - append(reflowString(option.help)) - } - } - - /** - * Update input field UI state. - * - * If the question does not use exclusive options, a text box will be shown. This does not - * change the contents of the text box. - */ - private fun updateInput(option: RcloneRpc.ProviderOption) { - if (option.exclusive) { - binding.textLayout.isVisible = false - } else { - binding.textLayout.isVisible = true - - binding.textLayout.hint = option.name - - if (option.required) { - binding.textLayout.helperText = - getString(R.string.ic_text_box_helper_required) - } else { - binding.textLayout.helperText = - getString(R.string.ic_text_box_helper_not_required) - } - - binding.textLayout.endIconMode = if (option.isPassword) { - TextInputLayout.END_ICON_PASSWORD_TOGGLE - } else { - TextInputLayout.END_ICON_NONE - } - - binding.text.inputType = InputType.TYPE_CLASS_TEXT or if (option.isPassword) { - InputType.TYPE_TEXT_VARIATION_PASSWORD - } else { - 0 - } - } - } - - /** - * Update examples UI state. - * - * This will clear existing radio buttons and create new ones for each example. If the examples - * are not exclusive, then the examples header is shown to notify the user that the radio - * buttons are just examples. - */ - private fun updateExamples(option: RcloneRpc.ProviderOption) { - radioToValue.clear() - binding.examplesGroup.removeAllViews() - - if (option.examples.isEmpty()) { - binding.examplesHeader.isVisible = false - binding.examplesGroup.isVisible = false - } else { - binding.examplesHeader.isVisible = !option.exclusive - binding.examplesGroup.isVisible = true - - val context = requireContext() - - for (example in option.examples) { - val radioButton = RadioButton(context).apply { - id = View.generateViewId() - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - // Not translatable. rclone always provides English strings anyway. - @SuppressLint("SetTextI18n") - text = "${example.value} (${example.help})" - } - radioToValue[radioButton.id] = example.value - binding.examplesGroup.addView(radioButton) - } - } - } - - private fun updateAuthorize(option: RcloneRpc.ProviderOption) { - binding.authorize.isVisible = option.isAuthorize - } - - /** - * Enable the next button if the current inputs are semantically valid. - * - * The answer may still be rejected upon submission. - */ - private fun refreshNextButtonEnabledState() { - val (_, ok) = getSubmission() - - val dialog = dialog as AlertDialog - dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = ok - } - - /** - * Get the value to submit as the answer to the question. - * - * @return The answer computed from the user's current selection/input and whether it is valid. - * A null answer means omitting it (ie. use the default/current value). - */ - private fun getSubmission(): Pair { - val (_, option) = viewModel.question.value ?: return Pair(null, false) - - val text = if (option.examples.isEmpty()) { - binding.text.text.toString() - } else { - userInput - } - - return if (option.exclusive && !option.examples.any { it.value == text }) { - Pair(text, false) - } else if (option.required && text.isEmpty()) { - Pair(text, false) - } else { - Pair(text, true) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/MessageDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/MessageDialogFragment.kt deleted file mode 100644 index d6b54ab..0000000 --- a/app/src/main/java/com/chiller3/rsaf/dialog/MessageDialogFragment.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 Andrew Gunnerson - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.chiller3.rsaf.dialog - -import android.app.Dialog -import android.content.ClipData -import android.content.ClipboardManager -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class MessageDialogFragment : DialogFragment() { - companion object { - val TAG: String = MessageDialogFragment::class.java.simpleName - - private const val ARG_TITLE = "title" - private const val ARG_MESSAGE = "message" - - fun newInstance(title: String?, message: String?): MessageDialogFragment = - MessageDialogFragment().apply { - arguments = Bundle().apply { - putString(ARG_TITLE, title) - putString(ARG_MESSAGE, message) - } - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val arguments = requireArguments() - val title = arguments.getString(ARG_TITLE) - val message = arguments.getString(ARG_MESSAGE)?.trimEnd() - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(title) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .setNeutralButton(android.R.string.copy) { _, _ -> - val clipboardManager = requireContext() - .getSystemService(ClipboardManager::class.java) - val clipData = ClipData.newPlainText("message", message) - - clipboardManager.setPrimaryClip(clipData) - } - .create() - .apply { - setCanceledOnTouchOutside(false) - } - } -} diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/PasswordDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/PasswordDialogFragment.kt deleted file mode 100644 index 91ee8be..0000000 --- a/app/src/main/java/com/chiller3/rsaf/dialog/PasswordDialogFragment.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Andrew Gunnerson - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.chiller3.rsaf.dialog - -import android.content.Context -import android.os.Bundle -import com.chiller3.rsaf.R -import com.chiller3.rsaf.settings.ImportExportMode - -class PasswordDialogFragment : TextInputDialogFragment() { - companion object { - val TAG: String = PasswordDialogFragment::class.java.simpleName - - const val RESULT_SUCCESS = TextInputDialogFragment.RESULT_SUCCESS - const val RESULT_PASSWORD = "password" - - fun newInstance(context: Context, mode: ImportExportMode) = - PasswordDialogFragment().apply { - arguments = TextInputParams( - inputType = TextInputType.PASSWORD, - title = when (mode) { - ImportExportMode.IMPORT -> - context.getString(R.string.dialog_import_password_title) - ImportExportMode.EXPORT -> - context.getString(R.string.dialog_export_password_title) - }, - message = when (mode) { - ImportExportMode.IMPORT -> - context.getString(R.string.dialog_import_password_message) - ImportExportMode.EXPORT -> - context.getString(R.string.dialog_export_password_message) - }, - hint = when (mode) { - ImportExportMode.IMPORT -> - context.getString(R.string.dialog_import_password_hint) - ImportExportMode.EXPORT -> - context.getString(R.string.dialog_export_password_hint) - }, - confirmHint = when (mode) { - ImportExportMode.IMPORT -> null - ImportExportMode.EXPORT -> - context.getString(R.string.dialog_export_password_confirm_hint) - }, - ).toArgs() - } - } - - override fun translateInput(input: String): String = input - - override fun updateResult(result: Bundle, value: String?) { - result.putString(RESULT_PASSWORD, value) - } -} diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/RemoteNameDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/RemoteNameDialogFragment.kt deleted file mode 100644 index 3340b4f..0000000 --- a/app/src/main/java/com/chiller3/rsaf/dialog/RemoteNameDialogFragment.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 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_SUCCESS = TextInputDialogFragment.RESULT_SUCCESS - const val RESULT_NAME = "name" - - fun newInstance( - context: Context, - action: RemoteNameDialogAction, - remoteNames: Array, - ) = RemoteNameDialogFragment().apply { - arguments = TextInputParams( - inputType = TextInputType.NORMAL, - title = action.getTitle(context), - message = context.getString(R.string.dialog_remote_name_message), - hint = context.getString(R.string.dialog_remote_name_hint), - ).toArgs().apply { - putStringArray(ARG_REMOTE_NAMES, remoteNames) - } - } - } - - private lateinit var remoteNames: Array - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - remoteNames = requireArguments().getStringArray(ARG_REMOTE_NAMES)!! - return super.onCreateDialog(savedInstanceState) - } - - override fun translateInput(input: String): String? { - try { - RcloneConfig.checkName(input) - if (input !in remoteNames) { - return input - } - } catch (_: Exception) { - // Ignore - } - - return null - } - - override fun updateResult(result: Bundle, value: String?) { - result.putString(RESULT_NAME, value) - } -} diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/TextInputDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/TextInputDialogFragment.kt deleted file mode 100644 index 5707659..0000000 --- a/app/src/main/java/com/chiller3/rsaf/dialog/TextInputDialogFragment.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 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.text.InputType -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import com.chiller3.rsaf.databinding.DialogTextInputBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -private const val ARG_TITLE = "title" -private const val ARG_MESSAGE = "message" -private const val ARG_HINT = "hint" -private const val ARG_CONFIRM_HINT = "confirm_hint" -private const val ARG_INPUT_TYPE = "input_type" -private const val ARG_ORIG_VALUE = "orig_value" - -enum class TextInputType { - NORMAL, - PASSWORD, - NUMBER, -} - -data class TextInputParams( - val inputType: TextInputType, - val title: String, - val message: String, - val hint: String, - val confirmHint: String? = null, - val origValue: String? = null, -) { - constructor(args: Bundle) : this( - inputType = TextInputType.entries[args.getInt(ARG_INPUT_TYPE)], - title = args.getString(ARG_TITLE)!!, - message = args.getString(ARG_MESSAGE)!!, - hint = args.getString(ARG_HINT)!!, - confirmHint = args.getString(ARG_CONFIRM_HINT), - origValue = args.getString(ARG_ORIG_VALUE), - ) - - fun toArgs() = Bundle().apply { - putInt(ARG_INPUT_TYPE, inputType.ordinal) - putString(ARG_TITLE, title) - putString(ARG_MESSAGE, message) - putString(ARG_HINT, hint) - putString(ARG_CONFIRM_HINT, confirmHint) - putString(ARG_ORIG_VALUE, origValue) - } -} - -abstract class TextInputDialogFragment : DialogFragment() { - companion object { - protected const val RESULT_SUCCESS = "success" - protected const val RESULT_ORIG_VALUE = "orig_value" - } - - private lateinit var binding: DialogTextInputBinding - private lateinit var params: TextInputParams - private var success: Boolean = false - private var value: T? = null - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - params = TextInputParams(requireArguments()) - - binding = DialogTextInputBinding.inflate(layoutInflater) - binding.message.text = params.message - - binding.textLayout.hint = params.hint - binding.confirmTextLayout.hint = params.confirmHint - binding.confirmTextLayout.isVisible = params.confirmHint != null - - val inputType = when (params.inputType) { - TextInputType.NORMAL -> - InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS - TextInputType.PASSWORD -> - InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - TextInputType.NUMBER -> InputType.TYPE_CLASS_NUMBER - } - binding.text.inputType = inputType - binding.confirmText.inputType = inputType - - binding.text.addTextChangedListener { - value = translateInput(it.toString()) - - refreshOkButtonEnabledState() - } - binding.confirmText.addTextChangedListener { - refreshOkButtonEnabledState() - } - - if (savedInstanceState == null) { - binding.text.setText(params.origValue) - } - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(params.title) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - success = true - } - .setNegativeButton(android.R.string.cancel, null) - .create() - } - - override fun onStart() { - super.onStart() - refreshOkButtonEnabledState() - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - - setFragmentResult(tag!!, Bundle().apply { - putBoolean(RESULT_SUCCESS, success) - putString(RESULT_ORIG_VALUE, params.origValue) - }.also { updateResult(it, value) }) - } - - private fun refreshOkButtonEnabledState() { - (dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = value != null - && (params.confirmHint == null - || binding.text.text?.toString() == binding.confirmText.text?.toString()) - } - - abstract fun translateInput(input: String): T? - - abstract fun updateResult(result: Bundle, value: T?) -} diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/VfsCacheDeletionDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/VfsCacheDeletionDialogFragment.kt deleted file mode 100644 index 8350557..0000000 --- a/app/src/main/java/com/chiller3/rsaf/dialog/VfsCacheDeletionDialogFragment.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 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.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 - -class VfsCacheDeletionDialogFragment : DialogFragment() { - companion object { - private const val ARG_TITLE = "title" - const val RESULT_SUCCESS = "success" - - fun newInstance(title: String) = VfsCacheDeletionDialogFragment().apply { - arguments = Bundle().apply { putString(ARG_TITLE, title) } - } - } - - private var success: Boolean = false - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val arguments = requireArguments() - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(arguments.getString(ARG_TITLE)) - .setMessage(R.string.dialog_vfs_cache_deletion_message) - .setPositiveButton(R.string.dialog_action_proceed_anyway) { _, _ -> - success = true - } - .setNegativeButton(android.R.string.cancel, null) - .create() - .apply { - if (Preferences(requireContext()).dialogsAtBottom) { - window!!.attributes.gravity = Gravity.BOTTOM - } - } - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - - setFragmentResult(tag!!, Bundle().apply { putBoolean(RESULT_SUCCESS, success) }) - } -} diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/VfsOptionsDialogFragment.kt b/app/src/main/java/com/chiller3/rsaf/dialog/VfsOptionsDialogFragment.kt deleted file mode 100644 index ee50869..0000000 --- a/app/src/main/java/com/chiller3/rsaf/dialog/VfsOptionsDialogFragment.kt +++ /dev/null @@ -1,205 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025-2026 Andrew Gunnerson - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.chiller3.rsaf.dialog - -import android.app.Dialog -import android.content.DialogInterface -import android.content.Intent -import android.os.Bundle -import android.system.ErrnoException -import android.text.Annotation -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.SpannedString -import android.text.method.LinkMovementMethod -import android.text.style.ClickableSpan -import android.view.Gravity -import android.view.View -import android.view.WindowManager -import androidx.appcompat.app.AlertDialog -import androidx.core.net.toUri -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import com.chiller3.rsaf.Preferences -import com.chiller3.rsaf.R -import com.chiller3.rsaf.binding.rcbridge.Rcbridge -import com.chiller3.rsaf.databinding.DialogVfsOptionsBinding -import com.chiller3.rsaf.extension.toSingleLineString -import com.chiller3.rsaf.rclone.RcloneRpc -import com.chiller3.rsaf.rclone.VfsCache -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class VfsOptionsDialogFragment : DialogFragment() { - companion object { - val TAG: String = VfsOptionsDialogFragment::class.java.simpleName - - private const val ARG_REMOTE = "remote" - const val RESULT_REMOTE = ARG_REMOTE - - fun newInstance(remote: String): VfsOptionsDialogFragment = - VfsOptionsDialogFragment().apply { - arguments = Bundle().apply { putString(ARG_REMOTE, remote) } - } - } - - private lateinit var binding: DialogVfsOptionsBinding - private lateinit var remote: String - private var overrides: Map? = null - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val arguments = requireArguments() - remote = arguments.getString(ARG_REMOTE)!! - - binding = DialogVfsOptionsBinding.inflate(layoutInflater) - - binding.message.movementMethod = LinkMovementMethod.getInstance() - binding.message.text = buildMessage() - - binding.text.addTextChangedListener { - try { - val newOverrides = mutableMapOf() - - for (line in it.toString().splitToSequence('\n')) { - if (line.trim().isEmpty()) { - continue - } - - val pieces = line.split('=', limit = 2) - - // Treat an incomplete line as just the key. rcbridge will show a better error - // message for unknown keys. - newOverrides[pieces[0]] = if (pieces.size > 1) { - pieces[1] - } else { - "" - } - } - - VfsCache.getVfsOptions(newOverrides) - overrides = newOverrides - - binding.textLayout.error = null - // Don't keep the layout space for the error message reserved. - binding.textLayout.isErrorEnabled = false - } catch (e: Exception) { - overrides = null - - binding.textLayout.error = if (e is ErrnoException) { - e.cause!!.message - } else { - e.toSingleLineString() - } - } - - refreshButtonsEnabledState() - } - - if (savedInstanceState == null) { - val config = RcloneRpc.remoteConfigs[remote]!! - - binding.text.setText(buildString { - for ((key, value) in config.vfsOptions) { - if (isNotEmpty()) { - append('\n') - } - append("$key=$value") - } - }) - } - - isCancelable = false - - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.vfs_options_title, remote)) - .setView(binding.root) - .setPositiveButton(R.string.vfs_options_save_and_reload) { _, _ -> - save(true) - } - .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.vfs_options_save_only) { _, _ -> - save(false) - } - .create() - .apply { - if (Preferences(requireContext()).dialogsAtBottom) { - window!!.attributes.gravity = Gravity.BOTTOM - } - - // The dialog can resize due to the multiline input. Make sure the dialog doesn't - // get hidden behind the IME. - // - // SOFT_INPUT_ADJUST_RESIZE works reliably, but is deprecated. The normal way of - // using setOnApplyWindowInsetsListener() + adjusting padding does not work for the - // dialog's DecorView. It causes terrible layout issues. - // - // Adding WindowInsetsCompat.Type.ime() to window!!.attributes.fitInsetsTypes does - // not work in all cases either. It kind of works until you add enough lines to max - // out the height, rotate to landscape, rotate back to portrait, and then tap on the - // text box. The dialog doesn't resize to the correct size until the keyboard is - // closed and reopened. - // - // AOSP still uses this in several places, like the Settings app, so it should stay - // working. - @Suppress("DEPRECATION") - window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) - - setCanceledOnTouchOutside(false) - } - - return dialog - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - - // Just to signal completion to the parent. - setFragmentResult(tag!!, Bundle().apply { putString(RESULT_REMOTE, remote) }) - } - - private fun buildMessage(): SpannableStringBuilder { - val origMessage = getText(R.string.vfs_options_message) as SpannedString - val message = SpannableStringBuilder(origMessage) - val annotations = message.getSpans(0, origMessage.length, Annotation::class.java) - - for (annotation in annotations) { - val start = message.getSpanStart(annotation) - val end = message.getSpanEnd(annotation) - - if (annotation.key == "type" && annotation.value == "rclone_vfs_docs") { - message.setSpan( - object : ClickableSpan() { - override fun onClick(widget: View) { - val uri = "https://rclone.org/commands/rclone_mount/".toUri() - startActivity(Intent(Intent.ACTION_VIEW, uri)) - } - }, - start, - end, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - } else { - throw IllegalStateException("Invalid annotation: $annotation") - } - } - - return message - } - - private fun refreshButtonsEnabledState() { - val dialog = dialog as AlertDialog? - dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = overrides != null - dialog?.getButton(AlertDialog.BUTTON_NEUTRAL)?.isEnabled = overrides != null - } - - private fun save(reload: Boolean) { - RcloneRpc.setRemoteConfig(remote, RcloneRpc.RemoteConfig(vfsOptions = overrides!!)) - - if (reload) { - Rcbridge.rbCacheClearRemote("$remote:", false) - } - } -} diff --git a/app/src/main/java/com/chiller3/rsaf/rclone/RcloneProvider.kt b/app/src/main/java/com/chiller3/rsaf/rclone/RcloneProvider.kt index 5e04b51..642a2fc 100644 --- a/app/src/main/java/com/chiller3/rsaf/rclone/RcloneProvider.kt +++ b/app/src/main/java/com/chiller3/rsaf/rclone/RcloneProvider.kt @@ -49,7 +49,6 @@ import com.chiller3.rsaf.binding.rcbridge.Rcbridge import com.chiller3.rsaf.extension.toException import com.chiller3.rsaf.extension.toSingleLineString import com.chiller3.rsaf.rclone.RcloneProvider.Companion.MIME_TYPE_BINARY -import com.chiller3.rsaf.settings.SettingsFragment.Companion.documentsUiIntent import java.io.FileNotFoundException import java.io.IOException import java.util.concurrent.Executors @@ -115,6 +114,11 @@ class RcloneProvider : DocumentsProvider(), SharedPreferences.OnSharedPreference projection } + fun documentsUiIntent(remote: String) = Intent(Intent.ACTION_VIEW).apply { + val uri = DocumentsContract.buildRootUri(BuildConfig.DOCUMENTS_AUTHORITY, remote) + setDataAndType(uri, DocumentsContract.Root.MIME_TYPE_ITEM) + } + private fun updateShortcuts(context: Context, remoteConfigs: Map) { val icon = IconCompat.createWithResource(context, R.mipmap.ic_launcher) val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) diff --git a/app/src/main/java/com/chiller3/rsaf/settings/AuthorizeDialog.kt b/app/src/main/java/com/chiller3/rsaf/settings/AuthorizeDialog.kt new file mode 100644 index 0000000..0128eb3 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/AuthorizeDialog.kt @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.rememberViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.chiller3.rsaf.R + +@Composable +fun AuthorizeDialog( + cmd: String, + onReceive: (String) -> Unit, + onDismiss: () -> Unit, +) { + val scopedOwner = rememberViewModelStoreOwner() + + CompositionLocalProvider(LocalViewModelStoreOwner provides scopedOwner) { + val viewModel: AuthorizeViewModel = viewModel() + viewModel.authorize(cmd) + + val url by viewModel.url.collectAsStateWithLifecycle() + + AlertDialog( + title = { Text(text = stringResource(R.string.dialog_authorize_title)) }, + text = { + Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) { + Text(text = urlMessage(url)) + + LinearWavyProgressIndicator( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + }, + onDismissRequest = onDismiss, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) + + LaunchedEffect(Unit) { + viewModel.code.collect { + if (it != null) { + onReceive(it) + } + } + } + } +} + +@Composable +private fun urlMessage(url: String?) = if (url == null) { + AnnotatedString(stringResource(R.string.dialog_authorize_message_loading)) +} else { + buildAnnotatedString { + append(stringResource(R.string.dialog_authorize_message_url)) + append("\n\n") + withLink(LinkAnnotation.Url(url)) { + append(url) + } + } +} diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/AuthorizeViewModel.kt b/app/src/main/java/com/chiller3/rsaf/settings/AuthorizeViewModel.kt similarity index 64% rename from app/src/main/java/com/chiller3/rsaf/dialog/AuthorizeViewModel.kt rename to app/src/main/java/com/chiller3/rsaf/settings/AuthorizeViewModel.kt index a9ca0d4..060bf10 100644 --- a/app/src/main/java/com/chiller3/rsaf/dialog/AuthorizeViewModel.kt +++ b/app/src/main/java/com/chiller3/rsaf/settings/AuthorizeViewModel.kt @@ -1,17 +1,19 @@ /* - * SPDX-FileCopyrightText: 2023 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ -package com.chiller3.rsaf.dialog +package com.chiller3.rsaf.settings import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.chiller3.rsaf.rclone.Authorizer +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope 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 @@ -21,13 +23,21 @@ class AuthorizeViewModel : ViewModel(), Authorizer.AuthorizeListener { private val TAG = AuthorizeViewModel::class.java.simpleName } + private var started = false + private val _url = MutableStateFlow(null) - val url: StateFlow = _url + val url = _url.asStateFlow() private val _code = MutableStateFlow(null) - val code: StateFlow = _code + val code = _code.asStateFlow() fun authorize(cmd: String) { + if (started) { + return + } else { + started = true + } + viewModelScope.launch { try { withContext(Dispatchers.IO) { @@ -40,8 +50,12 @@ class AuthorizeViewModel : ViewModel(), Authorizer.AuthorizeListener { } } + @OptIn(DelicateCoroutinesApi::class) fun cancel() { - viewModelScope.launch { + // This intentionally does not use viewModelScope. viewModelScope will not run any more + // coroutines during onCleared(). The authorizer is a global resource and this cancellation + // must happen or else it will be unusable for the life of the process. + GlobalScope.launch { withContext(Dispatchers.IO) { Authorizer.cancel() } @@ -59,4 +73,4 @@ class AuthorizeViewModel : ViewModel(), Authorizer.AuthorizeListener { override fun onAuthorizeCode(code: String) { _code.update { code } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteActivity.kt b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteActivity.kt index a7ef5a0..916cb7d 100644 --- a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteActivity.kt +++ b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteActivity.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -7,32 +7,36 @@ package com.chiller3.rsaf.settings import android.content.Context import android.content.Intent -import android.os.Bundle -import com.chiller3.rsaf.PreferenceBaseActivity -import com.chiller3.rsaf.PreferenceBaseFragment - -class EditRemoteActivity : PreferenceBaseActivity() { +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.chiller3.rsaf.BaseActivity +import com.chiller3.rsaf.ui.theme.AppTheme + +class EditRemoteActivity : BaseActivity() { 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 = Bundle().apply { putString(EditRemoteFragment.ARG_REMOTE, remote) } + @Composable + override fun ActivityContent() { + var remote by rememberSaveable { mutableStateOf(intent.getStringExtra(EXTRA_REMOTE)!!) } + + AppTheme { + EditRemoteScreen( + remote = remote, + onEditNext = { name -> + remote = name + }, + onBack = ::finish, + ) + } } } diff --git a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteAlert.kt b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteAlert.kt index d76d73f..6524c55 100644 --- a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteAlert.kt +++ b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteAlert.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -29,4 +29,6 @@ sealed interface EditRemoteAlert { val opt: String, val error: String, ) : EditRemoteAlert + + data object DocumentsUINotFound : 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 deleted file mode 100644 index f323a13..0000000 --- a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteFragment.kt +++ /dev/null @@ -1,356 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2025 Andrew Gunnerson - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.chiller3.rsaf.settings - -import android.os.Bundle -import android.util.Log -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.MessageDialogFragment -import com.chiller3.rsaf.dialog.RemoteNameDialogAction -import com.chiller3.rsaf.dialog.RemoteNameDialogFragment -import com.chiller3.rsaf.dialog.VfsCacheDeletionDialogFragment -import com.chiller3.rsaf.dialog.VfsOptionsDialogFragment -import com.chiller3.rsaf.rclone.RcloneProvider -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_RENAME_REMOTE = "$TAG.rename_remote" - private val TAG_DUPLICATE_REMOTE = "$TAG.duplicate_remote" - - private val TAG_RENAME_REMOTE_CONFIRM = "$TAG.rename_remote_confirm" - private val TAG_DELETE_REMOTE_CONFIRM = "$TAG.delete_remote_confirm" - } - - override val requestTag: String = TAG - - private val viewModel: EditRemoteViewModel by viewModels() - - private lateinit var prefs: Preferences - 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 prefAllowLockedAccess: SwitchPreferenceCompat - private lateinit var prefDynamicShortcut: SwitchPreferenceCompat - private lateinit var prefThumbnails: SwitchPreferenceCompat - private lateinit var prefReportUsage: SwitchPreferenceCompat - private lateinit var prefVfsOptions: Preference - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.preferences_edit_remote, rootKey) - - prefs = Preferences(requireContext()) - - 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 - - prefAllowLockedAccess = findPreference(Preferences.PREF_ALLOW_LOCKED_ACCESS)!! - prefAllowLockedAccess.onPreferenceChangeListener = this - - prefDynamicShortcut = findPreference(Preferences.PREF_DYNAMIC_SHORTCUT)!! - prefDynamicShortcut.onPreferenceChangeListener = this - - prefThumbnails = findPreference(Preferences.PREF_THUMBNAILS)!! - prefThumbnails.onPreferenceChangeListener = this - - prefReportUsage = findPreference(Preferences.PREF_REPORT_USAGE)!! - prefReportUsage.onPreferenceChangeListener = this - - prefVfsOptions = findPreference(Preferences.PREF_VFS_OPTIONS)!! - prefVfsOptions.onPreferenceClickListener = this - - viewModel.remote = requireArguments().getString(ARG_REMOTE)!! - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - viewModel.remoteState.collect { state -> - prefOpenRemote.isEnabled = state.allowExternalAccessOrDefault == true - - prefAllowExternalAccess.isEnabled = state.allowExternalAccessOrDefault != null - state.allowExternalAccessOrDefault?.let { - prefAllowExternalAccess.isChecked = it - } - - prefAllowLockedAccess.isEnabled = state.allowExternalAccessOrDefault == true - && prefs.requireAuth - state.allowLockedAccessOrDefault?.let { - prefAllowLockedAccess.isChecked = it - } - - prefDynamicShortcut.isEnabled = state.allowExternalAccessOrDefault == true - state.config?.dynamicShortcutOrDefault?.let { - prefDynamicShortcut.isChecked = it - } - - prefThumbnails.isEnabled = state.allowExternalAccessOrDefault == true - state.config?.thumbnailsOrDefault?.let { - prefThumbnails.isChecked = it - } - - prefReportUsage.isEnabled = state.allowExternalAccessOrDefault == true - && state.features?.about == true - - state.config?.reportUsageOrDefault?.let { - prefReportUsage.isChecked = it - } - prefReportUsage.summary = when (state.features?.about) { - null -> getString(R.string.pref_edit_remote_report_usage_desc_loading) - true -> getString(R.string.pref_edit_remote_report_usage_desc_supported) - false -> getString(R.string.pref_edit_remote_report_usage_desc_unsupported) - } - - prefVfsOptions.isEnabled = state.allowExternalAccessOrDefault == true - } - } - } - - 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) { - RcloneProvider.notifyRootsChanged(requireContext()) - } - it.editNewRemote?.let { newRemote -> - Log.d(TAG, "Editing new remote: $newRemote") - setFragmentResult(requestTag, Bundle().apply { - putString(EditRemoteActivity.RESULT_NEW_REMOTE, newRemote) - }) - } - if (it.finish) { - Log.d(TAG, "Finishing edit remote activity for: ${viewModel.remote}") - requireActivity().finish() - } - viewModel.activityActionCompleted() - } - } - } - - for (key in arrayOf( - TAG_RENAME_REMOTE, - TAG_DUPLICATE_REMOTE, - TAG_RENAME_REMOTE_CONFIRM, - TAG_DELETE_REMOTE_CONFIRM, - 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_NAME)!! - - viewModel.renameRemote(newRemote) - } - } - TAG_DUPLICATE_REMOTE -> { - if (bundle.getBoolean(RemoteNameDialogFragment.RESULT_SUCCESS)) { - val newRemote = bundle.getString(RemoteNameDialogFragment.RESULT_NAME)!! - - viewModel.duplicateRemote(newRemote) - } - } - TAG_RENAME_REMOTE_CONFIRM -> { - if (bundle.getBoolean(VfsCacheDeletionDialogFragment.RESULT_SUCCESS)) { - confirmRenameDialog(true) - } - } - TAG_DELETE_REMOTE_CONFIRM -> { - if (bundle.getBoolean(VfsCacheDeletionDialogFragment.RESULT_SUCCESS)) { - confirmDelete(true) - } - } - InteractiveConfigurationDialogFragment.TAG -> { - viewModel.interactiveConfigurationCompleted( - bundle.getString(InteractiveConfigurationDialogFragment.RESULT_REMOTE)!!, - ) - } - } - } - - override fun onPreferenceClick(preference: Preference): Boolean { - when (preference) { - prefOpenRemote -> { - startActivity(documentsUiIntent(viewModel.remote)) - return true - } - prefConfigureRemote -> { - InteractiveConfigurationDialogFragment.newInstance(viewModel.remote, false) - .show(parentFragmentManager.beginTransaction(), - InteractiveConfigurationDialogFragment.TAG) - return true - } - prefRenameRemote -> { - confirmRenameDialog(false) - return true - } - prefDuplicateRemote -> { - RemoteNameDialogFragment.newInstance( - requireContext(), - RemoteNameDialogAction.Duplicate(viewModel.remote), - viewModel.remoteConfigs.value.keys.toTypedArray(), - ).show(parentFragmentManager.beginTransaction(), TAG_DUPLICATE_REMOTE) - return true - } - prefDeleteRemote -> { - confirmDelete(false) - return true - } - prefVfsOptions -> { - VfsOptionsDialogFragment.newInstance(viewModel.remote) - .show(parentFragmentManager.beginTransaction(), VfsOptionsDialogFragment.TAG) - 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(newValue as Boolean) - } - prefAllowLockedAccess -> { - viewModel.setLockedAccess(newValue as Boolean) - } - prefDynamicShortcut -> { - viewModel.setDynamicShortcut(newValue as Boolean) - } - prefThumbnails -> { - viewModel.setThumbnails(newValue as Boolean) - } - prefReportUsage -> { - viewModel.setReportUsage(newValue as Boolean) - } - } - - return false - } - - private fun onAlert(alert: EditRemoteAlert) { - val msg = when (alert) { - is EditRemoteAlert.ListRemotesFailed -> getString(R.string.alert_list_remotes_failure) - is EditRemoteAlert.RemoteEditSucceeded -> - getString(R.string.alert_edit_remote_success, alert.remote) - is EditRemoteAlert.RemoteDeleteFailed -> - getString(R.string.alert_delete_remote_failure, alert.remote) - is EditRemoteAlert.RemoteRenameFailed -> - getString(R.string.alert_rename_remote_failure, alert.oldRemote, alert.newRemote) - is EditRemoteAlert.RemoteDuplicateFailed -> - getString(R.string.alert_duplicate_remote_failure, alert.oldRemote, alert.newRemote) - is EditRemoteAlert.SetConfigFailed -> - getString(R.string.alert_set_config_failure, alert.opt, alert.remote) - } - - val details = when (alert) { - is EditRemoteAlert.ListRemotesFailed -> alert.error - is EditRemoteAlert.RemoteEditSucceeded -> null - is EditRemoteAlert.RemoteDeleteFailed -> alert.error - is EditRemoteAlert.RemoteRenameFailed -> alert.error - is EditRemoteAlert.RemoteDuplicateFailed -> alert.error - is EditRemoteAlert.SetConfigFailed -> alert.error - } - - // Give users a chance to read the message. LENGTH_LONG is only 2750ms. - Snackbar.make(requireView(), msg, 5000) - .apply { - if (details != null) { - setAction(R.string.action_details) { - MessageDialogFragment.newInstance( - getString(R.string.dialog_error_details_title), - details, - ).show(parentFragmentManager.beginTransaction(), MessageDialogFragment.TAG) - } - } - } - .addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - if (event != DISMISS_EVENT_CONSECUTIVE) { - viewModel.acknowledgeFirstAlert() - } - } - }) - .show() - } - - private fun confirmRenameDialog(force: Boolean) { - if (!force && viewModel.isVfsCacheDirty) { - VfsCacheDeletionDialogFragment.newInstance( - getString(R.string.dialog_rename_remote_title, viewModel.remote), - ).show(parentFragmentManager.beginTransaction(), TAG_RENAME_REMOTE_CONFIRM) - } else { - RemoteNameDialogFragment.newInstance( - requireContext(), - RemoteNameDialogAction.Rename(viewModel.remote), - viewModel.remoteConfigs.value.keys.toTypedArray(), - ).show(parentFragmentManager.beginTransaction(), TAG_RENAME_REMOTE) - } - } - - private fun confirmDelete(force: Boolean) { - if (!force && viewModel.isVfsCacheDirty) { - VfsCacheDeletionDialogFragment.newInstance( - getString(R.string.dialog_delete_remote_title, viewModel.remote), - ).show(parentFragmentManager.beginTransaction(), TAG_DELETE_REMOTE_CONFIRM) - } else { - viewModel.deleteRemote() - } - } -} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteScreen.kt b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteScreen.kt new file mode 100644 index 0000000..4522e61 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteScreen.kt @@ -0,0 +1,489 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.content.ActivityNotFoundException +import android.content.res.Configuration +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.chiller3.rsaf.Preferences +import com.chiller3.rsaf.R +import com.chiller3.rsaf.rclone.RcloneProvider +import com.chiller3.rsaf.rclone.RcloneProvider.Companion.documentsUiIntent +import com.chiller3.rsaf.rclone.RcloneRpc +import com.chiller3.rsaf.ui.AppScreen +import com.chiller3.rsaf.ui.BetterSegmentedShapes +import com.chiller3.rsaf.ui.Preference +import com.chiller3.rsaf.ui.PreferenceCategory +import com.chiller3.rsaf.ui.PreferenceColumn +import com.chiller3.rsaf.ui.SwitchPreference +import com.chiller3.rsaf.ui.theme.AppTheme + +@Composable +fun EditRemoteScreen( + remote: String, + onEditNext: (String) -> Unit, + onBack: () -> Unit, + viewModel: EditRemoteViewModel = viewModel(), +) { + val context = LocalContext.current + val resources = LocalResources.current + + viewModel.init(remote) + + val prefs = remember { Preferences(context) } + var reloadPrefs by remember { mutableIntStateOf(0) } + val requireAuth = remember(reloadPrefs) { prefs.requireAuth } + + val remoteState by viewModel.remoteState.collectAsStateWithLifecycle() + val remoteConfigs by viewModel.remoteConfigs.collectAsStateWithLifecycle() + + val requestInteractiveConfiguration = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { + val remote = it.data?.getStringExtra(InteractiveConfigurationActivity.EXTRA_REMOTE)!! + + viewModel.interactiveConfigurationCompleted(remote) + } + + var showErrorDialog by rememberSaveable { mutableStateOf(null) } + + AppScreen( + title = { Text(text = remote) }, + onBack = onBack, + ) { params -> + LaunchedEffect(Unit) { + viewModel.alerts.collect { alerts -> + val alert = alerts.firstOrNull() ?: return@collect + val msg = when (alert) { + is EditRemoteAlert.ListRemotesFailed -> + resources.getString(R.string.alert_list_remotes_failure) + is EditRemoteAlert.RemoteEditSucceeded -> + resources.getString(R.string.alert_edit_remote_success, alert.remote) + is EditRemoteAlert.RemoteDeleteFailed -> + resources.getString(R.string.alert_delete_remote_failure, alert.remote) + is EditRemoteAlert.RemoteRenameFailed -> + resources.getString(R.string.alert_rename_remote_failure, alert.oldRemote, alert.newRemote) + is EditRemoteAlert.RemoteDuplicateFailed -> + resources.getString(R.string.alert_duplicate_remote_failure, alert.oldRemote, alert.newRemote) + is EditRemoteAlert.SetConfigFailed -> + resources.getString(R.string.alert_set_config_failure, alert.opt, alert.remote) + EditRemoteAlert.DocumentsUINotFound -> + resources.getString(R.string.alert_documentsui_not_found) + } + val details = when (alert) { + is EditRemoteAlert.ListRemotesFailed -> alert.error + is EditRemoteAlert.RemoteEditSucceeded -> null + is EditRemoteAlert.RemoteDeleteFailed -> alert.error + is EditRemoteAlert.RemoteRenameFailed -> alert.error + is EditRemoteAlert.RemoteDuplicateFailed -> alert.error + is EditRemoteAlert.SetConfigFailed -> alert.error + EditRemoteAlert.DocumentsUINotFound -> null + } + + val result = params.snackbarHostState.showSnackbar( + message = msg, + details?.let { resources.getString(R.string.action_details) }, + withDismissAction = true, + ) + viewModel.acknowledgeFirstAlert() + + when (result) { + SnackbarResult.Dismissed -> {} + SnackbarResult.ActionPerformed -> { showErrorDialog = details } + } + } + } + + showErrorDialog?.let { message -> + ErrorDetailsDialog( + message = message, + onDismiss = { showErrorDialog = null }, + ) + } + + EditRemoteContent( + remote = remote, + state = remoteState, + existingRemotes = remoteConfigs.keys.toList(), + requireAuth = requireAuth, + onRemoteOpen = { + try { + context.startActivity(documentsUiIntent(remote)) + } catch (_: ActivityNotFoundException) { + viewModel.addAlert(EditRemoteAlert.DocumentsUINotFound) + } + }, + onRemoteConfigure = { + requestInteractiveConfiguration.launch( + InteractiveConfigurationActivity.createIntent(context, remote, false) + ) + }, + onRemoteRename = { name -> + viewModel.renameRemote(name) + }, + onRemoteDuplicate = { name -> + viewModel.duplicateRemote(name) + }, + onRemoteDelete = { + viewModel.deleteRemote() + }, + onAllowExternalAccessChange = { enabled -> + viewModel.setExternalAccess(enabled) + }, + onAllowLockedAccessChange = { enabled -> + viewModel.setLockedAccess(enabled) + }, + onDynamicShortcutChange = { enabled -> + viewModel.setDynamicShortcut(enabled) + }, + onThumbnailsChange = { enabled -> + viewModel.setThumbnails(enabled) + }, + onReportUsageChange = { enabled -> + viewModel.setReportUsage(enabled) + }, + onVfsOptionsChange = { options, reload -> + viewModel.setVfsOptions(options, reload) + }, + isVfsCacheDirty = { + viewModel.isVfsCacheDirty + }, + contentPadding = params.contentPadding, + ) + } + + LaunchedEffect(remote) { + viewModel.activityActions.collect { + if (it.refreshRoots) { + RcloneProvider.notifyRootsChanged(context) + } + it.editNewRemote?.let { newRemote -> + onEditNext(newRemote) + } + if (it.finish) { + onBack() + } + viewModel.activityActionCompleted() + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun EditRemoteContent( + remote: String, + state: RemoteState, + existingRemotes: List, + requireAuth: Boolean, + onRemoteOpen: () -> Unit, + onRemoteConfigure: () -> Unit, + onRemoteRename: (String) -> Unit, + onRemoteDuplicate: (String) -> Unit, + onRemoteDelete: () -> Unit, + onAllowExternalAccessChange: (Boolean) -> Unit, + onAllowLockedAccessChange: (Boolean) -> Unit, + onDynamicShortcutChange: (Boolean) -> Unit, + onThumbnailsChange: (Boolean) -> Unit, + onReportUsageChange: (Boolean) -> Unit, + onVfsOptionsChange: (Map, Boolean) -> Unit, + isVfsCacheDirty: () -> Boolean, + contentPadding: PaddingValues = PaddingValues(), +) { + var showVfsWarningDialog by rememberSaveable { mutableStateOf(null) } + var showRemoteNameDialog by rememberSaveable { mutableStateOf(null) } + var showVfsOptionsDialog by rememberSaveable { mutableStateOf(false) } + + val allowExternalAccess = state.config?.hardBlockedOrDefault == false + val allowLockedAccess = state.config?.softBlockedOrDefault == false + + PreferenceColumn(contentPadding = contentPadding) { + item(key = "remote") { + PreferenceCategory( + title = { Text(text = stringResource(R.string.pref_header_remote)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "open_remote") { + Preference( + onClick = onRemoteOpen, + enabled = allowExternalAccess, + shapes = BetterSegmentedShapes.top(), + title = { Text(text = stringResource(R.string.pref_edit_remote_open_name)) }, + summary = { Text(text = stringResource(R.string.pref_edit_remote_open_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "configure_remote") { + Preference( + onClick = onRemoteConfigure, + shapes = BetterSegmentedShapes.middle(), + title = { Text(text = stringResource(R.string.pref_edit_remote_configure_name)) }, + summary = { Text(text = stringResource(R.string.pref_edit_remote_configure_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "rename_remote") { + Preference( + onClick = { + if (isVfsCacheDirty()) { + showVfsWarningDialog = VfsCacheDeletionReason.Rename(remote) + } else { + showRemoteNameDialog = RemoteNameDialogAction.Rename(remote) + } + }, + shapes = BetterSegmentedShapes.middle(), + title = { Text(text = stringResource(R.string.pref_edit_remote_rename_name)) }, + summary = { Text(text = stringResource(R.string.pref_edit_remote_rename_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "duplicate_remote") { + Preference( + onClick = { showRemoteNameDialog = RemoteNameDialogAction.Duplicate(remote) }, + shapes = BetterSegmentedShapes.middle(), + title = { Text(text = stringResource(R.string.pref_edit_remote_duplicate_name)) }, + summary = { Text(text = stringResource(R.string.pref_edit_remote_duplicate_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "delete_remote") { + Preference( + onClick = { + if (isVfsCacheDirty()) { + showVfsWarningDialog = VfsCacheDeletionReason.Delete(remote) + } else { + onRemoteDelete() + } + }, + shapes = BetterSegmentedShapes.bottom(), + title = { Text(text = stringResource(R.string.pref_edit_remote_delete_name)) }, + summary = { Text(text = stringResource(R.string.pref_edit_remote_delete_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + if (state.config != null) { + item(key = "behavior") { + PreferenceCategory( + title = { Text(text = stringResource(R.string.pref_header_behavior)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "allow_external_access") { + SwitchPreference( + checked = allowExternalAccess, + onCheckedChange = onAllowExternalAccessChange, + shapes = BetterSegmentedShapes.top(), + title = { Text(text = stringResource(R.string.pref_edit_remote_allow_external_access_name)) }, + summary = { Text(text = stringResource(R.string.pref_edit_remote_allow_external_access_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "allow_locked_access") { + SwitchPreference( + checked = allowLockedAccess, + onCheckedChange = onAllowLockedAccessChange, + enabled = allowExternalAccess && requireAuth, + shapes = BetterSegmentedShapes.middle(), + title = { Text(text = stringResource(R.string.pref_edit_remote_allow_locked_access_name)) }, + summary = { Text(text = allowLockedAccessSummary(allowLockedAccess)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "dynamic_shortcut") { + SwitchPreference( + checked = state.config.dynamicShortcutOrDefault, + onCheckedChange = onDynamicShortcutChange, + enabled = allowExternalAccess, + shapes = BetterSegmentedShapes.middle(), + title = { Text(text = stringResource(R.string.pref_edit_remote_dynamic_shortcut_name)) }, + summary = { Text(text = stringResource(R.string.pref_edit_remote_dynamic_shortcut_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "thumbnails") { + SwitchPreference( + checked = state.config.thumbnailsOrDefault, + onCheckedChange = onThumbnailsChange, + enabled = allowExternalAccess, + shapes = BetterSegmentedShapes.middle(), + title = { Text(text = stringResource(R.string.pref_edit_remote_thumbnails_name)) }, + summary = { Text(text = stringResource(R.string.pref_edit_remote_thumbnails_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "report_usage") { + SwitchPreference( + checked = state.config.reportUsageOrDefault, + onCheckedChange = onReportUsageChange, + enabled = allowExternalAccess && state.features?.about == true, + shapes = BetterSegmentedShapes.middle(), + title = { Text(text = stringResource(R.string.pref_edit_remote_report_usage_name)) }, + summary = { Text(text = reportUsageSummary(state)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "vfs_options") { + Preference( + onClick = { showVfsOptionsDialog = true }, + enabled = allowExternalAccess, + shapes = BetterSegmentedShapes.bottom(), + title = { Text(text = stringResource(R.string.pref_edit_remote_vfs_options_name)) }, + summary = { Text(text = stringResource(R.string.pref_edit_remote_vfs_options_desc)) }, + modifier = Modifier.animateItem(), + ) + } + } + } + + showVfsWarningDialog?.let { reason -> + VfsCacheDeletionDialog( + reason = reason, + onConfirm = { + when (reason) { + is VfsCacheDeletionReason.Rename -> + showRemoteNameDialog = RemoteNameDialogAction.Rename(reason.remote) + is VfsCacheDeletionReason.Delete -> onRemoteDelete() + else -> throw IllegalStateException("Invalid reason: $reason") + } + + @Suppress("AssignedValueIsNeverRead") + showVfsWarningDialog = null + }, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showVfsWarningDialog = null + } + ) + } + + showRemoteNameDialog?.let { action -> + RemoteNameDialog( + action = action, + existingRemotes = existingRemotes, + onSelect = { name -> + when (action) { + is RemoteNameDialogAction.Rename -> onRemoteRename(name) + is RemoteNameDialogAction.Duplicate -> onRemoteDuplicate(name) + else -> throw IllegalStateException("Invalid action: $action") + } + + @Suppress("AssignedValueIsNeverRead") + showRemoteNameDialog = null + }, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showRemoteNameDialog = null + }, + ) + } + + if (showVfsOptionsDialog) { + VfsOptionsDialog( + remote = remote, + initialOptions = state.config?.vfsOptions!!, + onSelect = { options, reload -> + onVfsOptionsChange(options, reload) + @Suppress("AssignedValueIsNeverRead") + showVfsOptionsDialog = false + }, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showVfsOptionsDialog = false + }, + ) + } +} + +@Composable +private fun allowLockedAccessSummary(allowLockedAccess: Boolean) = if (allowLockedAccess) { + stringResource(R.string.pref_edit_remote_allow_locked_access_desc_on) +} else { + stringResource(R.string.pref_edit_remote_allow_locked_access_desc_off) +} + +@Composable +private fun reportUsageSummary(state: RemoteState) = when (state.features?.about) { + null -> stringResource(R.string.pref_edit_remote_report_usage_desc_loading) + true -> stringResource(R.string.pref_edit_remote_report_usage_desc_supported) + false -> stringResource(R.string.pref_edit_remote_report_usage_desc_unsupported) +} + +@Preview( + name = "Light Mode", + showBackground = true, +) +@Preview( + name = "Dark Mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, +) +@Composable +private fun PreviewEditRemoteScreen() { + val remote = "test" + val state = RemoteState( + config = RcloneRpc.RemoteConfig(), + features = null, + ) + + AppTheme { + AppScreen( + title = { Text(text = remote) }, + onBack = {}, + ) { params -> + EditRemoteContent( + remote = remote, + state = state, + existingRemotes = listOf(remote), + requireAuth = false, + onRemoteOpen = {}, + onRemoteConfigure = {}, + onRemoteRename = {}, + onRemoteDuplicate = {}, + onRemoteDelete = {}, + onAllowExternalAccessChange = {}, + onAllowLockedAccessChange = {}, + onDynamicShortcutChange = {}, + onThumbnailsChange = {}, + onReportUsageChange = {}, + onVfsOptionsChange = { _, _ -> }, + isVfsCacheDirty = { false }, + contentPadding = params.contentPadding, + ) + } + } +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteViewModel.kt b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteViewModel.kt index 2242656..36910e9 100644 --- a/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteViewModel.kt +++ b/app/src/main/java/com/chiller3/rsaf/settings/EditRemoteViewModel.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023-2025 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -16,12 +16,19 @@ import com.chiller3.rsaf.extension.toSingleLineString import com.chiller3.rsaf.rclone.RcloneConfig import com.chiller3.rsaf.rclone.RcloneRpc import com.chiller3.rsaf.rclone.VfsCache +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield data class EditRemoteActivityActions( val refreshRoots: Boolean = false, @@ -32,25 +39,18 @@ data class EditRemoteActivityActions( data class RemoteState( val config: RcloneRpc.RemoteConfig? = null, val features: RbRemoteFeaturesResult? = null, -) { - val allowExternalAccessOrDefault: Boolean? - get() = config?.hardBlockedOrDefault?.let { !it } - val allowLockedAccessOrDefault: Boolean? - get() = config?.softBlockedOrDefault?.let { !it } -} +) class EditRemoteViewModel : ViewModel() { companion object { private val TAG = EditRemoteViewModel::class.java.simpleName } + private val mainLock = this + private val operationLock = Mutex() + private lateinit var _remote: String - var remote: String - get() = _remote - set(value) { - _remote = value - refreshRemotes(false) - } + private var refreshJob: Job? = null private val _remoteConfigs = MutableStateFlow>(emptyMap()) val remoteConfigs = _remoteConfigs.asStateFlow() @@ -64,64 +64,121 @@ class EditRemoteViewModel : ViewModel() { private val _activityActions = MutableStateFlow(EditRemoteActivityActions()) val activityActions = _activityActions.asStateFlow() - private suspend fun refreshRemotesInternal(force: Boolean) { - try { - if (_remoteConfigs.value.isEmpty() || force) { - withContext(Dispatchers.IO) { - _remoteConfigs.update { RcloneRpc.remoteConfigs } + // We intentionally use the same viewmodel instead of creating a new one with unique keys. Old + // viewmodels never get cleaned up until the composable is removed, so memory usage would grow + // indefinitely if the remote was continuously renamed. + fun init(newRemote: String) { + synchronized(mainLock) { + if (!::_remote.isInitialized || _remote != newRemote) { + _remote = newRemote + _remoteState.update { RemoteState() } + + // Refreshes after running. + launchOperation {} + } + } + } + + private fun currentRemote() = synchronized(mainLock) { _remote } + + private fun launchOperation(block: suspend CoroutineScope.(String) -> Unit): Job { + // Synchronously obtain the current remote because it can change while the operation is + // running. + val remote = currentRemote() + + // We always allow refreshes to be cancelled because it is more important that the user's + // operations are processed quickly. We'll refresh again afterwards anyway. + synchronized(mainLock) { + refreshJob?.let { + Log.d(TAG, "Cancelling existing refresh job: $it") + it.cancel() + } + } + + return viewModelScope.launch { + operationLock.withLock { + block(remote) + + val job = launch { + refreshRemotesLocked() + } + + synchronized(mainLock) { + refreshJob = job + } + + try { + job.join() + } finally { + synchronized(mainLock) { + refreshJob = null + } } } + } + } + + private suspend fun refreshRemotesLocked() { + val remote = currentRemote() + + try { + withContext(Dispatchers.IO) { + yield() + _remoteConfigs.update { RcloneRpc.remoteConfigs } + } val config = remoteConfigs.value[remote] if (config != null) { - _remoteState.update { - it.copy(config = config) - } + yield() + _remoteState.update { it.copy(config = config) } // Only calculate this once since the value can't change and it requires // initializing the backend, which may perform network calls. if (_remoteState.value.features == null) { - withContext(Dispatchers.IO) { - _remoteState.update { + // This is the slowest part. We run this in a separate coroutine so that when + // cancelled, the current coroutine can exit quickly. There's no way to + // interrupt rclone during this operation. + val features = viewModelScope.async { + withContext(Dispatchers.IO) { val error = RbError() - val features = Rcbridge.rbRemoteFeatures("$remote:", error) + Rcbridge.rbRemoteFeatures("$remote:", error) ?: throw error.toException("rbRemoteFeatures") - - it.copy(features = features) } - } + }.await() + _remoteState.update { it.copy(features = features) } } } else { // This will happen after renaming or deleting the remote. + yield() _remoteState.update { RemoteState() } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { + yield() Log.e(TAG, "Failed to refresh remotes", e) _alerts.update { it + EditRemoteAlert.ListRemotesFailed(e.toSingleLineString()) } } } - private fun refreshRemotes(@Suppress("SameParameterValue") force: Boolean) { - viewModelScope.launch { - refreshRemotesInternal(force) - } - } - val isVfsCacheDirty: Boolean - get() = VfsCache.hasOngoingUploads(remote) + get() = VfsCache.hasOngoingUploads(currentRemote()) private fun setCustomOpt( - remote: String, config: RcloneRpc.RemoteConfig, + clearVfsCache: Boolean = false, onSuccess: (() -> Unit)? = null, ) { - viewModelScope.launch { + launchOperation { remote -> try { withContext(Dispatchers.IO) { RcloneRpc.setRemoteConfig(remote, config) + + if (clearVfsCache) { + Rcbridge.rbCacheClearRemote("$remote:", false) + } } - refreshRemotesInternal(true) onSuccess?.let { it() } } catch (e: Exception) { Log.w(TAG, "Failed to set $remote config $config", e) @@ -135,43 +192,47 @@ class EditRemoteViewModel : ViewModel() { } fun setExternalAccess(allow: Boolean) { - setCustomOpt(remote, RcloneRpc.RemoteConfig(hardBlocked = !allow)) { + setCustomOpt(RcloneRpc.RemoteConfig(hardBlocked = !allow)) { _activityActions.update { it.copy(refreshRoots = true) } } } fun setLockedAccess(allow: Boolean) { - setCustomOpt(remote, RcloneRpc.RemoteConfig(softBlocked = !allow)) + setCustomOpt(RcloneRpc.RemoteConfig(softBlocked = !allow)) } fun setDynamicShortcut(enabled: Boolean) { - setCustomOpt(remote, RcloneRpc.RemoteConfig(dynamicShortcut = enabled)) { + setCustomOpt(RcloneRpc.RemoteConfig(dynamicShortcut = enabled)) { _activityActions.update { it.copy(refreshRoots = true) } } } fun setThumbnails(enabled: Boolean) { - setCustomOpt(remote, RcloneRpc.RemoteConfig(thumbnails = enabled)) + setCustomOpt(RcloneRpc.RemoteConfig(thumbnails = enabled)) } fun setReportUsage(enabled: Boolean) { - setCustomOpt(remote, RcloneRpc.RemoteConfig(reportUsage = enabled)) { + setCustomOpt(RcloneRpc.RemoteConfig(reportUsage = enabled)) { _activityActions.update { it.copy(refreshRoots = true) } } } + fun setVfsOptions(options: Map, reload: Boolean) { + setCustomOpt(RcloneRpc.RemoteConfig(vfsOptions = options), reload) + } + private fun copyRemote(newRemote: String, delete: Boolean) { - if (remote == newRemote) { - throw IllegalStateException("Old and new remote names are the same") - } + launchOperation { remote -> + if (remote == newRemote) { + throw IllegalStateException("Old and new remote names are the same") + } - val failure = if (delete) { - EditRemoteAlert::RemoteRenameFailed - } else { - EditRemoteAlert::RemoteDuplicateFailed - } + val failure = if (delete) { + EditRemoteAlert::RemoteRenameFailed + } else { + EditRemoteAlert::RemoteDuplicateFailed + } - viewModelScope.launch { try { withContext(Dispatchers.IO) { RcloneConfig.copyRemote(remote, newRemote) @@ -179,12 +240,10 @@ class EditRemoteViewModel : ViewModel() { RcloneRpc.deleteRemote(remote) } } - refreshRemotesInternal(true) _activityActions.update { it.copy( refreshRoots = true, editNewRemote = newRemote, - finish = true, ) } } catch (e: Exception) { @@ -204,12 +263,11 @@ class EditRemoteViewModel : ViewModel() { } fun deleteRemote() { - viewModelScope.launch { + launchOperation { remote -> 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) @@ -224,9 +282,12 @@ class EditRemoteViewModel : ViewModel() { _alerts.update { it.drop(1) } } + fun addAlert(alert: EditRemoteAlert) { + _alerts.update { it + alert } + } + fun interactiveConfigurationCompleted(remote: String) { - viewModelScope.launch { - refreshRemotesInternal(true) + launchOperation { _alerts.update { it + EditRemoteAlert.RemoteEditSucceeded(remote) } } } diff --git a/app/src/main/java/com/chiller3/rsaf/settings/ErrorDetailsDialog.kt b/app/src/main/java/com/chiller3/rsaf/settings/ErrorDetailsDialog.kt new file mode 100644 index 0000000..7082706 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/ErrorDetailsDialog.kt @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2024-2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +@file:OptIn(ExperimentalUnsignedTypes::class) + +package com.chiller3.rsaf.settings + +import android.content.ClipData +import android.content.ClipboardManager +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.DialogProperties +import com.chiller3.rsaf.R + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ErrorDetailsDialog( + message: String?, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + + AlertDialog( + title = { + Text(text = stringResource(R.string.dialog_error_details_title)) + }, + text = { + message?.let { + Text(text = it) + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + message?.let { + TextButton( + onClick = { + val clipboardManager = context.getSystemService(ClipboardManager::class.java) + val clipData = ClipData.newPlainText("message", message) + + clipboardManager.setPrimaryClip(clipData) + }, + ) { + Text(text = stringResource(android.R.string.copy)) + } + } + }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/InactivityTimeoutDialog.kt b/app/src/main/java/com/chiller3/rsaf/settings/InactivityTimeoutDialog.kt new file mode 100644 index 0000000..8168bce --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/InactivityTimeoutDialog.kt @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2024-2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.then +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.core.text.isDigitsOnly +import com.chiller3.rsaf.Preferences +import com.chiller3.rsaf.R + +@Composable +fun InactivityTimeoutDialog( + initialTimeout: Int, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val input = rememberTextFieldState(initialText = initialTimeout.toString()) + val timeout = tryParseInput(input.text.toString()) + + AlertDialog( + title = { Text(text = stringResource(R.string.dialog_inactivity_timeout_title)) }, + text = { + Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) { + Text(text = stringResource(R.string.dialog_inactivity_timeout_message)) + + OutlinedTextField( + state = input, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + inputTransformation = InputTransformation.then { + if (!asCharSequence().isDigitsOnly()) { + revertAllChanges() + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + lineLimits = TextFieldLineLimits.SingleLine, + ) + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { onSelect(timeout!!) }, + enabled = timeout != null, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) +} + +@Composable +private fun tryParseInput(input: String): Int? { + try { + val seconds = input.toInt() + if (seconds >= Preferences.MIN_INACTIVITY_TIMEOUT) { + return seconds + } + } catch (_: NumberFormatException) { + // Ignore. + } + + return null +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/InteractiveConfigurationActivity.kt b/app/src/main/java/com/chiller3/rsaf/settings/InteractiveConfigurationActivity.kt new file mode 100644 index 0000000..fc8c4b0 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/InteractiveConfigurationActivity.kt @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.compose.runtime.Composable +import com.chiller3.rsaf.BaseActivity +import com.chiller3.rsaf.ui.theme.AppTheme + +class InteractiveConfigurationActivity : BaseActivity() { + companion object { + const val EXTRA_REMOTE = "remote" + const val EXTRA_NEW = "new" + + fun createIntent(context: Context, remote: String, new: Boolean) = + Intent(context, InteractiveConfigurationActivity::class.java).apply { + putExtra(EXTRA_REMOTE, remote) + putExtra(EXTRA_NEW, new) + } + } + + private val remote by lazy { intent.getStringExtra(EXTRA_REMOTE)!! } + private val new by lazy { intent.getBooleanExtra(EXTRA_NEW, false) } + + private val resultData by lazy { + Intent().apply { + putExtra(EXTRA_REMOTE, remote) + putExtra(EXTRA_NEW, new) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setResult(RESULT_CANCELED, resultData) + } + + @Composable + override fun ActivityContent() { + AppTheme { + InteractiveConfigurationScreen( + remote = remote, + new = new, + onComplete = { + setResult(RESULT_OK, resultData) + finish() + }, + onCancel = ::finish, + ) + } + } +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/InteractiveConfigurationScreen.kt b/app/src/main/java/com/chiller3/rsaf/settings/InteractiveConfigurationScreen.kt new file mode 100644 index 0000000..32e4c39 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/InteractiveConfigurationScreen.kt @@ -0,0 +1,608 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.annotation.SuppressLint +import android.content.res.Configuration +import android.system.ErrnoException +import android.text.SpannableString +import android.text.style.URLSpan +import android.text.util.Linkify +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.chiller3.rsaf.R +import com.chiller3.rsaf.rclone.RcloneConfig +import com.chiller3.rsaf.rclone.RcloneRpc +import com.chiller3.rsaf.ui.AppScreen +import com.chiller3.rsaf.ui.PreferenceCategory +import com.chiller3.rsaf.ui.PreferenceColumn +import com.chiller3.rsaf.ui.PreferenceDefaults +import com.chiller3.rsaf.ui.RadioPreference +import com.chiller3.rsaf.ui.betterSegmentedShapes +import com.chiller3.rsaf.ui.theme.AppTheme +import com.chiller3.rsaf.ui.theme.Icons +import org.json.JSONArray +import org.json.JSONObject + +private const val TAG = "InteractiveConfigurationScreen" + +@Composable +fun InteractiveConfigurationScreen( + remote: String, + new: Boolean, + onComplete: () -> Unit, + onCancel: () -> Unit, + viewModel: InteractiveConfigurationViewModel = viewModel(), +) { + viewModel.init(remote) + + val question by viewModel.question.collectAsStateWithLifecycle() + val hasPrevious by viewModel.hasPrevious.collectAsStateWithLifecycle() + + val title = if (new) { + stringResource(R.string.ic_title_add_remote, remote) + } else { + stringResource(R.string.ic_title_edit_remote, remote) + } + + AppScreen( + title = { Text(text = title) }, + onBack = onCancel, + backIsExit = true, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime) + ) { params -> + question?.let { (error, option) -> + InteractiveConfigurationContent( + error = error, + option = option, + hasPrevious = hasPrevious, + onPrevQuestion = { + viewModel.goBack() + }, + onNextQuestion = { answer -> + viewModel.submit(answer) + }, + contentPadding = params.contentPadding, + ) + } + } + + BackHandler( + enabled = hasPrevious, + onBack = { viewModel.goBack() }, + ) + + LaunchedEffect(Unit) { + viewModel.run.collect { + if (!it) { + // No more questions. We can just exit because changes are immediately + // committed upon submission. + onComplete() + } + } + } +} + +private fun parseAnswer(option: RcloneRpc.ProviderOption, input: String): String? = + if (option.exclusive && !option.examples.any { it.value == input }) { + null + } else if (option.required && input.isEmpty()) { + null + } else { + input + } + +private fun annotateLinks(msg: String): AnnotatedString { + val spanned = SpannableString(msg) + if (!Linkify.addLinks(spanned, Linkify.WEB_URLS)) { + return AnnotatedString(msg) + } + + val origAnnotations = spanned.getSpans(0, spanned.length, URLSpan::class.java) + val newAnnotations = mutableListOf>() + + for (annotation in origAnnotations) { + val start = spanned.getSpanStart(annotation) + val end = spanned.getSpanEnd(annotation) + + newAnnotations.add( + AnnotatedString.Range( + LinkAnnotation.Url(annotation.url), + start, + end, + ) + ) + } + + return AnnotatedString(msg, newAnnotations) +} + +/** Replace newlines with spaces unless there are multiple newlines in a row. */ +private fun reflowString(msg: String): String = + msg.replace("([^\\n])\\n([^\\n]|$)".toRegex(), "$1 $2") + +private fun tryReveal(text: String, isPassword: Boolean): String { + var value = text + + if (isPassword) { + try { + value = RcloneConfig.revealPassword(text).value + } catch (e: ErrnoException) { + Log.w(TAG, "Failed to reveal password", e) + } + } + + return value +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@SuppressLint("PrivateResource") +@Composable +private fun InteractiveConfigurationContent( + error: String?, + option: RcloneRpc.ProviderOption, + hasPrevious: Boolean, + onPrevQuestion: () -> Unit, + onNextQuestion: (String) -> Unit, + contentPadding: PaddingValues = PaddingValues(), +) { + val isPreview = LocalInspectionMode.current + + val input = rememberTextFieldState() + + // When going back to the previous question, we need to discard state and reload the option, + // which may have a different value now. + var epoch by rememberSaveable { mutableIntStateOf(0) } + val currentOrDefault = remember(epoch, option.name) { + if (option.value.isNotEmpty()) { + tryReveal(option.value, option.isPassword && !isPreview) + } else { + tryReveal(option.default, option.isPassword && !isPreview) + } + } + var loadedOnce by rememberSaveable(epoch, option.name) { mutableStateOf(false) } + if (!loadedOnce) { + LaunchedEffect(epoch, option.name) { + input.edit { + replace(0, length, currentOrDefault) + } + @Suppress("AssignedValueIsNeverRead") + loadedOnce = true + } + } + + val answer = parseAnswer(option, input.text.toString()) + + var showAuthorizeDialog by rememberSaveable { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding + PreferenceDefaults.ListPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val maxWidthModifier = Modifier + .widthIn(max = PreferenceDefaults.MaxWidth) + .fillMaxWidth() + + PreferenceColumn( + modifier = Modifier.fillMaxSize().weight(weight = 1f), + contentPadding = PaddingValues(bottom = PreferenceDefaults.HorizontalPadding), + fillScreen = false, + ) { + item("message") { + val message = buildAnnotatedString { + if (error != null) { + append(reflowString(error)) + append("\n\n") + } + append(annotateLinks(reflowString(option.help))) + } + + SelectionContainer(modifier = maxWidthModifier) { + Text(text = message) + } + } + + if (!option.exclusive) { + item("input") { + AnswerTextField( + option = option, + state = input, + answer = answer, + modifier = maxWidthModifier.padding(top = 8.dp) + ) + } + } + + if (option.isAuthorize) { + item("authorize") { + Button( + onClick = { showAuthorizeDialog = option.authorizeCmd }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text(text = stringResource(R.string.dialog_action_authorize)) + } + } + } + + if (option.examples.isNotEmpty()) { + item("examples_divider") { + HorizontalDivider( + modifier = maxWidthModifier + .padding(vertical = PreferenceDefaults.HorizontalPadding), + ) + } + + if (!option.exclusive) { + item("examples_header") { + PreferenceCategory( + title = { Text(text = stringResource(R.string.ic_header_examples)) }, + ) + } + } + + itemsIndexed( + option.examples, + key = { _, e -> "example_${e.value}" }, + ) { index, example -> + val isSelected = answer == example.value + val onClick = { input.edit { replace(0, length, example.value) } } + + RadioPreference( + selected = isSelected, + onClick = onClick, + shapes = betterSegmentedShapes(index, option.examples.size), + title = { Text(text = example.value) }, + summary = if (example.help != example.value) { + { Text(text = example.help) } + } else { + null + }, + ) + } + } + } + + HorizontalDivider(modifier = maxWidthModifier) + + NavigationButtons( + hasPrevious = hasPrevious, + answer = answer, + onPrevQuestion = { + epoch++ + onPrevQuestion() + }, + onNextQuestion = onNextQuestion, + modifier = maxWidthModifier, + ) + } + + showAuthorizeDialog?.let { cmd -> + AuthorizeDialog( + cmd = cmd, + onReceive = { + input.edit { replace(0, length, it) } + @Suppress("AssignedValueIsNeverRead") + showAuthorizeDialog = null + }, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showAuthorizeDialog = null + }, + ) + } +} + +@Composable +private fun AnswerTextField( + option: RcloneRpc.ProviderOption, + state: TextFieldState, + answer: String?, + modifier: Modifier = Modifier, +) { + var showPassword by rememberSaveable { mutableStateOf(false) } + + val supportingText = if (option.required) { + stringResource(R.string.ic_text_box_helper_required) + } else { + stringResource(R.string.ic_text_box_helper_not_required) + } + + if (option.isPassword) { + OutlinedSecureTextField( + state = state, + modifier = modifier, + placeholder = { Text(text = option.name) }, + isError = answer == null, + supportingText = { Text(text = supportingText) }, + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + @SuppressLint("PrivateResource") + Icon( + imageVector = if (showPassword) { + Icons.VisibilityOff + } else { + Icons.Visibility + }, + contentDescription = stringResource( + com.google.android.material.R.string.password_toggle_content_description, + ), + ) + } + }, + textObfuscationMode = if (showPassword) { + TextObfuscationMode.Visible + } else { + TextObfuscationMode.RevealLastTyped + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + } else { + OutlinedTextField( + state = state, + modifier = modifier, + placeholder = { Text(text = option.name) }, + isError = answer == null, + supportingText = { Text(text = supportingText) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + ), + lineLimits = TextFieldLineLimits.SingleLine, + ) + } +} + +@Composable +private fun NavigationButtons( + hasPrevious: Boolean, + answer: String?, + onPrevQuestion: () -> Unit, + onNextQuestion: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxWidth()) { + OutlinedButton( + onClick = onPrevQuestion, + modifier = Modifier + .padding(all = 8.dp) + .align(alignment = Alignment.CenterStart), + enabled = hasPrevious, + ) { + Text(text = stringResource(R.string.dialog_action_back)) + } + + Button( + onClick = { onNextQuestion(answer!!) }, + modifier = Modifier + .padding(all = 8.dp) + .align(alignment = Alignment.CenterEnd), + enabled = answer != null, + ) { + Text(text = stringResource(R.string.dialog_action_next)) + } + } +} + +@Preview( + name = "Light Mode", + showBackground = true, +) +@Preview( + name = "Dark Mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, +) +@Composable +private fun PreviewQuestionArbitrary() { + val remote = "test" + val option = RcloneRpc.ProviderOption( + JSONObject() + .put("Name", "test") + .put("FieldName", "") + .put("Help", "This is an open ended question with a https://localhost link.") + .put("DefaultStr", "") + .put("ValueStr", "") + .put("Examples", JSONArray().apply { + put( + JSONObject() + .put("Value", "a") + .put("Help", "First option") + .put("Provider", "") + ) + put( + JSONObject() + .put("Value", "b") + .put("Help", "Second option") + .put("Provider", "") + ) + }) + .put("Hide", 0) + .put("Required", true) + .put("IsPassword", false) + .put("NoPrefix", false) + .put("Advanced", false) + .put("Exclusive", false) + .put("Sensitive", false) + .put("Type", "string") + ) + + AppTheme { + AppScreen( + title = { Text(text = stringResource(R.string.ic_title_add_remote, remote)) }, + onBack = {}, + backIsExit = true, + ) { params -> + InteractiveConfigurationContent( + error = null, + option = option, + hasPrevious = true, + onPrevQuestion = {}, + onNextQuestion = {}, + contentPadding = params.contentPadding, + ) + } + } +} + +@Preview( + name = "Light Mode", + showBackground = true, +) +@Preview( + name = "Dark Mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, +) +@Composable +private fun PreviewQuestionExclusive() { + val remote = "test" + val option = RcloneRpc.ProviderOption( + JSONObject() + .put("Name", "test") + .put("FieldName", "") + .put("Help", "This is a question with fixed choices.") + .put("DefaultStr", "b") + .put("ValueStr", "") + .put("Examples", JSONArray().apply { + put( + JSONObject() + .put("Value", "a") + .put("Help", "First option") + .put("Provider", "") + ) + put( + JSONObject() + .put("Value", "b") + .put("Help", "Second option") + .put("Provider", "") + ) + }) + .put("Hide", 0) + .put("Required", true) + .put("IsPassword", false) + .put("NoPrefix", false) + .put("Advanced", false) + .put("Exclusive", true) + .put("Sensitive", false) + .put("Type", "string") + ) + + AppTheme { + AppScreen( + title = { Text(text = stringResource(R.string.ic_title_add_remote, remote)) }, + onBack = {}, + backIsExit = true, + ) { params -> + InteractiveConfigurationContent( + error = null, + option = option, + hasPrevious = false, + onPrevQuestion = {}, + onNextQuestion = {}, + contentPadding = params.contentPadding, + ) + } + } +} + +@Preview( + name = "Light Mode", + showBackground = true, +) +@Preview( + name = "Dark Mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, +) +@Composable +private fun PreviewQuestionPassword() { + val remote = "test" + val option = RcloneRpc.ProviderOption( + JSONObject() + .put("Name", "test") + .put("FieldName", "") + .put("Help", "This is a question for a password.") + .put("DefaultStr", "hunter2") + .put("ValueStr", "") + .put("Examples", JSONArray()) + .put("Hide", 0) + .put("Required", true) + .put("IsPassword", true) + .put("NoPrefix", false) + .put("Advanced", false) + .put("Exclusive", false) + .put("Sensitive", false) + .put("Type", "string") + ) + + AppTheme { + AppScreen( + title = { Text(text = stringResource(R.string.ic_title_edit_remote, remote)) }, + onBack = {}, + backIsExit = true, + ) { params -> + InteractiveConfigurationContent( + error = "This is an error message from rclone.", + option = option, + hasPrevious = true, + onPrevQuestion = {}, + onNextQuestion = {}, + contentPadding = params.contentPadding, + ) + } + } +} diff --git a/app/src/main/java/com/chiller3/rsaf/dialog/InteractiveConfigurationViewModel.kt b/app/src/main/java/com/chiller3/rsaf/settings/InteractiveConfigurationViewModel.kt similarity index 91% rename from app/src/main/java/com/chiller3/rsaf/dialog/InteractiveConfigurationViewModel.kt rename to app/src/main/java/com/chiller3/rsaf/settings/InteractiveConfigurationViewModel.kt index 697684a..a0e6b62 100644 --- a/app/src/main/java/com/chiller3/rsaf/dialog/InteractiveConfigurationViewModel.kt +++ b/app/src/main/java/com/chiller3/rsaf/settings/InteractiveConfigurationViewModel.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: GPL-3.0-only */ -package com.chiller3.rsaf.dialog +package com.chiller3.rsaf.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -16,6 +16,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class InteractiveConfigurationViewModel : ViewModel() { + private var loadedOnce = false + private lateinit var ic: RcloneRpc.InteractiveConfiguration private val _question = MutableStateFlow?>(null) @@ -28,6 +30,11 @@ class InteractiveConfigurationViewModel : ViewModel() { val run = _run.asStateFlow() fun init(remote: String) { + if (loadedOnce) { + return + } + loadedOnce = true + viewModelScope.launch { ic = withContext(Dispatchers.IO) { RcloneRpc.InteractiveConfiguration(remote) diff --git a/app/src/main/java/com/chiller3/rsaf/settings/PasswordDialog.kt b/app/src/main/java/com/chiller3/rsaf/settings/PasswordDialog.kt new file mode 100644 index 0000000..76e62e3 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/PasswordDialog.kt @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.chiller3.rsaf.R +import com.chiller3.rsaf.ui.theme.Icons + +@Composable +fun PasswordDialog( + mode: ImportExportMode, + onSelect: (String) -> Unit, + onDismiss: () -> Unit, +) { + val input = rememberTextFieldState() + val inputConfirm = rememberTextFieldState() + + AlertDialog( + title = { Text(text = modeTitle(mode)) }, + text = { + Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) { + Text(text = modeMessage(mode)) + + TogglablePasswordField( + state = input, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + placeholder = { Text(text = modeHint(mode)) }, + ) + + if (mode == ImportExportMode.EXPORT) { + TogglablePasswordField( + state = inputConfirm, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + placeholder = { + Text(text = stringResource(R.string.dialog_export_password_confirm_hint)) + }, + isError = inputConfirm.text != input.text, + ) + } + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { onSelect(input.text.toString()) }, + enabled = when (mode) { + ImportExportMode.IMPORT -> true + ImportExportMode.EXPORT -> inputConfirm.text == input.text + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) +} + +@Composable +private fun TogglablePasswordField( + state: TextFieldState, + modifier: Modifier = Modifier, + isError: Boolean = false, + placeholder: @Composable (() -> Unit)? = null, +) { + var showPassword by rememberSaveable { mutableStateOf(false) } + + OutlinedSecureTextField( + state = state, + modifier = modifier, + placeholder = placeholder, + isError = isError, + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + @SuppressLint("PrivateResource") + Icon( + imageVector = if (showPassword) { + Icons.VisibilityOff + } else { + Icons.Visibility + }, + contentDescription = stringResource( + com.google.android.material.R.string.password_toggle_content_description, + ), + ) + } + }, + textObfuscationMode = if (showPassword) { + TextObfuscationMode.Visible + } else { + TextObfuscationMode.RevealLastTyped + }, + ) +} + +@Composable +private fun modeTitle(mode: ImportExportMode) = when (mode) { + ImportExportMode.IMPORT -> stringResource(R.string.dialog_import_password_title) + ImportExportMode.EXPORT -> stringResource(R.string.dialog_export_password_title) +} + +@Composable +private fun modeMessage(mode: ImportExportMode) = when (mode) { + ImportExportMode.IMPORT -> stringResource(R.string.dialog_import_password_message) + ImportExportMode.EXPORT -> stringResource(R.string.dialog_export_password_message) +} + +@Composable +private fun modeHint(mode: ImportExportMode) = when (mode) { + ImportExportMode.IMPORT -> stringResource(R.string.dialog_import_password_hint) + ImportExportMode.EXPORT -> stringResource(R.string.dialog_export_password_hint) +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/RemoteNameDialog.kt b/app/src/main/java/com/chiller3/rsaf/settings/RemoteNameDialog.kt new file mode 100644 index 0000000..099f260 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/RemoteNameDialog.kt @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.content.res.Resources +import android.os.Parcelable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.chiller3.rsaf.R +import com.chiller3.rsaf.rclone.RcloneConfig +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed interface RemoteNameDialogAction : Parcelable { + fun getTitle(resources: Resources): String + + @Parcelize + data object Add : RemoteNameDialogAction { + override fun getTitle(resources: Resources): String = + resources.getString(R.string.dialog_add_remote_title) + } + + @Parcelize + data class Rename(val remote: String) : RemoteNameDialogAction { + override fun getTitle(resources: Resources): String = + resources.getString(R.string.dialog_rename_remote_title, remote) + } + + @Parcelize + data class Duplicate(val remote: String) : RemoteNameDialogAction { + override fun getTitle(resources: Resources): String = + resources.getString(R.string.dialog_duplicate_remote_title, remote) + } +} + +@Composable +fun RemoteNameDialog( + action: RemoteNameDialogAction, + existingRemotes: List, + onSelect: (String) -> Unit, + onDismiss: () -> Unit, +) { + val resources = LocalResources.current + + val input = rememberTextFieldState() + val name = tryParseInput(input.text.toString(), existingRemotes) + + AlertDialog( + title = { Text(text = action.getTitle(resources)) }, + text = { + Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) { + Text(text = stringResource(R.string.dialog_remote_name_message)) + + OutlinedTextField( + state = input, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + placeholder = { Text(text = stringResource(R.string.dialog_remote_name_hint)) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + ), + lineLimits = TextFieldLineLimits.SingleLine, + ) + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { onSelect(name!!) }, + enabled = name != null, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) +} + +@Composable +private fun tryParseInput(input: String, existingRemotes: List): String? { + try { + RcloneConfig.checkName(input) + if (input !in existingRemotes) { + return input + } + } catch (_: Exception) { + // Ignore. + } + + return null +} 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 69f19c0..4639fcd 100644 --- a/app/src/main/java/com/chiller3/rsaf/settings/SettingsActivity.kt +++ b/app/src/main/java/com/chiller3/rsaf/settings/SettingsActivity.kt @@ -1,17 +1,33 @@ /* - * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ package com.chiller3.rsaf.settings -import com.chiller3.rsaf.PreferenceBaseActivity -import com.chiller3.rsaf.PreferenceBaseFragment +import android.os.Bundle +import androidx.compose.runtime.Composable +import com.chiller3.rsaf.AppLock +import com.chiller3.rsaf.BaseActivity +import com.chiller3.rsaf.rclone.KeepAliveService +import com.chiller3.rsaf.ui.theme.AppTheme -class SettingsActivity : PreferenceBaseActivity() { - override val actionBarTitle: CharSequence? = null +class SettingsActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - override val showUpButton: Boolean = false + KeepAliveService.startWithScanOnce(this) + } - override fun createFragment(): PreferenceBaseFragment = SettingsFragment() + @Composable + override fun ActivityContent() { + AppTheme { + SettingsScreen( + onLockNow = { + AppLock.onLock() + finishAndRemoveTask() + }, + ) + } + } } diff --git a/app/src/main/java/com/chiller3/rsaf/settings/SettingsAlert.kt b/app/src/main/java/com/chiller3/rsaf/settings/SettingsAlert.kt index 289e4be..6d3efa7 100644 --- a/app/src/main/java/com/chiller3/rsaf/settings/SettingsAlert.kt +++ b/app/src/main/java/com/chiller3/rsaf/settings/SettingsAlert.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -29,4 +29,6 @@ sealed interface SettingsAlert { data class LogcatSucceeded(val uri: Uri) : SettingsAlert data class LogcatFailed(val uri: Uri, val error: String) : SettingsAlert + + data object BrowserNotFound : 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 deleted file mode 100644 index 55643e2..0000000 --- a/app/src/main/java/com/chiller3/rsaf/settings/SettingsFragment.kt +++ /dev/null @@ -1,535 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2025 Andrew Gunnerson - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.chiller3.rsaf.settings - -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.os.Environment -import android.provider.DocumentsContract -import android.provider.Settings -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.net.toUri -import androidx.fragment.app.FragmentResultListener -import androidx.fragment.app.clearFragmentResult -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.preference.Preference -import androidx.preference.PreferenceCategory -import androidx.preference.SwitchPreferenceCompat -import androidx.preference.get -import androidx.preference.size -import com.chiller3.rsaf.AppLock -import com.chiller3.rsaf.BuildConfig -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.binding.rcbridge.Rcbridge -import com.chiller3.rsaf.dialog.InactivityTimeoutDialogFragment -import com.chiller3.rsaf.dialog.InteractiveConfigurationDialogFragment -import com.chiller3.rsaf.dialog.MessageDialogFragment -import com.chiller3.rsaf.dialog.PasswordDialogFragment -import com.chiller3.rsaf.dialog.RemoteNameDialogAction -import com.chiller3.rsaf.dialog.RemoteNameDialogFragment -import com.chiller3.rsaf.dialog.VfsCacheDeletionDialogFragment -import com.chiller3.rsaf.extension.formattedString -import com.chiller3.rsaf.rclone.KeepAliveService -import com.chiller3.rsaf.rclone.RcloneConfig -import com.chiller3.rsaf.rclone.RcloneProvider -import com.chiller3.rsaf.view.LongClickablePreference -import com.chiller3.rsaf.view.OnPreferenceLongClickListener -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch - -class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener, - Preference.OnPreferenceClickListener, 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_IMPORT_EXPORT_PASSWORD = "$TAG.import_export_password" - - private val TAG_IMPORT_CONFIRM = "$TAG.import_confirm" - - fun documentsUiIntent(remote: String): Intent = - Intent(Intent.ACTION_VIEW).apply { - val uri = DocumentsContract.buildRootUri( - BuildConfig.DOCUMENTS_AUTHORITY, remote) - setDataAndType(uri, DocumentsContract.Root.MIME_TYPE_ITEM) - } - } - - 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 - private lateinit var categoryDebug: PreferenceCategory - private lateinit var prefInhibitBatteryOpt: Preference - private lateinit var prefMissingNotifications: Preference - private lateinit var prefAddRemote: Preference - private lateinit var prefLocalStorageAccess: SwitchPreferenceCompat - private lateinit var prefImportConfiguration: Preference - private lateinit var prefExportConfiguration: Preference - private lateinit var prefInactivityTimeout: Preference - private lateinit var prefLockNow: Preference - private lateinit var prefVersion: LongClickablePreference - private lateinit var prefSaveLogs: Preference - private lateinit var prefAddInternalCacheRemote: Preference - - private val requestEditRemote = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - it.data?.extras?.getString(EditRemoteActivity.RESULT_NEW_REMOTE)?.let { newRemote -> - editRemote(newRemote) - } - viewModel.remoteEdited() - } - private val requestInhibitBatteryOpt = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - refreshPermissions() - } - private val requestPermissionRequired = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted -> - if (granted.all { it.value }) { - refreshPermissions() - } else { - startActivity(Permissions.getAppInfoIntent(requireContext())) - } - } - private val requestSafImportConfiguration = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - uri?.let { - viewModel.startImportExport(ImportExportMode.IMPORT, it) - } - } - private val requestSafExportConfiguration = - registerForActivityResult(ActivityResultContracts.CreateDocument(RcloneConfig.MIMETYPE)) { uri -> - uri?.let { - viewModel.startImportExport(ImportExportMode.EXPORT, it) - } - } - private val requestSafSaveLogs = - registerForActivityResult(ActivityResultContracts.CreateDocument(Logcat.MIMETYPE)) { uri -> - uri?.let { - viewModel.saveLogs(it) - } - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.preferences_root, rootKey) - - val context = requireContext() - - KeepAliveService.startWithScanOnce(context) - - prefs = Preferences(context) - - categoryPermissions = findPreference(Preferences.CATEGORY_PERMISSIONS)!! - categoryRemotes = findPreference(Preferences.CATEGORY_REMOTES)!! - categoryConfiguration = findPreference(Preferences.CATEGORY_CONFIGURATION)!! - categoryDebug = findPreference(Preferences.CATEGORY_DEBUG)!! - - prefInhibitBatteryOpt = findPreference(Preferences.PREF_INHIBIT_BATTERY_OPT)!! - prefInhibitBatteryOpt.onPreferenceClickListener = this - - prefMissingNotifications = findPreference(Preferences.PREF_MISSING_NOTIFICATIONS)!! - prefMissingNotifications.onPreferenceClickListener = this - - prefAddRemote = findPreference(Preferences.PREF_ADD_REMOTE)!! - prefAddRemote.onPreferenceClickListener = this - - prefLocalStorageAccess = findPreference(Preferences.PREF_LOCAL_STORAGE_ACCESS)!! - prefLocalStorageAccess.onPreferenceChangeListener = this - - prefImportConfiguration = findPreference(Preferences.PREF_IMPORT_CONFIGURATION)!! - prefImportConfiguration.onPreferenceClickListener = this - - prefExportConfiguration = findPreference(Preferences.PREF_EXPORT_CONFIGURATION)!! - prefExportConfiguration.onPreferenceClickListener = this - - prefInactivityTimeout = findPreference(Preferences.PREF_INACTIVITY_TIMEOUT)!! - prefInactivityTimeout.onPreferenceClickListener = this - - prefLockNow = findPreference(Preferences.PREF_LOCK_NOW)!! - prefLockNow.onPreferenceClickListener = this - - prefVersion = findPreference(Preferences.PREF_VERSION)!! - prefVersion.onPreferenceClickListener = this - prefVersion.onPreferenceLongClickListener = this - - prefSaveLogs = findPreference(Preferences.PREF_SAVE_LOGS)!! - prefSaveLogs.onPreferenceClickListener = this - - prefAddInternalCacheRemote = findPreference(Preferences.PREF_ADD_INTERNAL_CACHE_REMOTE)!! - prefAddInternalCacheRemote.onPreferenceClickListener = this - - // Call this once first to avoid UI jank from elements shifting. We call it again in - // onResume() because allowing the permissions does not restart the activity. - refreshPermissions() - - refreshInactivityTimeout() - refreshVersion() - refreshDebugPrefs() - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.alerts.collect { - it.firstOrNull()?.let { alert -> - onAlert(alert) - } - } - } - } - - lifecycleScope.launch { - // We don't need the lifecycle to be STARTED. This way, only the (slow) initial load - // will animate in the list of remotes. The items will show up instantaneously for - // configuration changes. - repeatOnLifecycle(Lifecycle.State.CREATED) { - viewModel.remotes.collect { remotes -> - for (i in (0 until categoryRemotes.size).reversed()) { - val p = categoryRemotes[i] - - if (p.key.startsWith(Preferences.PREF_EDIT_REMOTE_PREFIX)) { - categoryRemotes.removePreference(p) - } - } - - for (remote in remotes) { - // Silently ignore remote types that are no longer supported - if (remote.provider == null) { - continue - } - - val p = Preference(context).apply { - key = Preferences.PREF_EDIT_REMOTE_PREFIX + remote.name - isPersistent = false - title = remote.name - summary = remote.provider.description - isIconSpaceReserved = false - onPreferenceClickListener = this@SettingsFragment - } - categoryRemotes.addPreference(p) - } - } - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.importExportState.collect { state -> - setConfigRelatedPreferencesEnabled(state == null) - - if (state == null) { - return@collect - } - - if (state.status == ImportExportState.Status.NEED_PASSWORD && - parentFragmentManager.findFragmentByTag( - TAG_IMPORT_EXPORT_PASSWORD) == null) { - PasswordDialogFragment.newInstance(context, state.mode) - .show(parentFragmentManager.beginTransaction(), - TAG_IMPORT_EXPORT_PASSWORD) - } - } - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.activityActions.collect { - if (it.refreshRoots) { - RcloneProvider.notifyRootsChanged(requireContext()) - } - viewModel.activityActionCompleted() - } - } - } - - for (key in arrayOf( - TAG_ADD_REMOTE_NAME, - TAG_IMPORT_EXPORT_PASSWORD, - TAG_IMPORT_CONFIRM, - InteractiveConfigurationDialogFragment.TAG, - InactivityTimeoutDialogFragment.TAG, - )) { - parentFragmentManager.setFragmentResultListener(key, this, this) - } - } - - override fun onResume() { - super.onResume() - - refreshPermissions() - } - - private fun refreshPermissions() { - val context = requireContext() - - val allowedInhibitBatteryOpt = Permissions.isInhibitingBatteryOpt(context) - prefInhibitBatteryOpt.isVisible = !allowedInhibitBatteryOpt - - val allowedNotifications = Permissions.have(context, Permissions.NOTIFICATION) - prefMissingNotifications.isVisible = !allowedNotifications - - categoryPermissions.isVisible = !(allowedInhibitBatteryOpt && allowedNotifications) - - prefLocalStorageAccess.isChecked = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Environment.isExternalStorageManager() - } else { - Permissions.have(context, Permissions.LEGACY_STORAGE) - } - } - - private fun refreshInactivityTimeout() { - prefInactivityTimeout.summary = requireContext().resources.getQuantityString( - R.plurals.pref_inactivity_timeout_desc, - prefs.inactivityTimeout, - prefs.inactivityTimeout, - ) - } - - private fun refreshVersion() { - prefVersion.summary = buildString { - append(BuildConfig.VERSION_NAME) - - append(" (") - append(BuildConfig.BUILD_TYPE) - if (prefs.isDebugMode) { - append("+debugmode") - } - append(")\nrclone ") - - append(Rcbridge.rbVersion()) - } - } - - private fun refreshDebugPrefs() { - categoryDebug.isVisible = prefs.isDebugMode - } - - private fun setConfigRelatedPreferencesEnabled(enabled: Boolean) { - categoryRemotes.isEnabled = enabled - categoryConfiguration.isEnabled = enabled - } - - override fun onFragmentResult(requestKey: String, bundle: Bundle) { - clearFragmentResult(requestKey) - - when (requestKey) { - TAG_ADD_REMOTE_NAME -> { - if (bundle.getBoolean(RemoteNameDialogFragment.RESULT_SUCCESS)) { - val remote = bundle.getString(RemoteNameDialogFragment.RESULT_NAME)!! - - InteractiveConfigurationDialogFragment.newInstance(remote, true) - .show(parentFragmentManager.beginTransaction(), - InteractiveConfigurationDialogFragment.TAG) - } - } - TAG_IMPORT_EXPORT_PASSWORD -> { - if (bundle.getBoolean(PasswordDialogFragment.RESULT_SUCCESS)) { - val password = bundle.getString(PasswordDialogFragment.RESULT_PASSWORD)!! - viewModel.setImportExportPassword(RcloneConfig.Password(password)) - } else { - viewModel.cancelPendingImportExport() - } - } - TAG_IMPORT_CONFIRM -> { - if (bundle.getBoolean(VfsCacheDeletionDialogFragment.RESULT_SUCCESS)) { - confirmImport(true) - } - } - InteractiveConfigurationDialogFragment.TAG -> { - viewModel.interactiveConfigurationCompleted( - bundle.getString(InteractiveConfigurationDialogFragment.RESULT_REMOTE)!!, - bundle.getBoolean(InteractiveConfigurationDialogFragment.RESULT_CANCELLED), - ) - } - InactivityTimeoutDialogFragment.TAG -> { - refreshInactivityTimeout() - } - } - } - - override fun onPreferenceClick(preference: Preference): Boolean { - when { - preference === prefInhibitBatteryOpt -> { - requestInhibitBatteryOpt.launch( - Permissions.getInhibitBatteryOptIntent(requireContext())) - return true - } - preference === prefMissingNotifications -> { - requestPermissionRequired.launch(Permissions.NOTIFICATION) - return true - } - preference === prefAddRemote -> { - 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) - editRemote(remote) - return true - } - preference === prefImportConfiguration -> { - confirmImport(false) - return true - } - preference === prefExportConfiguration -> { - requestSafExportConfiguration.launch(RcloneConfig.FILENAME) - return true - } - preference === prefInactivityTimeout -> { - InactivityTimeoutDialogFragment().show( - parentFragmentManager.beginTransaction(), - InactivityTimeoutDialogFragment.TAG, - ) - return true - } - preference === prefLockNow -> { - AppLock.onLock() - requireActivity().finishAndRemoveTask() - return true - } - preference === prefVersion -> { - val uri = BuildConfig.PROJECT_URL_AT_COMMIT.toUri() - startActivity(Intent(Intent.ACTION_VIEW, uri)) - return true - } - preference === prefSaveLogs -> { - requestSafSaveLogs.launch(Logcat.FILENAME_DEFAULT) - return true - } - preference === prefAddInternalCacheRemote -> { - viewModel.addInternalCacheRemote() - return true - } - } - - return false - } - - override fun onPreferenceLongClick(preference: Preference): Boolean { - when (preference) { - prefVersion -> { - prefs.isDebugMode = !prefs.isDebugMode - refreshVersion() - refreshDebugPrefs() - return true - } - } - - return false - } - - override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean { - when (preference) { - prefLocalStorageAccess -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val intent = Intent( - Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, - "package:${BuildConfig.APPLICATION_ID}".toUri(), - ) - - startActivity(intent) - } else if (newValue == true) { - requestPermissionRequired.launch(Permissions.LEGACY_STORAGE) - } else { - startActivity(Permissions.getAppInfoIntent(requireContext())) - } - - // We rely on onPause() to adjust the switch state when the user comes back from the - // settings app. - return false - } - } - - return false - } - - private fun onAlert(alert: SettingsAlert) { - val msg = when (alert) { - is SettingsAlert.ListRemotesFailed -> getString(R.string.alert_list_remotes_failure) - 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) - is SettingsAlert.ExportFailed -> getString(R.string.alert_export_failure) - 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) - } - - val details = when (alert) { - is SettingsAlert.ListRemotesFailed -> alert.error - is SettingsAlert.RemoteAddSucceeded -> null - is SettingsAlert.RemoteAddPartiallySucceeded -> null - SettingsAlert.ImportSucceeded -> null - SettingsAlert.ExportSucceeded -> null - is SettingsAlert.ImportFailed -> alert.error - is SettingsAlert.ExportFailed -> alert.error - SettingsAlert.ImportCancelled -> null - SettingsAlert.ExportCancelled -> null - is SettingsAlert.LogcatSucceeded -> null - is SettingsAlert.LogcatFailed -> alert.error - } - - // Give users a chance to read the message. LENGTH_LONG is only 2750ms. - Snackbar.make(requireView(), msg, 5000) - .apply { - if (details != null) { - setAction(R.string.action_details) { - MessageDialogFragment.newInstance( - getString(R.string.dialog_error_details_title), - details, - ).show(parentFragmentManager.beginTransaction(), MessageDialogFragment.TAG) - } - } - } - .addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - if (event != DISMISS_EVENT_CONSECUTIVE) { - viewModel.acknowledgeFirstAlert() - } - } - }) - .show() - } - - private fun confirmImport(force: Boolean) { - if (!force && viewModel.isAnyVfsCacheDirty) { - VfsCacheDeletionDialogFragment.newInstance( - getString(R.string.dialog_import_password_title), - ).show(parentFragmentManager.beginTransaction(), TAG_IMPORT_CONFIRM) - } else { - // We intentionally do not filter for specific MIME types because document providers - // are inconsistent in what MIME types they report for .conf files. - requestSafImportConfiguration.launch(arrayOf("*/*")) - } - } - - private fun editRemote(remote: String) { - requestEditRemote.launch(EditRemoteActivity.createIntent(requireContext(), remote)) - } -} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/SettingsScreen.kt b/app/src/main/java/com/chiller3/rsaf/settings/SettingsScreen.kt new file mode 100644 index 0000000..dc24e66 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/SettingsScreen.kt @@ -0,0 +1,761 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.res.Configuration +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.chiller3.rsaf.BuildConfig +import com.chiller3.rsaf.Logcat +import com.chiller3.rsaf.Permissions +import com.chiller3.rsaf.Preferences +import com.chiller3.rsaf.R +import com.chiller3.rsaf.binding.rcbridge.Rcbridge +import com.chiller3.rsaf.extension.formattedString +import com.chiller3.rsaf.rclone.RcloneConfig +import com.chiller3.rsaf.rclone.RcloneProvider +import com.chiller3.rsaf.ui.AppScreen +import com.chiller3.rsaf.ui.BetterSegmentedShapes +import com.chiller3.rsaf.ui.Preference +import com.chiller3.rsaf.ui.PreferenceCategory +import com.chiller3.rsaf.ui.PreferenceColumn +import com.chiller3.rsaf.ui.SwitchPreference +import com.chiller3.rsaf.ui.betterSegmentedShapes +import com.chiller3.rsaf.ui.theme.AppTheme + +@Composable +fun SettingsScreen( + onLockNow: () -> Unit, + viewModel: SettingsViewModel = viewModel(), +) { + val context = LocalContext.current + val resources = LocalResources.current + + val prefs = remember { Preferences(context) } + var reloadPrefs by remember { mutableIntStateOf(0) } + val addFileExtension = remember(reloadPrefs) { prefs.addFileExtension } + val pretendLocal = remember(reloadPrefs) { prefs.pretendLocal } + val requireAuth = remember(reloadPrefs) { prefs.requireAuth } + val inactivityTimeout = remember(reloadPrefs) { prefs.inactivityTimeout } + val allowBackup = remember(reloadPrefs) { prefs.allowBackup } + val isDebugMode = remember(reloadPrefs) { prefs.isDebugMode } + val verboseRcloneLogs = remember(reloadPrefs) { prefs.verboseRcloneLogs } + + var reloadPerms by remember { mutableIntStateOf(0) } + val inhibitBatteryOpt = remember(reloadPerms) { Permissions.isInhibitingBatteryOpt(context) } + val notificationsGranted = remember(reloadPerms) { + Permissions.have(context, Permissions.NOTIFICATION) + } + val localStorageAccess = remember(reloadPerms) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + Permissions.have(context, Permissions.LEGACY_STORAGE) + } + } + + val remotes by viewModel.remotes.collectAsStateWithLifecycle() + val importExportState by viewModel.importExportState.collectAsStateWithLifecycle() + + val requestInteractiveConfiguration = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { + val remote = it.data?.getStringExtra(InteractiveConfigurationActivity.EXTRA_REMOTE)!! + val cancelled = it.resultCode != Activity.RESULT_OK + + viewModel.interactiveConfigurationCompleted(remote, cancelled) + } + val requestEditRemote = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { + viewModel.remoteEdited() + } + val requestPermissionActivity = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { + reloadPerms++ + } + val requestPermissionRequired = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { granted -> + if (granted.all { it.value }) { + reloadPerms++ + } else { + context.startActivity(Permissions.getAppInfoIntent(context)) + } + } + val requestSafImportConfiguration = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> + uri?.let { + viewModel.startImportExport(ImportExportMode.IMPORT, it) + } + } + val requestSafExportConfiguration = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument(RcloneConfig.MIMETYPE), + ) { uri -> + uri?.let { + viewModel.startImportExport(ImportExportMode.EXPORT, it) + } + } + val requestSafSaveLogs = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument(Logcat.MIMETYPE), + ) { uri -> + uri?.let { + viewModel.saveLogs(it) + } + } + + var showErrorDialog by rememberSaveable { mutableStateOf(null) } + + AppScreen( + title = { Text(text = stringResource(R.string.app_name)) }, + ) { params -> + LaunchedEffect(Unit) { + viewModel.alerts.collect { alerts -> + val alert = alerts.firstOrNull() ?: return@collect + val msg = when (alert) { + is SettingsAlert.ListRemotesFailed -> + resources.getString(R.string.alert_list_remotes_failure) + is SettingsAlert.RemoteAddSucceeded -> + resources.getString(R.string.alert_add_remote_success, alert.remote) + is SettingsAlert.RemoteAddPartiallySucceeded -> + resources.getString(R.string.alert_add_remote_partial, alert.remote) + SettingsAlert.ImportSucceeded -> + resources.getString(R.string.alert_import_success) + SettingsAlert.ExportSucceeded -> + resources.getString(R.string.alert_export_success) + is SettingsAlert.ImportFailed -> + resources.getString(R.string.alert_import_failure) + is SettingsAlert.ExportFailed -> + resources.getString(R.string.alert_export_failure) + SettingsAlert.ImportCancelled -> + resources.getString(R.string.alert_import_cancelled) + SettingsAlert.ExportCancelled -> + resources.getString(R.string.alert_export_cancelled) + is SettingsAlert.LogcatSucceeded -> + resources.getString(R.string.alert_logcat_success, alert.uri.formattedString) + is SettingsAlert.LogcatFailed -> + resources.getString(R.string.alert_logcat_failure, alert.uri.formattedString) + SettingsAlert.BrowserNotFound -> + resources.getString(R.string.alert_browser_not_found) + } + val details = when (alert) { + is SettingsAlert.ListRemotesFailed -> alert.error + is SettingsAlert.RemoteAddSucceeded -> null + is SettingsAlert.RemoteAddPartiallySucceeded -> null + SettingsAlert.ImportSucceeded -> null + SettingsAlert.ExportSucceeded -> null + is SettingsAlert.ImportFailed -> alert.error + is SettingsAlert.ExportFailed -> alert.error + SettingsAlert.ImportCancelled -> null + SettingsAlert.ExportCancelled -> null + is SettingsAlert.LogcatSucceeded -> null + is SettingsAlert.LogcatFailed -> alert.error + SettingsAlert.BrowserNotFound -> null + } + + val result = params.snackbarHostState.showSnackbar( + message = msg, + details?.let { resources.getString(R.string.action_details) }, + withDismissAction = true, + ) + viewModel.acknowledgeFirstAlert() + + when (result) { + SnackbarResult.Dismissed -> {} + SnackbarResult.ActionPerformed -> { showErrorDialog = details } + } + } + } + + showErrorDialog?.let { message -> + ErrorDetailsDialog( + message = message, + onDismiss = { showErrorDialog = null }, + ) + } + + SettingsContent( + importExportState = importExportState, + inhibitBatteryOpt = inhibitBatteryOpt, + notificationsGranted = notificationsGranted, + remotes = remotes, + addFileExtension = addFileExtension, + pretendLocal = pretendLocal, + localStorageAccess = localStorageAccess, + requireAuth = requireAuth, + inactivityTimeout = inactivityTimeout, + allowBackup = allowBackup, + isDebugMode = isDebugMode, + rcloneVersion = Rcbridge.rbVersion(), + verboseRcloneLogs = verboseRcloneLogs, + onInhibitBatteryOptGrant = { + requestPermissionActivity.launch(Permissions.getInhibitBatteryOptIntent(context)) + }, + onNotificationsGrant = { + requestPermissionRequired.launch(Permissions.NOTIFICATION) + }, + onRemoteAdd = { name -> + requestInteractiveConfiguration.launch( + InteractiveConfigurationActivity.createIntent(context, name, true), + ) + }, + onRemoteEdit = { name -> + requestEditRemote.launch(EditRemoteActivity.createIntent(context, name)) + }, + onConfigurationImport = { + // We intentionally do not filter for specific MIME types because document providers + // are inconsistent in what MIME types they report for .conf files. + requestSafImportConfiguration.launch(arrayOf("*/*")) + }, + onConfigurationExport = { + requestSafExportConfiguration.launch(RcloneConfig.FILENAME) + }, + onAddFileExtensionChange = { enabled -> + prefs.addFileExtension = enabled + reloadPrefs++ + }, + onPretendLocalChange = { enabled -> + prefs.pretendLocal = enabled + reloadPrefs++ + }, + onLocalStorageAccessChange = { enabled -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val intent = Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + "package:${BuildConfig.APPLICATION_ID}".toUri(), + ) + + requestPermissionActivity.launch(intent) + } else if (enabled) { + requestPermissionRequired.launch(Permissions.LEGACY_STORAGE) + } else { + requestPermissionActivity.launch(Permissions.getAppInfoIntent(context)) + } + }, + onRequireAuthChange = { enabled -> + prefs.requireAuth = enabled + reloadPrefs++ + }, + onInactivityTimeoutChange = { timeout -> + prefs.inactivityTimeout = timeout + reloadPrefs++ + }, + onLockNow = onLockNow, + onAllowBackupChange = { enabled -> + prefs.allowBackup = enabled + reloadPrefs++ + }, + onDebugModeChange = { enabled -> + prefs.isDebugMode = enabled + reloadPrefs++ + }, + onSourceRepoOpen = { + val uri = BuildConfig.PROJECT_URL_AT_COMMIT.toUri() + try { + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + } catch (_: ActivityNotFoundException) { + viewModel.addAlert(SettingsAlert.BrowserNotFound) + } + }, + onVerboseRcloneLogsChange = { enabled -> + prefs.verboseRcloneLogs = enabled + reloadPrefs++ + }, + onSaveLogs = { + requestSafSaveLogs.launch(Logcat.FILENAME_DEFAULT) + }, + onAddInternalCacheRemote = { + viewModel.addInternalCacheRemote() + }, + isVfsCacheDirty = { + viewModel.isAnyVfsCacheDirty + }, + contentPadding = params.contentPadding, + ) + } + + if (importExportState?.status == ImportExportState.Status.NEED_PASSWORD) { + PasswordDialog( + mode = importExportState!!.mode, + onSelect = { password -> + viewModel.setImportExportPassword(RcloneConfig.Password(password)) + }, + onDismiss = { + viewModel.cancelPendingImportExport() + }, + ) + } + + LaunchedEffect(Unit) { + viewModel.activityActions.collect { + if (it.refreshRoots) { + RcloneProvider.notifyRootsChanged(context) + } + viewModel.activityActionCompleted() + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SettingsContent( + importExportState: ImportExportState?, + inhibitBatteryOpt: Boolean, + notificationsGranted: Boolean, + remotes: List, + addFileExtension: Boolean, + pretendLocal: Boolean, + localStorageAccess: Boolean, + requireAuth: Boolean, + inactivityTimeout: Int, + allowBackup: Boolean, + isDebugMode: Boolean, + rcloneVersion: String, + verboseRcloneLogs: Boolean, + onInhibitBatteryOptGrant: () -> Unit, + onNotificationsGrant: () -> Unit, + onRemoteAdd: (String) -> Unit, + onRemoteEdit: (String) -> Unit, + onConfigurationImport: () -> Unit, + onConfigurationExport: () -> Unit, + onAddFileExtensionChange: (Boolean) -> Unit, + onPretendLocalChange: (Boolean) -> Unit, + onLocalStorageAccessChange: (Boolean) -> Unit, + onRequireAuthChange: (Boolean) -> Unit, + onInactivityTimeoutChange: (Int) -> Unit, + onLockNow: () -> Unit, + onAllowBackupChange: (Boolean) -> Unit, + onDebugModeChange: (Boolean) -> Unit, + onSourceRepoOpen: () -> Unit, + onVerboseRcloneLogsChange: (Boolean) -> Unit, + onSaveLogs: () -> Unit, + onAddInternalCacheRemote: () -> Unit, + isVfsCacheDirty: () -> Boolean, + contentPadding: PaddingValues = PaddingValues(), +) { + var showVfsWarningDialog by rememberSaveable { mutableStateOf(null) } + var showRemoteNameDialog by rememberSaveable { mutableStateOf(null) } + var showInactivityTimeoutDialog by rememberSaveable { mutableStateOf(false) } + + PreferenceColumn(contentPadding = contentPadding) { + if (!inhibitBatteryOpt || !notificationsGranted) { + item(key = "permissions") { + PreferenceCategory( + title = { Text(text = stringResource(R.string.pref_header_permissions)) }, + modifier = Modifier.animateItem(), + ) + } + + if (!inhibitBatteryOpt) { + item(key = "inhibit_battery_opt") { + Preference( + onClick = onInhibitBatteryOptGrant, + shapes = if (!notificationsGranted) { + BetterSegmentedShapes.top() + } else { + BetterSegmentedShapes.single() + }, + title = { Text(text = stringResource(R.string.pref_inhibit_battery_opt_name)) }, + summary = { Text(text = stringResource(R.string.pref_inhibit_battery_opt_desc)) }, + modifier = Modifier.animateItem(), + ) + } + } + + if (!notificationsGranted) { + item(key = "missing_notifications") { + Preference( + onClick = onNotificationsGrant, + shapes = if (!inhibitBatteryOpt) { + BetterSegmentedShapes.bottom() + } else { + BetterSegmentedShapes.single() + }, + title = { Text(text = stringResource(R.string.pref_missing_notifications_name)) }, + summary = { Text(text = stringResource(R.string.pref_missing_notifications_desc)) }, + modifier = Modifier.animateItem(), + ) + } + } + } + + item(key = "remotes") { + PreferenceCategory( + title = { Text(text = stringResource(R.string.pref_header_remotes)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "add_remote") { + Preference( + onClick = { showRemoteNameDialog = RemoteNameDialogAction.Add }, + enabled = importExportState == null, + shapes = if (remotes.isEmpty()) { + BetterSegmentedShapes.single() + } else { + BetterSegmentedShapes.top() + }, + title = { Text(text = stringResource(R.string.pref_add_remote_name)) }, + summary = { Text(text = stringResource(R.string.pref_add_remote_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + itemsIndexed(remotes, key = { _, remote -> remote.name }) { index, remote -> + Preference( + onClick = { onRemoteEdit(remote.name) }, + enabled = importExportState == null, + shapes = betterSegmentedShapes(index = index + 1, count = remotes.size + 1), + title = { Text(text = remote.name) }, + summary = { Text(text = remote.provider.description) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "configuration") { + PreferenceCategory( + title = { Text(text = stringResource(R.string.pref_header_configuration)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "import_configuration") { + Preference( + onClick = { + if (isVfsCacheDirty()) { + showVfsWarningDialog = VfsCacheDeletionReason.Import + } else { + onConfigurationImport() + } + }, + enabled = importExportState == null, + shapes = BetterSegmentedShapes.top(), + title = { Text(text = stringResource(R.string.pref_import_configuration_name)) }, + summary = { Text(text = stringResource(R.string.pref_import_configuration_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "export_configuration") { + Preference( + onClick = onConfigurationExport, + enabled = importExportState == null, + shapes = BetterSegmentedShapes.bottom(), + title = { Text(text = stringResource(R.string.pref_export_configuration_name)) }, + summary = { Text(text = stringResource(R.string.pref_export_configuration_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "behavior") { + PreferenceCategory( + title = { Text(text = stringResource(R.string.pref_header_behavior)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "add_file_extension") { + SwitchPreference( + checked = addFileExtension, + onCheckedChange = onAddFileExtensionChange, + shapes = BetterSegmentedShapes.top(), + title = { Text(text = stringResource(R.string.pref_add_file_extension_name)) }, + summary = { Text(text = stringResource(R.string.pref_add_file_extension_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "pretend_local") { + SwitchPreference( + checked = pretendLocal, + onCheckedChange = onPretendLocalChange, + shapes = BetterSegmentedShapes.middle(), + title = { Text(text = stringResource(R.string.pref_pretend_local_name)) }, + summary = { Text(text = stringResource(R.string.pref_pretend_local_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "local_storage_access") { + SwitchPreference( + checked = localStorageAccess, + onCheckedChange = onLocalStorageAccessChange, + shapes = BetterSegmentedShapes.bottom(), + title = { Text(text = stringResource(R.string.pref_local_storage_access_name)) }, + summary = { Text(text = stringResource(R.string.pref_local_storage_access_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "app_lock") { + PreferenceCategory( + title = { Text(text = stringResource(R.string.pref_header_app_lock)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "require_auth") { + SwitchPreference( + checked = requireAuth, + onCheckedChange = onRequireAuthChange, + shapes = BetterSegmentedShapes.top(), + title = { Text(text = stringResource(R.string.pref_require_auth_name)) }, + summary = { Text(text = stringResource(R.string.pref_require_auth_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "inactivity_timeout") { + Preference( + onClick = { showInactivityTimeoutDialog = true }, + enabled = requireAuth, + shapes = BetterSegmentedShapes.middle(), + title = { Text(text = stringResource(R.string.pref_inactivity_timeout_name)) }, + summary = { Text(text = inactivityTimeoutSummary(inactivityTimeout)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "lock_now") { + Preference( + onClick = onLockNow, + enabled = requireAuth, + shapes = BetterSegmentedShapes.bottom(), + title = { Text(text = stringResource(R.string.pref_lock_now_name)) }, + summary = { Text(text = stringResource(R.string.pref_lock_now_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "advanced") { + PreferenceCategory( + title = { Text(text = stringResource(R.string.pref_header_advanced)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "allow_backup") { + SwitchPreference( + checked = allowBackup, + onCheckedChange = onAllowBackupChange, + shapes = BetterSegmentedShapes.single(), + title = { Text(text = stringResource(R.string.pref_allow_backup_name)) }, + summary = { Text(text = stringResource(R.string.pref_allow_backup_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "about") { + PreferenceCategory( + title = { Text(text = stringResource(R.string.pref_header_about)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "version") { + Preference( + onClick = onSourceRepoOpen, + onLongClick = { onDebugModeChange(!isDebugMode) }, + shapes = BetterSegmentedShapes.single(), + title = { Text(text = stringResource(R.string.pref_version_name)) }, + summary = { Text(text = versionSummary(isDebugMode, rcloneVersion)) }, + modifier = Modifier.animateItem(), + ) + } + + if (isDebugMode) { + item(key = "debug") { + PreferenceCategory( + title = { Text(text = stringResource(R.string.pref_header_debug)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "verbose_rclone_logs") { + SwitchPreference( + checked = verboseRcloneLogs, + onCheckedChange = onVerboseRcloneLogsChange, + shapes = BetterSegmentedShapes.top(), + title = { Text(text = stringResource(R.string.pref_verbose_rclone_logs_name)) }, + summary = { Text(text = stringResource(R.string.pref_verbose_rclone_logs_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "save_logs") { + Preference( + onClick = onSaveLogs, + shapes = BetterSegmentedShapes.middle(), + title = { Text(text = stringResource(R.string.pref_save_logs_name)) }, + summary = { Text(text = stringResource(R.string.pref_save_logs_desc)) }, + modifier = Modifier.animateItem(), + ) + } + + item(key = "add_internal_cache_remote") { + Preference( + onClick = onAddInternalCacheRemote, + shapes = BetterSegmentedShapes.bottom(), + title = { Text(text = stringResource(R.string.pref_add_internal_cache_remote_name)) }, + summary = { Text(text = stringResource(R.string.pref_add_internal_cache_remote_desc)) }, + modifier = Modifier.animateItem(), + ) + } + } + } + + showVfsWarningDialog?.let { reason -> + VfsCacheDeletionDialog( + reason = reason, + onConfirm = { + when (reason) { + VfsCacheDeletionReason.Import -> onConfigurationImport() + else -> throw IllegalStateException("Invalid reason: $reason") + } + + @Suppress("AssignedValueIsNeverRead") + showVfsWarningDialog = null + }, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showVfsWarningDialog = null + } + ) + } + + showRemoteNameDialog?.let { action -> + RemoteNameDialog( + action = action, + existingRemotes = remotes.map { it.name }, + onSelect = { name -> + onRemoteAdd(name) + @Suppress("AssignedValueIsNeverRead") + showRemoteNameDialog = null + }, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showRemoteNameDialog = null + }, + ) + } + + if (showInactivityTimeoutDialog) { + InactivityTimeoutDialog( + initialTimeout = inactivityTimeout, + onSelect = { timeout -> + onInactivityTimeoutChange(timeout) + @Suppress("AssignedValueIsNeverRead") + showInactivityTimeoutDialog = false + }, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showInactivityTimeoutDialog = false + }, + ) + } +} + +@Composable +private fun inactivityTimeoutSummary(timeout: Int) = + pluralStringResource(R.plurals.pref_inactivity_timeout_desc, timeout, timeout) + +@Composable +private fun versionSummary(isDebugMode: Boolean, rcloneVersion: String) = buildString { + append(BuildConfig.VERSION_NAME) + + append(" (") + append(BuildConfig.BUILD_TYPE) + if (isDebugMode) { + append("+debugmode") + } + append(")\nrclone ") + + append(rcloneVersion) +} + +@Preview( + name = "Light Mode", + showBackground = true, +) +@Preview( + name = "Dark Mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, +) +@Composable +private fun PreviewSettingsScreen() { + AppTheme { + AppScreen( + title = { Text(text = stringResource(R.string.app_name)) }, + ) { params -> + SettingsContent( + importExportState = null, + inhibitBatteryOpt = false, + notificationsGranted = false, + remotes = emptyList(), + addFileExtension = true, + pretendLocal = false, + localStorageAccess = false, + requireAuth = true, + inactivityTimeout = 60, + allowBackup = false, + isDebugMode = true, + rcloneVersion = "1.74.2-rsaf.0", + verboseRcloneLogs = false, + onInhibitBatteryOptGrant = {}, + onNotificationsGrant = {}, + onRemoteAdd = {}, + onRemoteEdit = {}, + onConfigurationImport = {}, + onConfigurationExport = {}, + onAddFileExtensionChange = {}, + onPretendLocalChange = {}, + onLocalStorageAccessChange = {}, + onRequireAuthChange = {}, + onInactivityTimeoutChange = {}, + onLockNow = {}, + onAllowBackupChange = {}, + onDebugModeChange = {}, + onSourceRepoOpen = {}, + onVerboseRcloneLogsChange = {}, + onSaveLogs = {}, + onAddInternalCacheRemote = {}, + isVfsCacheDirty = { false }, + contentPadding = params.contentPadding, + ) + } + } +} 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 4c6499a..62c4391 100644 --- a/app/src/main/java/com/chiller3/rsaf/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/chiller3/rsaf/settings/SettingsViewModel.kt @@ -25,7 +25,7 @@ import java.io.File data class Remote( val name: String, - val provider: RcloneRpc.Provider?, + val provider: RcloneRpc.Provider, ) enum class ImportExportMode { @@ -77,9 +77,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application val r = withContext(Dispatchers.IO) { val providers = RcloneRpc.providers - RcloneRpc.remoteConfigsRaw.map { - Remote(it.key, providers[it.value["type"]]) - }.sortedBy { it.name } + RcloneRpc + .remoteConfigsRaw + .asSequence() + // Silently ignore remote types that are no longer supported. + .mapNotNull { providers[it.value["type"]]?.let { p -> Remote(it.key, p) } } + .sortedBy { it.name } + .toList() } _remotes.update { r } @@ -207,6 +211,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application _alerts.update { it.drop(1) } } + fun addAlert(alert: SettingsAlert) { + _alerts.update { it + alert } + } + fun interactiveConfigurationCompleted(remote: String, cancelled: Boolean) { viewModelScope.launch { refreshRemotesInternal() diff --git a/app/src/main/java/com/chiller3/rsaf/settings/VfsCacheDeletionDialog.kt b/app/src/main/java/com/chiller3/rsaf/settings/VfsCacheDeletionDialog.kt new file mode 100644 index 0000000..3640e19 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/VfsCacheDeletionDialog.kt @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.content.res.Resources +import android.os.Parcelable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import com.chiller3.rsaf.R +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed interface VfsCacheDeletionReason : Parcelable { + fun getTitle(resources: Resources): String + + @Parcelize + data object Import : VfsCacheDeletionReason { + override fun getTitle(resources: Resources): String = + resources.getString(R.string.dialog_import_password_title) + + } + + @Parcelize + data class Rename(val remote: String) : VfsCacheDeletionReason { + override fun getTitle(resources: Resources): String = + resources.getString(R.string.dialog_rename_remote_title, remote) + } + + @Parcelize + data class Delete(val remote: String) : VfsCacheDeletionReason { + override fun getTitle(resources: Resources): String = + resources.getString(R.string.dialog_delete_remote_title, remote) + } +} + +@Composable +fun VfsCacheDeletionDialog( + reason: VfsCacheDeletionReason, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val resources = LocalResources.current + + AlertDialog( + title = { Text(text = reason.getTitle(resources)) }, + text = { + Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) { + Text(text = stringResource(R.string.dialog_vfs_cache_deletion_message)) + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(R.string.dialog_action_proceed_anyway)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/com/chiller3/rsaf/settings/VfsOptionsDialog.kt b/app/src/main/java/com/chiller3/rsaf/settings/VfsOptionsDialog.kt new file mode 100644 index 0000000..aee9b87 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/settings/VfsOptionsDialog.kt @@ -0,0 +1,229 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.settings + +import android.system.ErrnoException +import android.text.Annotation +import android.text.SpannedString +import android.view.View +import android.view.Window +import android.view.WindowManager +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import com.chiller3.rsaf.R +import com.chiller3.rsaf.extension.toSingleLineString +import com.chiller3.rsaf.rclone.VfsCache +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.iterator + +private fun findDialogWindow(view: View): Window { + var current: View? = view + + while (current != null) { + if (current is DialogWindowProvider) { + return current.window + } + + current = current.parent as? View + } + + throw IllegalStateException("Dialog window not found: $view") +} + +@Composable +private fun rememberFixInputResize(): Int { + val view = LocalView.current + val window = findDialogWindow(view) + + // The dialog can resize due to the multiline input. Make sure the dialog doesn't get hidden + // behind the IME. + // + // SOFT_INPUT_ADJUST_RESIZE works reliably, but is deprecated. The normal way of using + // setOnApplyWindowInsetsListener() + adjusting padding does not work for the dialog's + // DecorView. It causes terrible layout issues. + // + // Adding WindowInsetsCompat.Type.ime() to window!!.attributes.fitInsetsTypes does not work in + // all cases either. It kind of works until you add enough lines to max out the height, rotate + // to landscape, rotate back to portrait, and then tap on the text box. The dialog doesn't + // resize to the correct size until the keyboard is closed and reopened. + // + // AOSP still uses this in several places, like the Settings app, so it should stay working. + return remember(window) { + @Suppress("DEPRECATION") + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + + // Just to avoid rerunning on every composition. + 0 + } +} + +@Composable +fun VfsOptionsDialog( + remote: String, + initialOptions: Map, + onSelect: (Map, Boolean) -> Unit, + onDismiss: () -> Unit, +) { + val initialText = remember { + buildString { + for ((key, value) in initialOptions) { + if (isNotEmpty()) { + append('\n') + } + append("$key=$value") + } + } + } + val input = rememberTextFieldState(initialText = initialText) + val options = tryParseInput(input.text.toString()) + + AlertDialog( + title = { Text(text = stringResource(R.string.vfs_options_title, remote)) }, + text = { + // This is here because we need to run with LocalView being the dialog view. + rememberFixInputResize() + + Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) { + Text(text = buildMessage()) + + OutlinedTextField( + state = input, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + isError = options is VfsOptionsParse.Error, + supportingText = { + if (options is VfsOptionsParse.Error && options.message != null) { + Text(text = options.message) + } + }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + ), + lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 2), + ) + + Text(text = stringResource(R.string.vfs_options_reload_warning)) + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { onSelect((options as VfsOptionsParse.Value).options, true) }, + enabled = options is VfsOptionsParse.Value, + ) { + Text(text = stringResource(R.string.vfs_options_save_and_reload)) + } + + TextButton( + onClick = { onSelect((options as VfsOptionsParse.Value).options, false) }, + enabled = options is VfsOptionsParse.Value, + ) { + Text(text = stringResource(R.string.vfs_options_save_only)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) +} + +@Composable +private fun buildMessage(): AnnotatedString { + val resources = LocalResources.current + + val origMessage = resources.getText(R.string.vfs_options_message) as SpannedString + val message = StringBuilder(origMessage) + val origAnnotations = origMessage.getSpans(0, origMessage.length, Annotation::class.java) + val newAnnotations = mutableListOf>() + + for (annotation in origAnnotations) { + val start = origMessage.getSpanStart(annotation) + val end = origMessage.getSpanEnd(annotation) + + if (annotation.key == "type" && annotation.value == "rclone_vfs_docs") { + newAnnotations.add( + AnnotatedString.Range( + LinkAnnotation.Url("https://rclone.org/commands/rclone_mount/"), + start, + end, + ) + ) + } else { + throw IllegalStateException("Invalid annotation: $annotation") + } + } + + return AnnotatedString(message.toString(), newAnnotations) +} + +private sealed interface VfsOptionsParse { + data class Value(val options: Map) : VfsOptionsParse + + data class Error(val message: String?) : VfsOptionsParse +} + +@Composable +private fun tryParseInput(input: String): VfsOptionsParse { + try { + val options = mutableMapOf() + + for (line in input.splitToSequence('\n')) { + if (line.trim().isEmpty()) { + continue + } + + val pieces = line.split('=', limit = 2) + + // Treat an incomplete line as just the key. rcbridge will show a better error message + // for unknown keys. + options[pieces[0]] = if (pieces.size > 1) { + pieces[1] + } else { + "" + } + } + + VfsCache.getVfsOptions(options) + return VfsOptionsParse.Value(options) + } catch (e: Exception) { + val message = if (e is ErrnoException) { + e.cause!!.message + } else { + e.toSingleLineString() + } + + return VfsOptionsParse.Error(message) + } +} diff --git a/app/src/main/java/com/chiller3/rsaf/ui/Padding.kt b/app/src/main/java/com/chiller3/rsaf/ui/Padding.kt new file mode 100644 index 0000000..9ca1195 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/ui/Padding.kt @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp + +@Composable +fun PaddingValues.copy( + start: Dp? = null, + top: Dp? = null, + end: Dp? = null, + bottom: Dp? = null, +): PaddingValues { + val layoutDirection = LocalLayoutDirection.current + + return PaddingValues( + start = start ?: calculateStartPadding(layoutDirection), + top = top ?: calculateTopPadding(), + end = end ?: calculateEndPadding(layoutDirection), + bottom = bottom ?: calculateBottomPadding(), + ) +} diff --git a/app/src/main/java/com/chiller3/rsaf/ui/Preferences.kt b/app/src/main/java/com/chiller3/rsaf/ui/Preferences.kt new file mode 100644 index 0000000..9a16619 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/ui/Preferences.kt @@ -0,0 +1,289 @@ +/* + * SPDX-FileCopyrightText: 2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.ui + +import androidx.compose.foundation.OverscrollEffect +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberOverscrollEffect +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.chiller3.rsaf.ui.theme.Icons + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +object PreferenceDefaults { + val HorizontalPadding = 16.dp + val SegmentedGap = ListItemDefaults.SegmentedGap + + // Equal to ListItemStartPadding and ListItemEndPadding. + val CategoryHorizontalPadding = 16.dp + val CategoryTopPadding = 24.dp + val CategoryBottomPadding = 8.dp + + val MaxWidth = 720.dp + + val ListPadding = PaddingValues(horizontal = HorizontalPadding) + + val containerColor: Color + @Composable get() = MaterialTheme.colorScheme.surfaceContainerHigh + val scrolledContainerColor: Color + @Composable get() = MaterialTheme.colorScheme.surfaceContainerLow + + @Composable + fun appBarColors() = TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + scrolledContainerColor = scrolledContainerColor, + ) + + @Composable + fun preferenceColors() = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + disabledContainerColor = MaterialTheme.colorScheme.surfaceBright, + ) + + @Composable + fun switchColors() = SwitchDefaults.colors( + checkedIconColor = SwitchDefaults.colors().checkedTrackColor, + ) +} + +@Composable +fun PreferenceColumn( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(), + fillScreen: Boolean = true, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(PreferenceDefaults.SegmentedGap), + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), + content: LazyListScope.() -> Unit, +) { + LazyColumn( + modifier = if (fillScreen) { + Modifier.fillMaxSize().then(modifier) + } else { + modifier + }, + state = state, + contentPadding = if (fillScreen) { + contentPadding + PreferenceDefaults.ListPadding + } else { + contentPadding + }, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + flingBehavior = flingBehavior, + overscrollEffect = overscrollEffect, + content = content, + ) +} + +@Composable +fun PreferenceCategory( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + segmentedGap: Dp = PreferenceDefaults.SegmentedGap, +) { + Box( + modifier = Modifier + .widthIn(max = PreferenceDefaults.MaxWidth) + .fillMaxWidth() + .padding( + start = PreferenceDefaults.CategoryHorizontalPadding, + top = PreferenceDefaults.CategoryTopPadding - segmentedGap, + end = PreferenceDefaults.CategoryHorizontalPadding, + bottom = PreferenceDefaults.CategoryBottomPadding - segmentedGap, + ) + .then(modifier), + contentAlignment = Alignment.CenterStart, + ) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { + ProvideTextStyle(value = MaterialTheme.typography.labelLarge) { + // This is a separate box so that the focusable item is not full-width. + Box(modifier = Modifier.semantics(mergeDescendants = true) { heading() }) { + title() + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun Preference( + onClick: () -> Unit, + shapes: ListItemShapes, + modifier: Modifier = Modifier, + enabled: Boolean = true, + summary: @Composable (() -> Unit)? = null, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + onLongClick: (() -> Unit)? = null, + onLongClickLabel: String? = null, + colors: ListItemColors = PreferenceDefaults.preferenceColors(), + title: @Composable () -> Unit, +) { + SegmentedListItem( + onClick = onClick, + shapes = shapes, + modifier = Modifier.widthIn(max = PreferenceDefaults.MaxWidth).then(modifier), + enabled = enabled, + supportingContent = summary, + verticalAlignment = verticalAlignment, + onLongClick = onLongClick, + onLongClickLabel = onLongClickLabel, + colors = colors, + content = title, + ) +} + +@Composable +private fun PreferenceSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + enabled: Boolean = true, + switchColors: SwitchColors = PreferenceDefaults.switchColors(), +) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + thumbContent = { + Icon( + imageVector = if (checked) Icons.Check else Icons.Close, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + }, + colors = switchColors, + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SwitchPreference( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + shapes: ListItemShapes, + modifier: Modifier = Modifier, + enabled: Boolean = true, + summary: @Composable (() -> Unit)? = null, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + onLongClick: (() -> Unit)? = null, + onLongClickLabel: String? = null, + colors: ListItemColors = PreferenceDefaults.preferenceColors(), + switchColors: SwitchColors = PreferenceDefaults.switchColors(), + title: @Composable () -> Unit, +) { + SegmentedListItem( + onClick = { onCheckedChange(!checked) }, + shapes = shapes, + modifier = Modifier + .widthIn(max = PreferenceDefaults.MaxWidth) + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ) + .then(modifier), + enabled = enabled, + trailingContent = { + PreferenceSwitch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + switchColors = switchColors, + ) + }, + supportingContent = summary, + verticalAlignment = verticalAlignment, + onLongClick = onLongClick, + onLongClickLabel = onLongClickLabel, + colors = colors, + content = title, + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun RadioPreference( + selected: Boolean, + onClick: () -> Unit, + shapes: ListItemShapes, + modifier: Modifier = Modifier, + enabled: Boolean = true, + summary: @Composable (() -> Unit)? = null, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + onLongClick: (() -> Unit)? = null, + onLongClickLabel: String? = null, + colors: ListItemColors = PreferenceDefaults.preferenceColors(), + title: @Composable () -> Unit, +) { + SegmentedListItem( + selected = selected, + onClick = onClick, + shapes = shapes, + modifier = Modifier + .widthIn(max = PreferenceDefaults.MaxWidth) + .selectable( + selected = selected, + onClick = onClick, + role = Role.RadioButton, + ) + .then(modifier), + enabled = enabled, + leadingContent = { + RadioButton( + selected = selected, + onClick = null, + ) + }, + supportingContent = summary, + verticalAlignment = verticalAlignment, + onLongClick = onLongClick, + onLongClickLabel = onLongClickLabel, + colors = colors, + content = title, + ) +} diff --git a/app/src/main/java/com/chiller3/rsaf/ui/Screen.kt b/app/src/main/java/com/chiller3/rsaf/ui/Screen.kt new file mode 100644 index 0000000..3af7340 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/ui/Screen.kt @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: 2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.ui + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.chiller3.rsaf.ui.theme.Icons + +data class AppScreenParams( + val contentPadding: PaddingValues, + val snackbarHostState: SnackbarHostState, +) + +@Composable +fun AppScreen( + title: @Composable () -> Unit, + onBack: (() -> Unit)? = null, + backIsExit: Boolean = false, + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + content: @Composable (AppScreenParams) -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + TopAppBar( + title = title, + navigationIcon = { + onBack?.let { onClick -> + IconButton(onClick = onClick) { + if (backIsExit) { + Icon( + imageVector = Icons.Close, + contentDescription = stringResource(android.R.string.cancel) + ) + } else { + @SuppressLint("PrivateResource") + Icon( + imageVector = Icons.AutoMirrored.ArrowBack, + contentDescription = stringResource( + androidx.appcompat.R.string.abc_action_bar_up_description, + ), + ) + } + } + } + }, + colors = PreferenceDefaults.appBarColors(), + scrollBehavior = scrollBehavior, + ) + }, + containerColor = PreferenceDefaults.containerColor, + contentWindowInsets = contentWindowInsets, + ) { contentPadding -> + val outerPadding = contentPadding.copy(bottom = 0.dp) + val innerPadding = contentPadding.copy(start = 0.dp, top = 0.dp, end = 0.dp) + + Box(modifier = Modifier.padding(outerPadding)) { + content(AppScreenParams( + contentPadding = innerPadding, + snackbarHostState = snackbarHostState, + )) + } + } +} diff --git a/app/src/main/java/com/chiller3/rsaf/ui/SegmentedList.kt b/app/src/main/java/com/chiller3/rsaf/ui/SegmentedList.kt new file mode 100644 index 0000000..dc70ca1 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/ui/SegmentedList.kt @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.ui + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemDefaults.shapes +import androidx.compose.material3.ListItemShapes +import androidx.compose.runtime.Composable + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun betterSegmentedShapes( + index: Int, + count: Int, + defaultShapes: ListItemShapes = shapes(), +): ListItemShapes { + if (count == 1) { + val top = ListItemDefaults.segmentedShapes(0, 2, defaultShapes) + val bottom = ListItemDefaults.segmentedShapes(1, 2, defaultShapes) + + val topShape = top.shape + val bottomShape = bottom.shape + + if (topShape is CornerBasedShape && bottomShape is CornerBasedShape) { + return top.copy( + shape = topShape.copy( + bottomStart = bottomShape.bottomStart, + bottomEnd = bottomShape.bottomEnd, + ) + ) + } + } + + return ListItemDefaults.segmentedShapes(index, count, defaultShapes) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +object BetterSegmentedShapes { + @Composable + fun top() = betterSegmentedShapes(0, 3) + + @Composable + fun middle() = betterSegmentedShapes(1, 3) + + @Composable + fun bottom() = betterSegmentedShapes(2, 3) + + @Composable + fun single() = betterSegmentedShapes(0, 1) +} diff --git a/app/src/main/java/com/chiller3/rsaf/ui/theme/Icons.kt b/app/src/main/java/com/chiller3/rsaf/ui/theme/Icons.kt new file mode 100644 index 0000000..d2bc39f --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/ui/theme/Icons.kt @@ -0,0 +1,328 @@ +/* + * SPDX-FileCopyrightText: Google + * SPDX-License-Identifier: Apache-2.0 + * + * All icons here originated from Material Symbols: https://fonts.google.com/icons + */ + +package com.chiller3.rsaf.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +object Icons { + object AutoMirrored { + // https://fonts.gstatic.com/render/v1/Material+Symbols+Outlined/24dp/arrow_back.kt?var=opsz,wght,FILL,GRAD,ROND@24,400,0,0,50 + val ArrowBack: ImageVector + get() { + if (_ArrowBack != null) { + return _ArrowBack!! + } + _ArrowBack = + ImageVector.Builder( + name = "arrow_back", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + autoMirror = true, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(7.83f, 13f) + lineToRelative(5.6f, 5.6f) + lineTo(12f, 20f) + lineTo(4f, 12f) + lineTo(12f, 4f) + lineToRelative(1.43f, 1.4f) + lineTo(7.83f, 11f) + horizontalLineTo(20f) + verticalLineToRelative(2f) + horizontalLineTo(7.83f) + close() + } + } + .build() + return _ArrowBack!! + } + + private var _ArrowBack: ImageVector? = null + } + + // https://fonts.gstatic.com/render/v1/Material+Symbols+Outlined/24dp/check.kt?var=opsz,wght,FILL,GRAD,ROND@24,400,0,0,50 + val Check: ImageVector + get() { + if (_Check != null) { + return _Check!! + } + _Check = + ImageVector.Builder( + name = "check", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(9.55f, 18f) + lineTo(3.85f, 12.3f) + lineTo(5.28f, 10.88f) + lineToRelative(4.28f, 4.28f) + lineTo(18.73f, 5.97f) + lineTo(20.15f, 7.4f) + lineTo(9.55f, 18f) + close() + } + } + .build() + return _Check!! + } + + private var _Check: ImageVector? = null + + // https://fonts.gstatic.com/render/v1/Material+Symbols+Outlined/24dp/close.kt?var=opsz,wght,FILL,GRAD,ROND@24,400,0,0,50 + val Close: ImageVector + get() { + if (_Close != null) { + return _Close!! + } + _Close = + ImageVector.Builder( + name = "close", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(6.4f, 19f) + lineTo(5f, 17.6f) + lineTo(10.6f, 12f) + lineTo(5f, 6.4f) + lineTo(6.4f, 5f) + lineTo(12f, 10.6f) + lineTo(17.6f, 5f) + lineTo(19f, 6.4f) + lineTo(13.4f, 12f) + lineTo(19f, 17.6f) + lineTo(17.6f, 19f) + lineTo(12f, 13.4f) + lineTo(6.4f, 19f) + close() + } + } + .build() + return _Close!! + } + + private var _Close: ImageVector? = null + + // https://fonts.gstatic.com/render/v1/Material+Symbols+Outlined/24dp/visibility.kt?var=opsz,wght,FILL,GRAD,ROND@24,400,0,0,50 + val Visibility: ImageVector + get() { + if (_Visibility != null) { + return _Visibility!! + } + _Visibility = + ImageVector.Builder( + name = "visibility", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(15.19f, 14.69f) + quadTo(16.5f, 13.38f, 16.5f, 11.5f) + reflectiveQuadTo(15.19f, 8.31f) + reflectiveQuadTo(12f, 7f) + reflectiveQuadTo(8.81f, 8.31f) + reflectiveQuadTo(7.5f, 11.5f) + reflectiveQuadToRelative(1.31f, 3.19f) + reflectiveQuadTo(12f, 16f) + reflectiveQuadToRelative(3.19f, -1.31f) + close() + moveToRelative(-5.1f, -1.28f) + quadTo(9.3f, 12.63f, 9.3f, 11.5f) + reflectiveQuadTo(10.09f, 9.59f) + reflectiveQuadTo(12f, 8.8f) + reflectiveQuadToRelative(1.91f, 0.79f) + quadToRelative(0.79f, 0.79f, 0.79f, 1.91f) + reflectiveQuadToRelative(-0.79f, 1.91f) + reflectiveQuadTo(12f, 14.2f) + reflectiveQuadTo(10.09f, 13.41f) + close() + moveTo(5.35f, 16.96f) + quadTo(2.35f, 14.93f, 1f, 11.5f) + quadTo(2.35f, 8.07f, 5.35f, 6.04f) + reflectiveQuadTo(12f, 4f) + reflectiveQuadToRelative(6.65f, 2.04f) + reflectiveQuadTo(23f, 11.5f) + quadToRelative(-1.35f, 3.42f, -4.35f, 5.46f) + reflectiveQuadTo(12f, 19f) + reflectiveQuadTo(5.35f, 16.96f) + close() + moveTo(12f, 11.5f) + close() + moveToRelative(5.19f, 4.01f) + quadTo(19.55f, 14.02f, 20.8f, 11.5f) + quadTo(19.55f, 8.98f, 17.19f, 7.49f) + reflectiveQuadTo(12f, 6f) + quadTo(9.18f, 6f, 6.81f, 7.49f) + reflectiveQuadTo(3.2f, 11.5f) + quadToRelative(1.25f, 2.52f, 3.61f, 4.01f) + reflectiveQuadTo(12f, 17f) + reflectiveQuadToRelative(5.19f, -1.49f) + close() + } + } + .build() + return _Visibility!! + } + + private var _Visibility: ImageVector? = null + + // https://fonts.gstatic.com/render/v1/Material+Symbols+Outlined/24dp/visibility_off.kt?var=opsz,wght,FILL,GRAD,ROND@24,400,0,0,50 + val VisibilityOff: ImageVector + get() { + if (_VisibilityOff != null) { + return _VisibilityOff!! + } + _VisibilityOff = + ImageVector.Builder( + name = "visibility_off", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(16.1f, 13.3f) + lineTo(14.65f, 11.85f) + quadToRelative(0.22f, -1.18f, -0.67f, -2.2f) + quadTo(13.08f, 8.63f, 11.65f, 8.85f) + lineTo(10.2f, 7.4f) + quadTo(10.63f, 7.2f, 11.06f, 7.1f) + reflectiveQuadTo(12f, 7f) + quadToRelative(1.88f, 0f, 3.19f, 1.31f) + reflectiveQuadTo(16.5f, 11.5f) + quadToRelative(0f, 0.5f, -0.1f, 0.94f) + reflectiveQuadTo(16.1f, 13.3f) + close() + moveToRelative(3.2f, 3.15f) + lineToRelative(-1.45f, -1.4f) + quadToRelative(0.95f, -0.72f, 1.69f, -1.59f) + reflectiveQuadTo(20.8f, 11.5f) + quadTo(19.55f, 8.98f, 17.21f, 7.49f) + reflectiveQuadTo(12f, 6f) + quadTo(11.28f, 6f, 10.58f, 6.1f) + reflectiveQuadTo(9.2f, 6.4f) + lineTo(7.65f, 4.85f) + quadTo(8.68f, 4.42f, 9.75f, 4.21f) + reflectiveQuadTo(12f, 4f) + quadToRelative(3.78f, 0f, 6.73f, 2.09f) + reflectiveQuadTo(23f, 11.5f) + quadToRelative(-0.57f, 1.47f, -1.51f, 2.74f) + reflectiveQuadTo(19.3f, 16.45f) + close() + moveToRelative(0.5f, 6.15f) + lineTo(15.6f, 18.45f) + quadToRelative(-0.88f, 0.28f, -1.76f, 0.41f) + reflectiveQuadTo(12f, 19f) + quadTo(8.23f, 19f, 5.28f, 16.91f) + reflectiveQuadTo(1f, 11.5f) + quadTo(1.53f, 10.17f, 2.33f, 9.04f) + reflectiveQuadTo(4.15f, 7f) + lineTo(1.4f, 4.2f) + lineTo(2.8f, 2.8f) + lineTo(21.2f, 21.2f) + lineToRelative(-1.4f, 1.4f) + close() + moveTo(5.55f, 8.4f) + quadTo(4.83f, 9.05f, 4.23f, 9.82f) + reflectiveQuadTo(3.2f, 11.5f) + quadToRelative(1.25f, 2.52f, 3.59f, 4.01f) + reflectiveQuadTo(12f, 17f) + quadToRelative(0.5f, 0f, 0.98f, -0.06f) + reflectiveQuadTo(13.95f, 16.8f) + lineToRelative(-0.9f, -0.95f) + quadToRelative(-0.28f, 0.07f, -0.53f, 0.11f) + reflectiveQuadTo(12f, 16f) + quadTo(10.13f, 16f, 8.81f, 14.69f) + reflectiveQuadTo(7.5f, 11.5f) + quadToRelative(0f, -0.28f, 0.04f, -0.53f) + reflectiveQuadTo(7.65f, 10.45f) + lineTo(5.55f, 8.4f) + close() + moveToRelative(7.98f, 2.33f) + close() + moveTo(9.75f, 12.6f) + close() + } + } + .build() + return _VisibilityOff!! + } + + private var _VisibilityOff: ImageVector? = null +} diff --git a/app/src/main/java/com/chiller3/rsaf/ui/theme/Theme.kt b/app/src/main/java/com/chiller3/rsaf/ui/theme/Theme.kt new file mode 100644 index 0000000..4051752 --- /dev/null +++ b/app/src/main/java/com/chiller3/rsaf/ui/theme/Theme.kt @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.rsaf.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +fun AppTheme(content: @Composable () -> Unit) { + val darkTheme = isSystemInDarkTheme() + + val colorScheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } + } + darkTheme -> darkColorScheme() + else -> lightColorScheme() + } + + MaterialTheme( + colorScheme = colorScheme, + content = content, + ) +} diff --git a/app/src/main/java/com/chiller3/rsaf/view/LongClickablePreference.kt b/app/src/main/java/com/chiller3/rsaf/view/LongClickablePreference.kt deleted file mode 100644 index 3baf204..0000000 --- a/app/src/main/java/com/chiller3/rsaf/view/LongClickablePreference.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022-2023 Andrew Gunnerson - * SPDX-License-Identifier: GPL-3.0-only - * Based on BCR code. - */ - -package com.chiller3.rsaf.view - -import android.content.Context -import android.util.AttributeSet -import androidx.preference.Preference -import androidx.preference.PreferenceViewHolder - -/** - * A thin shell over [Preference] that allows registering a long click listener. - */ -class LongClickablePreference : Preference { - var onPreferenceLongClickListener: OnPreferenceLongClickListener? = null - - @Suppress("unused") - constructor(context: Context) : super(context) - - @Suppress("unused") - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - - @Suppress("unused") - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : - super(context, attrs, defStyleAttr) - - override fun onBindViewHolder(holder: PreferenceViewHolder) { - super.onBindViewHolder(holder) - - val listener = onPreferenceLongClickListener - if (listener == null) { - holder.itemView.setOnLongClickListener(null) - holder.itemView.isLongClickable = false - } else { - holder.itemView.setOnLongClickListener { - listener.onPreferenceLongClick(this) - } - } - } -} - -interface OnPreferenceLongClickListener { - fun onPreferenceLongClick(preference: Preference): Boolean -} diff --git a/app/src/main/res/layout/dialog_authorize.xml b/app/src/main/res/layout/dialog_authorize.xml deleted file mode 100644 index 5e3256d..0000000 --- a/app/src/main/res/layout/dialog_authorize.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/dialog_interactive_configuration.xml b/app/src/main/res/layout/dialog_interactive_configuration.xml deleted file mode 100644 index 2cf5f5e..0000000 --- a/app/src/main/res/layout/dialog_interactive_configuration.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_text_input.xml b/app/src/main/res/layout/dialog_text_input.xml deleted file mode 100644 index 7504af5..0000000 --- a/app/src/main/res/layout/dialog_text_input.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_vfs_options.xml b/app/src/main/res/layout/dialog_vfs_options.xml deleted file mode 100644 index b87f34a..0000000 --- a/app/src/main/res/layout/dialog_vfs_options.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/material_switch_preference.xml b/app/src/main/res/layout/material_switch_preference.xml deleted file mode 100644 index 49d8bc7..0000000 --- a/app/src/main/res/layout/material_switch_preference.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/app/src/main/res/layout/settings_activity.xml b/app/src/main/res/layout/settings_activity.xml deleted file mode 100644 index cba7b8a..0000000 --- a/app/src/main/res/layout/settings_activity.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 615f1d5..9a5a80a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -10,7 +10,6 @@ 远程存储 配置 行为 - 界面 应用锁定 高级 关于 @@ -33,8 +32,6 @@ 将 rclone 远程存储作为本地存储呈现给存储访问框架。这将强制与只允许选择本地文件的应用兼容。 允许本地存储访问 允许包装远程存储(如 crypt)访问 /sdcard 下的本地存储。 - 在底部显示对话框 - 使单手操作更容易,并防止对话框按钮移动。 需要身份验证 需要生物识别解锁或屏幕锁定 PIN/密码才能查看和更改 RSAF 设置。 非活动超时 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20efd65..99b1a62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,7 +9,6 @@ Remote Configuration Behavior - UI App lock Advanced About @@ -32,8 +31,6 @@ Present the rclone remotes as local storage to the Storage Access Framework. This forces compatibility with apps that only allow selecting local files. Allow local storage access Allows wrapper remotes, like crypt, to access local storage under /sdcard. - Show dialogs at bottom - Makes one-handed use easier and prevents dialog buttons from shifting. Require authentication Require biometric unlock or screen lock PIN/password to view and change RSAF settings. Inactivity timeout @@ -83,6 +80,8 @@ Details Error details + No web browser is available. + Android\'s builtin file manager (DocumentsUI) is unavailable. Failed to get list of remotes diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml deleted file mode 100644 index 5d3e8e5..0000000 --- a/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 767b144..a1a1259 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,10 +1,21 @@ - diff --git a/app/src/main/res/xml/preferences_edit_remote.xml b/app/src/main/res/xml/preferences_edit_remote.xml deleted file mode 100644 index 37043c8..0000000 --- a/app/src/main/res/xml/preferences_edit_remote.xml +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/xml/preferences_root.xml b/app/src/main/res/xml/preferences_root.xml deleted file mode 100644 index ba72f23..0000000 --- a/app/src/main/res/xml/preferences_root.xml +++ /dev/null @@ -1,168 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 02579f1..8927fd9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,11 @@ /* - * SPDX-FileCopyrightText: 2023 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.kotlin.parcelize) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ba419c9..56139c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,34 +1,41 @@ [versions] -activity-ktx = "1.13.0" android-gradle-plugin = "9.2.1" -appcompat = "1.7.1" -biometric = "1.1.0" -core-ktx = "1.18.0" +androidx-activity = "1.13.0" +androidx-biometric = "1.4.0-alpha05" +androidx-compose-bom = "2026.05.01" +androidx-compose-material3 = "1.5.0-alpha18" +androidx-core = "1.18.0" +androidx-exifinterface = "1.4.2" +androidx-lifecycle-viewmodel-compose = "2.11.0-alpha03" +androidx-preference = "1.2.1" +androidx-test-espresso-core = "3.7.0" jgit = "7.6.0.202603022253-r" -espresso-core = "3.7.0" -exifinterface = "1.4.2" -fragment-ktx = "1.8.9" junit = "1.3.0" +kotlin = "2.3.21" material = "1.14.0" -preference-ktx = "1.2.1" spotbugs = "4.9.8" tink-android = "1.21.0" [libraries] -activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-ktx" } -appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } -core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } -espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } -exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" } -fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment-ktx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity" } +androidx-biometric-compose = { group = "androidx.biometric", name = "biometric-compose", version.ref = "androidx-biometric" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-compose-material3" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } +androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "androidx-exifinterface" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-viewmodel-compose" } +androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "androidx-preference" } +androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-test-espresso-core" } jgit = { group = "org.eclipse.jgit", name = "org.eclipse.jgit", version.ref = "jgit" } jgit-archive = { group = "org.eclipse.jgit", name = "org.eclipse.jgit.archive", version.ref = "jgit" } junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } -preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference-ktx" } spotbugs = { group = "com.github.spotbugs", name = "spotbugs-annotations", version.ref = "spotbugs" } tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink-android" } [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 3f6e13c..8460567 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -18,6 +18,14 @@ + + + + + + + + @@ -26,14 +34,9 @@ - - - - - - - - + + + @@ -77,20 +80,20 @@ - - - + + + - - + + - - - + + + - - + + @@ -106,14 +109,6 @@ - - - - - - - - @@ -122,12 +117,28 @@ - - - + + + - - + + + + + + + + + + + + + + + + + + @@ -144,53 +155,311 @@ - - - - - - + + + - - - + + + - - + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -246,11 +515,6 @@ - - - - - @@ -291,6 +555,14 @@ + + + + + + + + @@ -307,19 +579,6 @@ - - - - - - - - - - - - - @@ -337,26 +596,36 @@ - - - + + + + + + + + - - - + + + + + + + + @@ -365,25 +634,43 @@ - - - + + + + + + + + + + + + + + + + + + + + + - - - + + + - - + + - - - + + + - - + + @@ -415,6 +702,11 @@ + + + + + @@ -426,63 +718,86 @@ - - - - - - + + + + + + + + - - + + - - - + + + - - - + + + - - - + + + - - + + - - - + + + + + + + + + + + - - - + + + - - + + - - - + + + - - - + + + - - + + + + + + + + + + + + + + + @@ -490,56 +805,130 @@ + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - + + + + + + + - - - + + + - - - + + + - - + + - - - + + + + + + - - - + + + - - + + + + @@ -547,12 +936,12 @@ - - - + + + - - + + @@ -576,6 +965,19 @@ + + + + + + + + + + + + + @@ -592,11 +994,6 @@ - - - - - @@ -622,21 +1019,79 @@ - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -645,11 +1100,6 @@ - - - - - @@ -743,6 +1193,14 @@ + + + + + + + + @@ -783,12 +1241,25 @@ - - - + + + - - + + + + + + + + + + + + + + + @@ -2729,20 +3200,36 @@ - - - + + + - - + + - - - + + + - - + + + + + + + + + + + + + + + + + + @@ -2750,135 +3237,164 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + + + + - - + + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + @@ -2913,44 +3429,12 @@ - - - - - - - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - - - - - + + @@ -2969,6 +3453,14 @@ + + + + + + + + @@ -2985,6 +3477,14 @@ + + + + + + + + @@ -3006,11 +3506,6 @@ - - - - - @@ -3019,11 +3514,6 @@ - - - - - @@ -3032,11 +3522,6 @@ - - - - - @@ -3045,11 +3530,6 @@ - - - - - @@ -3058,36 +3538,46 @@ - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + + + + - - + + + + + + + + + @@ -3103,9 +3593,9 @@ - - - + + + @@ -3190,6 +3680,24 @@ + + + + + + + + + + + + + + + + + + @@ -3275,6 +3783,14 @@ + + + + + + + + @@ -3299,6 +3815,14 @@ + + + + + + + +