Skip to content

Commit

Permalink
Add support for disabling VFS caching
Browse files Browse the repository at this point in the history
VFS caching can now be configured per-remote. Disabling it is equivalent
to `rclone mount --vfs-cache-mode none`. This will disable support for
random writes and prevent failed uploads from being retried. However, it
allows uploads to begin immediately and allows the client to show more
accurate file write progress.

If a remote type does not support streaming, VFS caching will be force
enabled and the UI option will be grayed out. This is preferable to
allowing the user to set a broken configuration and not knowing until
they try to create a file and having it fail.

Internally, RSAF now maintains two sets of VFS instances. One set with
caching VFSs and another set with streaming VFSs. This allows the
caching option to be toggled immediately without waiting for open files
to be closed (and fully uploaded). The downside is that if the user
toggles the option while uploads are occurring, the directory listings
may be inconsistent until the uploads complete.

Issue: #79

Signed-off-by: Andrew Gunnerson <accounts+github@chiller3.com>
  • Loading branch information
chenxiaolong committed Oct 23, 2024
1 parent 066b993 commit 232594b
Show file tree
Hide file tree
Showing 9 changed files with 195 additions and 42 deletions.
1 change: 1 addition & 0 deletions app/src/main/java/com/chiller3/rsaf/Preferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class Preferences(private val context: Context) {
const val PREF_DELETE_REMOTE = "delete_remote"
const val PREF_ALLOW_EXTERNAL_ACCESS = "allow_external_access"
const val PREF_DYNAMIC_SHORTCUT = "dynamic_shortcut"
const val PREF_VFS_CACHING = "vfs_caching"

// Not associated with a UI preference
const val PREF_DEBUG_MODE = "debug_mode"
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/com/chiller3/rsaf/rclone/RcloneProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ class RcloneProvider : DocumentsProvider(), SharedPreferences.OnSharedPreference
val config = configs[remote]
?: throw IllegalArgumentException("Remote does not exist: $remote")

if (config[RcloneRpc.CUSTOM_OPT_BLOCKED] == "true") {
if (RcloneRpc.getCustomBoolOpt(config, RcloneRpc.CUSTOM_OPT_BLOCKED)) {
throw SecurityException("Access to remote is blocked: $remote")
}
}
Expand All @@ -393,7 +393,7 @@ class RcloneProvider : DocumentsProvider(), SharedPreferences.OnSharedPreference
}

