Initial commit — SyncFlow Android file sync app
Supports WebDAV, SFTP, SFTPGo, Nextcloud, ownCloud, Google Drive, Dropbox, and OneDrive. Credentials encrypted with Android Keystore. Biometric app-lock, conflict resolution, and auto-sync via WorkManager. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
package com.syncflow.ui.auth
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Activity
|
||||
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
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.syncflow.ui.auth
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.syncflow.data.db.entities.CloudAccountEntity
|
||||
import com.syncflow.data.providers.ProviderFactory
|
||||
import com.syncflow.data.repository.AccountRepository
|
||||
import com.syncflow.domain.model.ProviderType
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.*
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AccountSetupState(
|
||||
val providerType: ProviderType? = null,
|
||||
val displayName: String = "",
|
||||
val serverUrl: String = "",
|
||||
val port: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val privateKey: String = "",
|
||||
val oauthToken: String = "",
|
||||
val oauthEmail: String = "",
|
||||
val httpWarning: Boolean = false,
|
||||
val isTestingConnection: Boolean = false,
|
||||
val testResult: TestResult? = null,
|
||||
val isSaving: Boolean = false,
|
||||
val error: String? = null,
|
||||
val done: Boolean = false,
|
||||
)
|
||||
|
||||
sealed interface TestResult {
|
||||
object Success : TestResult
|
||||
data class Failure(val message: String) : TestResult
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class AccountSetupViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val providerFactory: ProviderFactory,
|
||||
val credentialStore: com.syncflow.data.security.CredentialStore,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(AccountSetupState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val oauthReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
val token = intent.getStringExtra(OAUTH_EXTRA_TOKEN) ?: return
|
||||
val email = intent.getStringExtra(OAUTH_EXTRA_EMAIL) ?: ""
|
||||
_state.update { s ->
|
||||
s.copy(
|
||||
oauthToken = token,
|
||||
oauthEmail = email,
|
||||
displayName = s.displayName.ifBlank { email.ifBlank { s.providerType?.friendlyName() ?: "" } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
LocalBroadcastManager.getInstance(context)
|
||||
.registerReceiver(oauthReceiver, IntentFilter(OAUTH_REDIRECT_ACTION))
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
LocalBroadcastManager.getInstance(context).unregisterReceiver(oauthReceiver)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun update(transform: AccountSetupState.() -> AccountSetupState) {
|
||||
_state.update { s ->
|
||||
val next = transform(s).copy(testResult = null)
|
||||
next.copy(httpWarning = next.serverUrl.startsWith("http://", ignoreCase = true))
|
||||
}
|
||||
}
|
||||
|
||||
fun onGoogleAccountChosen(accountName: String) {
|
||||
_state.update { it.copy(oauthEmail = accountName, oauthToken = "google_account:$accountName", displayName = it.displayName.ifBlank { accountName }) }
|
||||
}
|
||||
|
||||
fun testConnection() {
|
||||
val entity = buildEntity() ?: run {
|
||||
_state.update { it.copy(error = "Fill in all required fields first") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isTestingConnection = true, testResult = null, error = null) }
|
||||
val provider = runCatching { providerFactory.create(entity.toDomain()) }.getOrElse { e ->
|
||||
_state.update { it.copy(isTestingConnection = false, testResult = TestResult.Failure(e.message ?: "Provider error")) }
|
||||
return@launch
|
||||
}
|
||||
val result = provider.testConnection()
|
||||
_state.update { s ->
|
||||
s.copy(
|
||||
isTestingConnection = false,
|
||||
testResult = if (result.isSuccess) TestResult.Success else TestResult.Failure(result.exceptionOrNull()?.message ?: "Connection failed"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val built = buildEntityWithCreds() ?: run {
|
||||
_state.update { it.copy(error = "Fill in all required fields") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSaving = true, error = null) }
|
||||
runCatching { accountRepository.insert(built.first, built.second) }
|
||||
.onSuccess { _state.update { it.copy(done = true) } }
|
||||
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns Pair(entity, credentialJson) or null if validation fails. */
|
||||
private fun buildEntityWithCreds(): Pair<CloudAccountEntity, String>? {
|
||||
val s = _state.value
|
||||
val provider = s.providerType ?: return null
|
||||
val credJson: String
|
||||
val serverUrl: String?
|
||||
val port: Int?
|
||||
val email: String?
|
||||
|
||||
when {
|
||||
provider.isOAuth() -> {
|
||||
if (s.oauthToken.isBlank()) return null
|
||||
credJson = buildJsonObject { put("access_token", s.oauthToken) }.toString()
|
||||
serverUrl = null
|
||||
port = null
|
||||
email = s.oauthEmail.ifBlank { null }
|
||||
}
|
||||
provider == ProviderType.SFTP -> {
|
||||
if (s.serverUrl.isBlank() || s.username.isBlank()) return null
|
||||
credJson = buildJsonObject {
|
||||
put("username", s.username)
|
||||
if (s.privateKey.isNotBlank()) put("private_key", s.privateKey)
|
||||
else put("password", s.password)
|
||||
}.toString()
|
||||
serverUrl = s.serverUrl
|
||||
port = s.port.toIntOrNull() ?: 22
|
||||
email = s.username
|
||||
}
|
||||
else -> {
|
||||
if (s.serverUrl.isBlank() || s.username.isBlank()) return null
|
||||
credJson = buildJsonObject {
|
||||
put("username", s.username)
|
||||
put("password", s.password)
|
||||
}.toString()
|
||||
serverUrl = s.serverUrl.trimEnd('/')
|
||||
port = s.port.toIntOrNull()
|
||||
email = s.username
|
||||
}
|
||||
}
|
||||
|
||||
val entity = CloudAccountEntity(
|
||||
displayName = s.displayName.ifBlank { provider.friendlyName() },
|
||||
email = email,
|
||||
providerType = provider,
|
||||
credentialJson = "", // never persisted to plaintext DB
|
||||
serverUrl = serverUrl,
|
||||
port = port,
|
||||
)
|
||||
return Pair(entity, credJson)
|
||||
}
|
||||
|
||||
// testConnection() still uses in-memory credentials — build a temporary CloudAccount
|
||||
private fun buildEntity(): CloudAccountEntity? = buildEntityWithCreds()?.let { (entity, cred) ->
|
||||
entity.copy(credentialJson = cred)
|
||||
}
|
||||
}
|
||||
|
||||
fun ProviderType.isOAuth() = this in setOf(ProviderType.GOOGLE_DRIVE, ProviderType.DROPBOX, ProviderType.ONEDRIVE)
|
||||
|
||||
fun ProviderType.friendlyName() = when (this) {
|
||||
ProviderType.GOOGLE_DRIVE -> "Google Drive"
|
||||
ProviderType.DROPBOX -> "Dropbox"
|
||||
ProviderType.ONEDRIVE -> "OneDrive"
|
||||
ProviderType.WEBDAV -> "WebDAV"
|
||||
ProviderType.SFTP -> "SFTP"
|
||||
ProviderType.NEXTCLOUD -> "Nextcloud"
|
||||
ProviderType.OWNCLOUD -> "ownCloud"
|
||||
ProviderType.SFTPGO -> "SFTPGo"
|
||||
}
|
||||
|
||||
private fun CloudAccountEntity.toDomain() = com.syncflow.domain.model.CloudAccount(
|
||||
id = id, displayName = displayName, email = email, providerType = providerType,
|
||||
credentialJson = credentialJson, serverUrl = serverUrl, port = port,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.syncflow.ui.auth
|
||||
|
||||
// Removed — OneDrive auth is now handled via OAuthHelper (PKCE + Chrome Custom Tab)
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.syncflow.ui.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import com.syncflow.data.security.CredentialStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
const val OAUTH_REDIRECT_ACTION = "com.syncflow.OAUTH_RESULT"
|
||||
const val OAUTH_EXTRA_TOKEN = "token"
|
||||
const val OAUTH_EXTRA_EMAIL = "email"
|
||||
const val OAUTH_EXTRA_PROVIDER = "provider"
|
||||
|
||||
private val client = OkHttpClient()
|
||||
private val random = SecureRandom()
|
||||
|
||||
private fun generateVerifier(): String {
|
||||
val bytes = ByteArray(32)
|
||||
random.nextBytes(bytes)
|
||||
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
private fun generateChallenge(verifier: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKey: String) {
|
||||
val verifier = generateVerifier()
|
||||
credentialStore.savePkceVerifier("dropbox", verifier)
|
||||
val challenge = generateChallenge(verifier)
|
||||
val url = "https://www.dropbox.com/oauth2/authorize" +
|
||||
"?client_id=$appKey" +
|
||||
"&response_type=code" +
|
||||
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fdropbox" +
|
||||
"&code_challenge=$challenge" +
|
||||
"&code_challenge_method=S256" +
|
||||
"&token_access_type=offline"
|
||||
openCustomTab(context, url)
|
||||
}
|
||||
|
||||
fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clientId: String) {
|
||||
val verifier = generateVerifier()
|
||||
credentialStore.savePkceVerifier("onedrive", verifier)
|
||||
val challenge = generateChallenge(verifier)
|
||||
val scopes = "Files.ReadWrite+User.Read+offline_access"
|
||||
val url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" +
|
||||
"?client_id=$clientId" +
|
||||
"&response_type=code" +
|
||||
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fonedrive" +
|
||||
"&scope=$scopes" +
|
||||
"&code_challenge=$challenge" +
|
||||
"&code_challenge_method=S256"
|
||||
openCustomTab(context, url)
|
||||
}
|
||||
|
||||
private fun openCustomTab(context: Context, url: String) {
|
||||
try {
|
||||
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
|
||||
} catch (_: Exception) {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exchangeDropboxCode(
|
||||
credentialStore: CredentialStore,
|
||||
code: String,
|
||||
appKey: String,
|
||||
): Pair<String, String>? = withContext(Dispatchers.IO) {
|
||||
val verifier = credentialStore.getPkceVerifier("dropbox") ?: return@withContext null
|
||||
credentialStore.removePkceVerifier("dropbox")
|
||||
val body = FormBody.Builder()
|
||||
.add("code", code)
|
||||
.add("grant_type", "authorization_code")
|
||||
.add("client_id", appKey)
|
||||
.add("redirect_uri", "syncflow://oauth/dropbox")
|
||||
.add("code_verifier", verifier)
|
||||
.build()
|
||||
val req = Request.Builder().url("https://api.dropboxapi.com/oauth2/token").post(body).build()
|
||||
val resp = runCatching { client.newCall(req).execute() }.getOrNull() ?: return@withContext null
|
||||
val text = resp.body?.string() ?: return@withContext null
|
||||
val json = runCatching { Json.parseToJsonElement(text).jsonObject }.getOrNull() ?: return@withContext null
|
||||
val token = json["access_token"]?.jsonPrimitive?.content ?: return@withContext null
|
||||
val email = json["account_id"]?.jsonPrimitive?.content ?: ""
|
||||
Pair(token, email)
|
||||
}
|
||||
|
||||
suspend fun exchangeOneDriveCode(
|
||||
credentialStore: CredentialStore,
|
||||
code: String,
|
||||
clientId: String,
|
||||
): Pair<String, String>? = withContext(Dispatchers.IO) {
|
||||
val verifier = credentialStore.getPkceVerifier("onedrive") ?: return@withContext null
|
||||
credentialStore.removePkceVerifier("onedrive")
|
||||
val body = FormBody.Builder()
|
||||
.add("code", code)
|
||||
.add("grant_type", "authorization_code")
|
||||
.add("client_id", clientId)
|
||||
.add("redirect_uri", "syncflow://oauth/onedrive")
|
||||
.add("code_verifier", verifier)
|
||||
.add("scope", "Files.ReadWrite User.Read offline_access")
|
||||
.build()
|
||||
val req = Request.Builder().url("https://login.microsoftonline.com/common/oauth2/v2.0/token").post(body).build()
|
||||
val resp = runCatching { client.newCall(req).execute() }.getOrNull() ?: return@withContext null
|
||||
val text = resp.body?.string() ?: return@withContext null
|
||||
val json = runCatching { Json.parseToJsonElement(text).jsonObject }.getOrNull() ?: return@withContext null
|
||||
val token = json["access_token"]?.jsonPrimitive?.content ?: return@withContext null
|
||||
Pair(token, "")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.syncflow.ui.auth
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.syncflow.data.security.CredentialStore
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class OAuthRedirectActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var credentialStore: CredentialStore
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
val uri = intent.data ?: run { finish(); return }
|
||||
val code = uri.getQueryParameter("code") ?: run { finish(); return }
|
||||
val provider = when {
|
||||
uri.host == "oauth" && uri.path?.contains("dropbox") == true -> "dropbox"
|
||||
uri.host == "oauth" && uri.path?.contains("onedrive") == true -> "onedrive"
|
||||
else -> run { finish(); return }
|
||||
}
|
||||
val appKey = getString(com.syncflow.R.string.dropbox_app_key)
|
||||
val odClientId = getString(com.syncflow.R.string.onedrive_client_id)
|
||||
lifecycleScope.launch {
|
||||
val result = when (provider) {
|
||||
"dropbox" -> exchangeDropboxCode(credentialStore, code, appKey)
|
||||
"onedrive" -> exchangeOneDriveCode(credentialStore, code, odClientId)
|
||||
else -> null
|
||||
} ?: run { finish(); return@launch }
|
||||
val broadcast = Intent(OAUTH_REDIRECT_ACTION).apply {
|
||||
putExtra(OAUTH_EXTRA_TOKEN, result.first)
|
||||
putExtra(OAUTH_EXTRA_EMAIL, result.second)
|
||||
putExtra(OAUTH_EXTRA_PROVIDER, provider)
|
||||
}
|
||||
LocalBroadcastManager.getInstance(this@OAuthRedirectActivity).sendBroadcast(broadcast)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user