Files
SyncFlow/app/src/main/kotlin/com/syncflow/ui/auth/AccountSetupScreen.kt
T
amir be3f46287a security: fix all review findings, bump to 1.0.19 (build 20)
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>
2026-05-24 18:08:40 +00:00

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"
}