be3f46287a
CRITICAL - SftpProvider: replace PromiscuousVerifier with TofuHostKeyVerifier (trust-on-first-use; stores SHA-256 fingerprints in EncryptedSharedPreferences; rejects key changes on subsequent connections) HIGH - GoogleDriveProvider: replace raw string interpolation with buildJsonObject in uploadFile, createDirectory, and moveFile to prevent JSON injection - DropboxProvider: replace all raw JSON strings and Dropbox-API-Arg headers with buildJsonObject for the same reason - OAuthHelper: add cryptographically random state parameter to Dropbox and OneDrive authorization URLs (stored alongside the PKCE verifier) - OAuthRedirectActivity: validate returned state against stored value before exchanging the authorization code (CSRF protection) MEDIUM - WebDavProvider: block cross-host redirects in the manual redirect interceptor so Authorization headers are never forwarded to a different server - AccountSetupScreen: set FLAG_SECURE on the window while credential fields are visible to prevent screenshots and screen-recording capture - libs.versions.toml: security-crypto alpha06 → stable 1.0.0; biometric-ktx alpha05 → biometric 1.1.0 (stable, non-ktx artifact matches the BiometricManager/BiometricPrompt API actually used in MainActivity) - CredentialStore: migrate to security-crypto 1.0.0 API (MasterKeys.getOrCreate + positional create() args); add saveHostKey/getHostFingerprint for SFTP TOFU Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
505 lines
21 KiB
Kotlin
505 lines
21 KiB
Kotlin
package com.syncflow.ui.auth
|
|
|
|
import android.accounts.AccountManager
|
|
import android.app.Activity
|
|
import android.view.WindowManager
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.layout.*
|
|
import androidx.compose.foundation.lazy.grid.GridCells
|
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
import androidx.compose.foundation.lazy.grid.items
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.filled.*
|
|
import androidx.compose.material3.*
|
|
import androidx.compose.runtime.*
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.input.ImeAction
|
|
import androidx.compose.ui.text.input.KeyboardType
|
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
import androidx.compose.ui.text.input.VisualTransformation
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import com.syncflow.R
|
|
import com.syncflow.domain.model.ProviderType
|
|
|
|
// All providers in display order
|
|
private val ALL_PROVIDERS = listOf(
|
|
ProviderType.NEXTCLOUD,
|
|
ProviderType.OWNCLOUD,
|
|
ProviderType.SFTPGO,
|
|
ProviderType.WEBDAV,
|
|
ProviderType.SFTP,
|
|
ProviderType.GOOGLE_DRIVE,
|
|
ProviderType.DROPBOX,
|
|
ProviderType.ONEDRIVE,
|
|
)
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun AccountSetupScreen(
|
|
onDone: () -> Unit,
|
|
vm: AccountSetupViewModel = hiltViewModel(),
|
|
) {
|
|
val state by vm.state.collectAsState()
|
|
val context = LocalContext.current
|
|
|
|
LaunchedEffect(state.done) { if (state.done) onDone() }
|
|
|
|
val googleAccountLauncher = rememberLauncherForActivityResult(
|
|
ActivityResultContracts.StartActivityForResult()
|
|
) { result ->
|
|
if (result.resultCode == Activity.RESULT_OK) {
|
|
val name = result.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
|
|
if (name != null) vm.onGoogleAccountChosen(name)
|
|
}
|
|
}
|
|
|
|
Scaffold(
|
|
topBar = {
|
|
TopAppBar(
|
|
title = {
|
|
Text(
|
|
if (state.providerType == null) "Choose a service" else state.providerType!!.friendlyName(),
|
|
fontWeight = FontWeight.SemiBold,
|
|
)
|
|
},
|
|
navigationIcon = {
|
|
IconButton(onClick = {
|
|
if (state.providerType != null) vm.update { copy(providerType = null, testResult = null, error = null) }
|
|
else onDone()
|
|
}) {
|
|
Icon(
|
|
if (state.providerType != null) Icons.Default.ArrowBack else Icons.Default.Close,
|
|
contentDescription = null,
|
|
)
|
|
}
|
|
},
|
|
actions = {
|
|
if (state.providerType != null) {
|
|
TextButton(
|
|
onClick = vm::save,
|
|
enabled = !state.isSaving && state.testResult is TestResult.Success,
|
|
) {
|
|
if (state.isSaving) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
|
else Text("Save")
|
|
}
|
|
}
|
|
},
|
|
)
|
|
},
|
|
) { padding ->
|
|
if (state.providerType == null) {
|
|
ProviderPickerContent(
|
|
modifier = Modifier.padding(padding),
|
|
onPick = { vm.update { copy(providerType = it) } },
|
|
)
|
|
} else {
|
|
CredentialContent(
|
|
state = state,
|
|
modifier = Modifier.padding(padding),
|
|
vm = vm,
|
|
onDropboxConnect = {
|
|
launchDropboxOAuth(context, vm.credentialStore, context.getString(R.string.dropbox_app_key))
|
|
},
|
|
onGoogleSignIn = {
|
|
val intent = AccountManager.newChooseAccountIntent(
|
|
null, null, arrayOf("com.google"), null, null, null, null,
|
|
)
|
|
googleAccountLauncher.launch(intent)
|
|
},
|
|
onOneDriveSignIn = {
|
|
launchOneDriveOAuth(context, vm.credentialStore, context.getString(R.string.onedrive_client_id))
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Step 1: Provider picker grid ────────────────────────────────────────────
|
|
|
|
@Composable
|
|
private fun ProviderPickerContent(modifier: Modifier = Modifier, onPick: (ProviderType) -> Unit) {
|
|
Column(modifier = modifier.fillMaxSize()) {
|
|
Text(
|
|
"Select the service you want to sync with",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
|
)
|
|
LazyVerticalGrid(
|
|
columns = GridCells.Fixed(2),
|
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
modifier = Modifier.fillMaxSize(),
|
|
) {
|
|
items(ALL_PROVIDERS) { provider ->
|
|
ProviderCard(provider = provider, onClick = { onPick(provider) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ProviderCard(provider: ProviderType, onClick: () -> Unit) {
|
|
ElevatedCard(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.aspectRatio(1.4f)
|
|
.clip(MaterialTheme.shapes.large)
|
|
.clickable(onClick = onClick),
|
|
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 3.dp),
|
|
) {
|
|
Column(
|
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.Center,
|
|
) {
|
|
Image(
|
|
painter = painterResource(provider.iconRes()),
|
|
contentDescription = null,
|
|
modifier = Modifier.size(48.dp),
|
|
)
|
|
Spacer(Modifier.height(10.dp))
|
|
Text(
|
|
provider.friendlyName(),
|
|
style = MaterialTheme.typography.titleSmall,
|
|
fontWeight = FontWeight.Medium,
|
|
)
|
|
provider.subtitle()?.let { sub ->
|
|
Text(
|
|
sub,
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Step 2: Credential form ──────────────────────────────────────────────────
|
|
|
|
@Composable
|
|
private fun CredentialContent(
|
|
state: AccountSetupState,
|
|
modifier: Modifier,
|
|
vm: AccountSetupViewModel,
|
|
onDropboxConnect: () -> Unit,
|
|
onGoogleSignIn: () -> Unit,
|
|
onOneDriveSignIn: () -> Unit,
|
|
) {
|
|
val provider = state.providerType ?: return
|
|
|
|
// Prevent screenshots and screen recording while credentials are visible
|
|
val activity = LocalContext.current as? Activity
|
|
DisposableEffect(Unit) {
|
|
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
|
onDispose {
|
|
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
|
}
|
|
}
|
|
|
|
Column(
|
|
modifier = modifier
|
|
.padding(horizontal = 20.dp)
|
|
.verticalScroll(rememberScrollState()),
|
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
) {
|
|
Spacer(Modifier.height(4.dp))
|
|
|
|
// Account display name
|
|
OutlinedTextField(
|
|
value = state.displayName,
|
|
onValueChange = { vm.update { copy(displayName = it) } },
|
|
label = { Text("Account name (optional)") },
|
|
placeholder = { Text(provider.friendlyName()) },
|
|
leadingIcon = { Icon(Icons.Default.Badge, null) },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
|
)
|
|
|
|
HorizontalDivider()
|
|
|
|
// Provider-specific fields
|
|
when {
|
|
provider == ProviderType.GOOGLE_DRIVE -> OAuthSection(
|
|
providerName = "Google Drive",
|
|
email = state.oauthEmail,
|
|
isConnected = state.oauthToken.isNotBlank(),
|
|
onConnect = onGoogleSignIn,
|
|
connectLabel = "Choose Google Account",
|
|
description = "Picks any Google account already added to this device.",
|
|
)
|
|
provider == ProviderType.DROPBOX -> OAuthSection(
|
|
providerName = "Dropbox",
|
|
email = state.oauthEmail,
|
|
isConnected = state.oauthToken.isNotBlank(),
|
|
onConnect = onDropboxConnect,
|
|
connectLabel = "Authorize with Dropbox",
|
|
description = "Opens Dropbox in your browser. Works with any Dropbox account.",
|
|
)
|
|
provider == ProviderType.ONEDRIVE -> OAuthSection(
|
|
providerName = "OneDrive",
|
|
email = state.oauthEmail,
|
|
isConnected = state.oauthToken.isNotBlank(),
|
|
onConnect = onOneDriveSignIn,
|
|
connectLabel = "Sign in with Microsoft",
|
|
description = "Personal, work, or school Microsoft account.",
|
|
)
|
|
provider == ProviderType.SFTP -> SftpFields(state, vm)
|
|
else -> ServerFields(provider, state, vm)
|
|
}
|
|
|
|
// Test Connection (required before Save)
|
|
HorizontalDivider()
|
|
Button(
|
|
onClick = vm::testConnection,
|
|
enabled = !state.isTestingConnection && credentialsFilled(state),
|
|
modifier = Modifier.fillMaxWidth(),
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = when (state.testResult) {
|
|
is TestResult.Success -> MaterialTheme.colorScheme.primary
|
|
is TestResult.Failure -> MaterialTheme.colorScheme.error
|
|
null -> MaterialTheme.colorScheme.secondary
|
|
},
|
|
),
|
|
) {
|
|
when {
|
|
state.isTestingConnection -> {
|
|
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = Color.White)
|
|
Spacer(Modifier.width(10.dp))
|
|
Text("Testing…")
|
|
}
|
|
state.testResult is TestResult.Success -> {
|
|
Icon(Icons.Default.CheckCircle, null, Modifier.size(18.dp))
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Connected — tap Save ↑")
|
|
}
|
|
state.testResult is TestResult.Failure -> {
|
|
Icon(Icons.Default.Refresh, null, Modifier.size(18.dp))
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Retry Test Connection")
|
|
}
|
|
else -> {
|
|
Icon(Icons.Default.NetworkCheck, null, Modifier.size(18.dp))
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Test Connection")
|
|
}
|
|
}
|
|
}
|
|
|
|
when (val r = state.testResult) {
|
|
is TestResult.Failure -> Text(r.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
|
is TestResult.Success -> Text("Connection successful!", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
|
|
null -> if (credentialsFilled(state)) Text("Test required before saving.", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
|
|
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
|
|
|
|
Spacer(Modifier.height(32.dp))
|
|
}
|
|
}
|
|
|
|
// ─── Credential field composables ────────────────────────────────────────────
|
|
|
|
@Composable
|
|
private fun OAuthSection(
|
|
providerName: String,
|
|
email: String,
|
|
isConnected: Boolean,
|
|
onConnect: () -> Unit,
|
|
connectLabel: String,
|
|
description: String,
|
|
) {
|
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
if (!isConnected) {
|
|
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
} else {
|
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
|
Column {
|
|
Text("Authorized", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
|
if (email.isNotBlank()) Text(email, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
}
|
|
}
|
|
OutlinedButton(onClick = onConnect, modifier = Modifier.fillMaxWidth()) {
|
|
Icon(if (isConnected) Icons.Default.SwitchAccount else Icons.Default.Login, null, Modifier.size(18.dp))
|
|
Spacer(Modifier.width(8.dp))
|
|
Text(if (isConnected) "Switch account" else connectLabel)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ServerFields(provider: ProviderType, state: AccountSetupState, vm: AccountSetupViewModel) {
|
|
val urlHint = when (provider) {
|
|
ProviderType.NEXTCLOUD -> "https://cloud.example.com"
|
|
ProviderType.OWNCLOUD -> "https://owncloud.example.com"
|
|
ProviderType.SFTPGO -> "https://sftpgo.example.com"
|
|
else -> "https://dav.example.com"
|
|
}
|
|
val urlNote = when (provider) {
|
|
ProviderType.NEXTCLOUD -> "Just the base URL — WebDAV path is added automatically."
|
|
ProviderType.OWNCLOUD -> "Just the base URL — WebDAV path is added automatically."
|
|
ProviderType.SFTPGO -> "Base URL only — SFTPGo WebDAV path added automatically."
|
|
else -> null
|
|
}
|
|
|
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
OutlinedTextField(
|
|
value = state.serverUrl,
|
|
onValueChange = { vm.update { copy(serverUrl = it) } },
|
|
label = { Text("Server URL") },
|
|
placeholder = { Text(urlHint) },
|
|
leadingIcon = { Icon(Icons.Default.Language, null) },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
|
)
|
|
urlNote?.let { Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
|
if (state.httpWarning) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Icon(Icons.Default.Warning, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
|
|
Text(
|
|
"HTTP sends credentials in plaintext. Use HTTPS for security.",
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.error,
|
|
)
|
|
}
|
|
}
|
|
OutlinedTextField(
|
|
value = state.username,
|
|
onValueChange = { vm.update { copy(username = it) } },
|
|
label = { Text("Username") },
|
|
leadingIcon = { Icon(Icons.Default.Person, null) },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
|
)
|
|
PasswordField(value = state.password, onValueChange = { vm.update { copy(password = it) } }, label = "Password")
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun SftpFields(state: AccountSetupState, vm: AccountSetupViewModel) {
|
|
var useKey by remember { mutableStateOf(false) }
|
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
OutlinedTextField(
|
|
value = state.serverUrl,
|
|
onValueChange = { vm.update { copy(serverUrl = it) } },
|
|
label = { Text("Hostname or IP") },
|
|
placeholder = { Text("sftp.example.com") },
|
|
leadingIcon = { Icon(Icons.Default.Dns, null) },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
|
)
|
|
OutlinedTextField(
|
|
value = state.port,
|
|
onValueChange = { vm.update { copy(port = it) } },
|
|
label = { Text("Port") },
|
|
placeholder = { Text("22") },
|
|
leadingIcon = { Icon(Icons.Default.SettingsEthernet, null) },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
|
|
)
|
|
OutlinedTextField(
|
|
value = state.username,
|
|
onValueChange = { vm.update { copy(username = it) } },
|
|
label = { Text("Username") },
|
|
leadingIcon = { Icon(Icons.Default.Person, null) },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
|
)
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Switch(checked = useKey, onCheckedChange = { useKey = it })
|
|
Spacer(Modifier.width(10.dp))
|
|
Text("Use SSH private key", style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
if (useKey) {
|
|
OutlinedTextField(
|
|
value = state.privateKey,
|
|
onValueChange = { vm.update { copy(privateKey = it) } },
|
|
label = { Text("Private key (PEM)") },
|
|
placeholder = { Text("-----BEGIN RSA PRIVATE KEY-----\n…") },
|
|
modifier = Modifier.fillMaxWidth().height(120.dp),
|
|
)
|
|
} else {
|
|
PasswordField(value = state.password, onValueChange = { vm.update { copy(password = it) } }, label = "Password")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun PasswordField(value: String, onValueChange: (String) -> Unit, label: String) {
|
|
var visible by remember { mutableStateOf(false) }
|
|
OutlinedTextField(
|
|
value = value,
|
|
onValueChange = onValueChange,
|
|
label = { Text(label) },
|
|
leadingIcon = { Icon(Icons.Default.Lock, null) },
|
|
trailingIcon = {
|
|
IconButton(onClick = { visible = !visible }) {
|
|
Icon(if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility, null)
|
|
}
|
|
},
|
|
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
|
)
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
private fun credentialsFilled(state: AccountSetupState): Boolean {
|
|
val p = state.providerType ?: return false
|
|
return when {
|
|
p.isOAuth() -> state.oauthToken.isNotBlank()
|
|
p == ProviderType.SFTP -> state.serverUrl.isNotBlank() && state.username.isNotBlank()
|
|
else -> state.serverUrl.isNotBlank() && state.username.isNotBlank()
|
|
}
|
|
}
|
|
|
|
private fun ProviderType.iconRes() = when (this) {
|
|
ProviderType.NEXTCLOUD -> R.drawable.ic_provider_nextcloud
|
|
ProviderType.OWNCLOUD -> R.drawable.ic_provider_owncloud
|
|
ProviderType.SFTPGO -> R.drawable.ic_provider_sftpgo
|
|
ProviderType.WEBDAV -> R.drawable.ic_provider_webdav
|
|
ProviderType.SFTP -> R.drawable.ic_provider_sftp
|
|
ProviderType.GOOGLE_DRIVE -> R.drawable.ic_provider_googledrive
|
|
ProviderType.DROPBOX -> R.drawable.ic_provider_dropbox
|
|
ProviderType.ONEDRIVE -> R.drawable.ic_provider_onedrive
|
|
}
|
|
|
|
private fun ProviderType.subtitle() = when (this) {
|
|
ProviderType.NEXTCLOUD -> "Self-hosted"
|
|
ProviderType.OWNCLOUD -> "Self-hosted"
|
|
ProviderType.SFTPGO -> "Self-hosted"
|
|
ProviderType.WEBDAV -> "Any WebDAV server"
|
|
ProviderType.SFTP -> "SSH file transfer"
|
|
ProviderType.GOOGLE_DRIVE -> "Google account"
|
|
ProviderType.DROPBOX -> "Dropbox account"
|
|
ProviderType.ONEDRIVE -> "Microsoft account"
|
|
}
|