for ((remote, config) in RcloneRpc.remotes) {
if (config[RcloneRpc.CUSTOM_OPT_BLOCKED] == "true") {
if (RcloneRpc.getCustomBoolOpt(config, RcloneRpc.CUSTOM_OPT_BLOCKED)) {
debugLog("Skipping blocked remote: $remote")
continue
}
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/com/chiller3/rsaf/rclone/RcloneRpc.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ object RcloneRpc {
// This is called hidden due to backwards compatibility.
const val CUSTOM_OPT_BLOCKED = CUSTOM_OPT_PREFIX + "hidden"
const val CUSTOM_OPT_DYNAMIC_SHORTCUT = CUSTOM_OPT_PREFIX + "dynamic_shortcut"
const val CUSTOM_OPT_VFS_CACHING = CUSTOM_OPT_PREFIX + "vfs_caching"

private const val DEFAULT_BLOCKED = false
private const val DEFAULT_DYNAMIC_SHORTCUT = false
private const val DEFAULT_VFS_CACHING = true

/**
* Perform an rclone RPC call.
Expand Down Expand Up @@ -386,4 +391,16 @@ object RcloneRpc {

return output.getString("obscured")
}

/** Get the custom option boolean value or return the default if unset or invalid. */
fun getCustomBoolOpt(config: Map<String, String>, opt: String): Boolean {
val default = when (opt) {
CUSTOM_OPT_BLOCKED -> DEFAULT_BLOCKED
CUSTOM_OPT_DYNAMIC_SHORTCUT -> DEFAULT_DYNAMIC_SHORTCUT
CUSTOM_OPT_VFS_CACHING -> DEFAULT_VFS_CACHING
else -> throw IllegalArgumentException("Invalid custom option: $opt")
}

return config[opt]?.toBooleanStrictOrNull() ?: default
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ sealed interface EditRemoteAlert {
data class UpdateExternalAccessFailed(val remote: String, val error: String) : EditRemoteAlert

data class UpdateDynamicShortcutFailed(val remote: String, val error: String) : EditRemoteAlert

data class UpdateVfsCachingFailed(val remote: String, val error: String) : EditRemoteAlert
}
39 changes: 29 additions & 10 deletions app/src/main/java/com/chiller3/rsaf/settings/EditRemoteFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class EditRemoteFragment : PreferenceBaseFragment(), FragmentResultListener,
private lateinit var prefDeleteRemote: Preference
private lateinit var prefAllowExternalAccess: SwitchPreferenceCompat
private lateinit var prefDynamicShortcut: SwitchPreferenceCompat
private lateinit var prefVfsCaching: SwitchPreferenceCompat

private lateinit var remote: String

Expand Down Expand Up @@ -85,6 +86,9 @@ class EditRemoteFragment : PreferenceBaseFragment(), FragmentResultListener,
prefDynamicShortcut = findPreference(Preferences.PREF_DYNAMIC_SHORTCUT)!!
prefDynamicShortcut.onPreferenceChangeListener = this

prefVfsCaching = findPreference(Preferences.PREF_VFS_CACHING)!!
prefVfsCaching.onPreferenceChangeListener = this

remote = requireArguments().getString(ARG_REMOTE)!!
viewModel.setRemote(remote)

Expand All @@ -100,16 +104,26 @@ class EditRemoteFragment : PreferenceBaseFragment(), FragmentResultListener,
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
viewModel.remoteConfig.collect {
if (it != null) {
prefOpenRemote.isEnabled = it.allowExternalAccess
prefAllowExternalAccess.isEnabled = true
prefOpenRemote.isEnabled = it.allowExternalAccess ?: false

prefAllowExternalAccess.isEnabled = it.allowExternalAccess != null
if (it.allowExternalAccess != null) {
prefAllowExternalAccess.isChecked = it.allowExternalAccess
prefDynamicShortcut.isEnabled = it.allowExternalAccess
}

prefDynamicShortcut.isEnabled = it.allowExternalAccess ?: false
if (it.dynamicShortcut != null) {
prefDynamicShortcut.isChecked = it.dynamicShortcut
} else {
prefOpenRemote.isEnabled = false
prefAllowExternalAccess.isEnabled = false
prefDynamicShortcut.isEnabled = false
}

prefVfsCaching.isEnabled = it.allowExternalAccess ?: false && it.canStream ?: false
if (it.vfsCaching != null) {
prefVfsCaching.isChecked = it.vfsCaching
}
prefVfsCaching.summary = when (it.canStream) {
null -> getString(R.string.pref_edit_remote_vfs_caching_desc_loading)
true -> getString(R.string.pref_edit_remote_vfs_caching_desc_optional)
false -> getString(R.string.pref_edit_remote_vfs_caching_desc_required)
}
}
}
Expand Down Expand Up @@ -230,6 +244,9 @@ class EditRemoteFragment : PreferenceBaseFragment(), FragmentResultListener,
prefDynamicShortcut -> {
viewModel.setDynamicShortcut(remote, newValue as Boolean)
}
prefVfsCaching -> {
viewModel.setVfsCaching(remote, newValue as Boolean)
}
}

return false
Expand All @@ -253,6 +270,8 @@ class EditRemoteFragment : PreferenceBaseFragment(), FragmentResultListener,
getString(R.string.alert_update_external_access_failure, alert.remote, alert.error)
is EditRemoteAlert.UpdateDynamicShortcutFailed ->
getString(R.string.alert_update_dynamic_shortcut_failure, alert.remote, alert.error)
is EditRemoteAlert.UpdateVfsCachingFailed ->
getString(R.string.alert_update_vfs_caching_failure, alert.remote, alert.error)
}

Snackbar.make(requireView(), msg, Snackbar.LENGTH_LONG)
Expand All @@ -273,8 +292,8 @@ class EditRemoteFragment : PreferenceBaseFragment(), FragmentResultListener,
var rank = 0

for ((remote, config) in remotes) {
if (config[RcloneRpc.CUSTOM_OPT_BLOCKED] == "true"
|| config[RcloneRpc.CUSTOM_OPT_DYNAMIC_SHORTCUT] != "true") {
if (RcloneRpc.getCustomBoolOpt(config, RcloneRpc.CUSTOM_OPT_BLOCKED)
|| !RcloneRpc.getCustomBoolOpt(config, RcloneRpc.CUSTOM_OPT_DYNAMIC_SHORTCUT)) {
continue
}

Expand Down
61 changes: 50 additions & 11 deletions app/src/main/java/com/chiller3/rsaf/settings/EditRemoteViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package com.chiller3.rsaf.settings
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.chiller3.rsaf.binding.rcbridge.Rcbridge
import com.chiller3.rsaf.rclone.RcloneConfig
import com.chiller3.rsaf.rclone.RcloneRpc
import kotlinx.coroutines.Dispatchers
Expand All @@ -24,8 +25,10 @@ data class EditRemoteActivityActions(
)

data class RemoteConfigState(
val allowExternalAccess: Boolean,
val dynamicShortcut: Boolean,
val allowExternalAccess: Boolean? = null,
val dynamicShortcut: Boolean? = null,
val vfsCaching: Boolean? = null,
val canStream: Boolean? = null,
)

class EditRemoteViewModel : ViewModel() {
Expand All @@ -38,7 +41,7 @@ class EditRemoteViewModel : ViewModel() {
private val _remotes = MutableStateFlow<Map<String, Map<String, String>>>(emptyMap())
val remotes = _remotes.asStateFlow()

private val _remoteConfig = MutableStateFlow<RemoteConfigState?>(null)
private val _remoteConfig = MutableStateFlow(RemoteConfigState())
val remoteConfig = _remoteConfig.asStateFlow()

private val _alerts = MutableStateFlow<List<EditRemoteAlert>>(emptyList())
Expand All @@ -55,25 +58,43 @@ class EditRemoteViewModel : ViewModel() {
private suspend fun refreshRemotesInternal(force: Boolean) {
try {
if (_remotes.value.isEmpty() || force) {
val r = withContext(Dispatchers.IO) {
RcloneRpc.remotes
withContext(Dispatchers.IO) {
_remotes.update { RcloneRpc.remotes }
}

_remotes.update { r }
}

val config = remotes.value[remote]

if (config != null) {
_remoteConfig.update {
RemoteConfigState(
allowExternalAccess = config[RcloneRpc.CUSTOM_OPT_BLOCKED] != "true",
dynamicShortcut = config[RcloneRpc.CUSTOM_OPT_DYNAMIC_SHORTCUT] == "true",
it.copy(
allowExternalAccess = !RcloneRpc.getCustomBoolOpt(
config,
RcloneRpc.CUSTOM_OPT_BLOCKED,
),
dynamicShortcut = RcloneRpc.getCustomBoolOpt(
config,
RcloneRpc.CUSTOM_OPT_DYNAMIC_SHORTCUT,
),
vfsCaching = RcloneRpc.getCustomBoolOpt(
config,
RcloneRpc.CUSTOM_OPT_VFS_CACHING,
),
)
}

// Only calculate this once since the value can't change and it requires
// initializing the backend, which may perform network calls.
if (_remoteConfig.value.canStream == null) {
withContext(Dispatchers.IO) {
_remoteConfig.update {
it.copy(canStream = Rcbridge.rbCanStream("$remote:"))
}
}
}
} else {
// This will happen after renaming or deleting the remote.
_remoteConfig.update { null }
_remoteConfig.update { RemoteConfigState() }
}
} catch (e: Exception) {
Log.e(TAG, "Failed to refresh remotes", e)
Expand Down Expand Up @@ -124,6 +145,24 @@ class EditRemoteViewModel : ViewModel() {
}
}

fun setVfsCaching(remote: String, enabled: Boolean) {
viewModelScope.launch {
try {
withContext(Dispatchers.IO) {
RcloneRpc.setRemoteOptions(
remote, mapOf(
RcloneRpc.CUSTOM_OPT_VFS_CACHING to enabled.toString(),
)
)
}
refreshRemotesInternal(true)
} catch (e: Exception) {
Log.w(TAG, "Failed to set remote $remote VFS caching state to $enabled", e)
_alerts.update { it + EditRemoteAlert.UpdateVfsCachingFailed(remote, e.toString()) }
}
}
}

private fun copyRemote(oldRemote: String, newRemote: String, delete: Boolean) {
if (oldRemote == newRemote) {
throw IllegalStateException("Old and new remote names are the same")
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@
<string name="pref_edit_remote_allow_external_access_desc">Allow external apps to access this remote via the system file manager. Access is not needed if this remote is just a backend for another remote.</string>
<string name="pref_edit_remote_dynamic_shortcut_name">Show in launcher shortcuts</string>
<string name="pref_edit_remote_dynamic_shortcut_desc">Include this remote in the list of shortcuts when long pressing RSAF\'s launcher icon.</string>
<string name="pref_edit_remote_vfs_caching_name">Enable VFS caching</string>
<string name="pref_edit_remote_vfs_caching_desc_loading">(Checking if streaming is supported…)</string>
<string name="pref_edit_remote_vfs_caching_desc_optional">VFS caching enables support for random writes and allows failed uploads to be retried. However, files do not begin uploading until the client app closes them.</string>
<string name="pref_edit_remote_vfs_caching_desc_required">VFS caching cannot be disabled because this remote type does not support streaming uploads.</string>

<!-- Main alerts -->
<string name="alert_list_remotes_failure">Failed to get list of remotes: %1$s</string>
Expand All @@ -82,6 +86,7 @@
<string name="alert_duplicate_remote_failure">Failed to duplicate remote %1$s to %2$s: %3$s</string>
<string name="alert_update_external_access_failure">Failed to update external app access to remote %1$s: %2$s</string>
<string name="alert_update_dynamic_shortcut_failure">Failed to update launcher shortcut for remote %1$s: %2$s</string>
<string name="alert_update_vfs_caching_failure">Failed to update VFS caching for remote %1$s: %2$s</string>

<!-- Biometric -->
<string name="biometric_title">Unlock configuration</string>
Expand Down
10 changes: 9 additions & 1 deletion app/src/main/res/xml/preferences_edit_remote.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,21 @@
app:persistent="false"
app:title="@string/pref_edit_remote_allow_external_access_name"
app:summary="@string/pref_edit_remote_allow_external_access_desc"
app:iconSpaceReserved="false" />
app:iconSpaceReserved="false"
app:defaultValue="true" />

<SwitchPreferenceCompat
app:key="dynamic_shortcut"
app:persistent="false"
app:title="@string/pref_edit_remote_dynamic_shortcut_name"
app:summary="@string/pref_edit_remote_dynamic_shortcut_desc"
app:iconSpaceReserved="false" />

<SwitchPreferenceCompat
app:key="vfs_caching"
app:persistent="false"
app:title="@string/pref_edit_remote_vfs_caching_name"
app:iconSpaceReserved="false"
app:defaultValue="true" />
</PreferenceCategory>
</PreferenceScreen>
Loading

0 comments on commit 232594b

Please sign in to comment.