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:
2026-05-22 20:21:20 +00:00
commit cff4233de6
95 changed files with 5381 additions and 0 deletions
@@ -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()
}
}
}