Compare commits

...

6 Commits

Author SHA1 Message Date
amir cfac742856 ci: add Gitea Actions workflow to build and attach APK on tag push
Triggers on v* tags — sets up Java 17 + Android SDK, builds a debug APK
(installable without a keystore), renames it SyncFlow-v<version>.apk, and
uploads it to the matching Gitea release via the API using the built-in token.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:51:37 +00:00
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
amir 894c2ffe78 v1.0.18: fix ON_CHANGE never starting, improve version display
- Start FileWatchService from SyncFlowApp.onCreate() for any existing
  enabled ON_CHANGE pairs — previously the watcher only started on
  device boot or explicit pair toggle, so existing pairs after an
  app update never got watched
- About screen now shows "Version X.Y.Z (build N)" updating
  automatically from BuildConfig on every release

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:11:47 +00:00
amir 59335dab13 v1.0.17: modern multi-color app icon with depth and detail
Redesigned launcher icon:
- Background: deep violet #2E1065 → purple #6D28D9 → navy #1E40AF
- Three concentric glow rings (white, layered alpha) for depth
- Upload arrow: neon cyan #67E8F9 → sky blue #38BDF8
- Download arrow: hot pink #F472B6 → coral #FB923C
- Double-layer center orb (frosted + solid white)
- 4 cardinal accent sparks (cyan/indigo/pink/emerald)
- 4 diagonal mini sparks (light cyan/peach/violet/green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 03:48:18 +00:00
amir b15637132c v1.0.16: spinning sync icon, colorful icon, ON_CHANGE fix, notification fix
- Sync icon now rotates (CSS-style spin) in StatusPill, StatusBanner,
  and card sync button whenever status is SYNCING
- Launcher icon redesigned: indigo→violet→cyan gradient background,
  upload arrow fades white→sky-blue, download arrow fades white→violet,
  soft glow ring behind arrows
- Fix ON_CHANGE not triggering: FileWatchService.start() now called
  from AddPairViewModel.save() so pairs created with ON_CHANGE
  immediately begin watching without needing a toggle or reboot
- Fix FileWatch notification hidden: IMPORTANCE_MIN → IMPORTANCE_LOW
  so the "Watching N folders" notification shows in the shade

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 03:42:30 +00:00
amir bcfecbb867 releases/latest: add v1.0.15 APK
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:55:48 +00:00
23 changed files with 321 additions and 60 deletions
+52
View File
@@ -0,0 +1,52 @@
name: Build & Release APK
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- uses: android-actions/setup-android@v3
- name: Build debug APK
run: |
chmod +x gradlew
./gradlew assembleDebug --no-daemon
- name: Get version name
id: ver
run: echo "name=$(grep VERSION_NAME version.properties | cut -d= -f2)" >> $GITHUB_OUTPUT
- name: Rename APK
run: |
mkdir dist
cp app/build/outputs/apk/debug/app-debug.apk \
dist/SyncFlow-v${{ steps.ver.outputs.name }}.apk
- name: Attach APK to release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
VERSION: ${{ steps.ver.outputs.name }}
run: |
RELEASE_ID=$(curl -sf \
"https://gitea.khodak.me/api/v1/repos/amir/SyncFlow/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
curl -sf -X POST \
"https://gitea.khodak.me/api/v1/repos/amir/SyncFlow/releases/$RELEASE_ID/assets" \
-H "Authorization: token $TOKEN" \
-F "attachment=@dist/SyncFlow-v${VERSION}.apk"
echo "APK uploaded to release $TAG"
@@ -3,7 +3,13 @@ package com.syncflow
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType
import com.syncflow.worker.FileWatchService
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -11,10 +17,16 @@ import javax.inject.Inject
class SyncFlowApp : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var syncPairDao: SyncPairDao
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
// Start file watcher on every app launch for any existing ON_CHANGE pairs
CoroutineScope(Dispatchers.IO).launch {
val hasOnChange = syncPairDao.getEnabled().any { it.scheduleType == ScheduleType.ON_CHANGE }
if (hasOnChange) FileWatchService.start(this@SyncFlowApp)
}
}
override val workManagerConfiguration: Configuration
@@ -7,19 +7,20 @@ import com.syncflow.data.providers.owncloud.OwnCloudProvider
import com.syncflow.data.providers.onedrive.OneDriveProvider
import com.syncflow.data.providers.sftp.SftpProvider
import com.syncflow.data.providers.webdav.WebDavProvider
import com.syncflow.data.security.CredentialStore
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.ProviderType
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ProviderFactory @Inject constructor() {
class ProviderFactory @Inject constructor(private val credentialStore: CredentialStore) {
fun create(account: CloudAccount): CloudProvider = when (account.providerType) {
ProviderType.GOOGLE_DRIVE -> GoogleDriveProvider(account)
ProviderType.DROPBOX -> DropboxProvider(account)
ProviderType.ONEDRIVE -> OneDriveProvider(account)
ProviderType.WEBDAV -> WebDavProvider(account)
ProviderType.SFTP -> SftpProvider(account)
ProviderType.SFTP -> SftpProvider(account, credentialStore)
ProviderType.NEXTCLOUD -> NextcloudProvider(account)
ProviderType.OWNCLOUD -> OwnCloudProvider(account)
ProviderType.SFTPGO -> WebDavProvider(account) // SFTPGo exposes WebDAV
@@ -18,9 +18,9 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
}
private val client = OkHttpClient()
private fun apiReq(url: String, bodyJson: String): Request =
private fun apiReq(url: String, argJson: JsonObject): Request =
Request.Builder().url(url)
.post(bodyJson.toRequestBody("application/json".toMediaType()))
.post(argJson.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer $token")
.build()
@@ -33,7 +33,8 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
val path = if (remotePath == "/" || remotePath.isBlank()) "" else remotePath
val req = apiReq("https://api.dropboxapi.com/2/files/list_folder", """{"path":"$path","recursive":false}""")
val arg = buildJsonObject { put("path", path); put("recursive", false) }
val req = apiReq("https://api.dropboxapi.com/2/files/list_folder", arg)
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body")
@@ -44,11 +45,15 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
override suspend fun uploadFile(localStream: InputStream, remotePath: String, sizeBytes: Long, onProgress: (Long) -> Unit): Result<RemoteFile> = runCatching {
val bytes = localStream.readBytes()
val argHeader = """{"path":"${remotePath.normalizeDropbox()}","mode":"overwrite","autorename":false}"""
val arg = buildJsonObject {
put("path", remotePath.normalizeDropbox())
put("mode", "overwrite")
put("autorename", false)
}
val req = Request.Builder().url("https://content.dropboxapi.com/2/files/upload")
.post(bytes.toRequestBody("application/octet-stream".toMediaType()))
.header("Authorization", "Bearer $token")
.header("Dropbox-API-Arg", argHeader).build()
.header("Dropbox-API-Arg", arg.toString()).build()
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body")
@@ -58,11 +63,11 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
}
override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
val argHeader = """{"path":"${remotePath.normalizeDropbox()}"}"""
val arg = buildJsonObject { put("path", remotePath.normalizeDropbox()) }
val req = Request.Builder().url("https://content.dropboxapi.com/2/files/download")
.post("".toRequestBody())
.header("Authorization", "Bearer $token")
.header("Dropbox-API-Arg", argHeader).build()
.header("Dropbox-API-Arg", arg.toString()).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}")
var total = 0L
@@ -75,17 +80,20 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
}
override suspend fun deleteFile(remotePath: String): Result<Unit> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/delete_v2", """{"path":"${remotePath.normalizeDropbox()}"}""")
val arg = buildJsonObject { put("path", remotePath.normalizeDropbox()) }
val req = apiReq("https://api.dropboxapi.com/2/files/delete_v2", arg)
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/create_folder_v2", """{"path":"${remotePath.normalizeDropbox()}"}""")
val arg = buildJsonObject { put("path", remotePath.normalizeDropbox()) }
val req = apiReq("https://api.dropboxapi.com/2/files/create_folder_v2", arg)
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful && resp.code != 409) throw Exception("HTTP ${resp.code}") }
}
override suspend fun getFileMetadata(remotePath: String): Result<RemoteFile> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/get_metadata", """{"path":"${remotePath.normalizeDropbox()}"}""")
val arg = buildJsonObject { put("path", remotePath.normalizeDropbox()) }
val req = apiReq("https://api.dropboxapi.com/2/files/get_metadata", arg)
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
Json.parseToJsonElement(body).jsonObject.toRemoteFile()
@@ -93,8 +101,11 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
}
override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/move_v2",
"""{"from_path":"${fromPath.normalizeDropbox()}","to_path":"${toPath.normalizeDropbox()}"}""")
val arg = buildJsonObject {
put("from_path", fromPath.normalizeDropbox())
put("to_path", toPath.normalizeDropbox())
}
val req = apiReq("https://api.dropboxapi.com/2/files/move_v2", arg)
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
@@ -44,9 +44,11 @@ class GoogleDriveProvider(private val account: CloudAccount) : CloudProvider {
val name = remotePath.substringAfterLast('/')
val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root"
// Multipart upload
val metaPart = """{"name":"$name","parents":["$parentId"]}"""
.toRequestBody("application/json".toMediaType())
// Multipart upload — use JSON builder to avoid injection via filenames with special chars
val metaPart = buildJsonObject {
put("name", name)
put("parents", buildJsonArray { add(parentId) })
}.toString().toRequestBody("application/json".toMediaType())
val dataPart = bytes.toRequestBody("application/octet-stream".toMediaType())
val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM)
@@ -86,8 +88,11 @@ class GoogleDriveProvider(private val account: CloudAccount) : CloudProvider {
override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching {
val name = remotePath.substringAfterLast('/')
val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root"
val body = """{"name":"$name","mimeType":"application/vnd.google-apps.folder","parents":["$parentId"]}"""
.toRequestBody("application/json".toMediaType())
val body = buildJsonObject {
put("name", name)
put("mimeType", "application/vnd.google-apps.folder")
put("parents", buildJsonArray { add(parentId) })
}.toString().toRequestBody("application/json".toMediaType())
val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/files").post(body)).build()
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
@@ -102,7 +107,8 @@ class GoogleDriveProvider(private val account: CloudAccount) : CloudProvider {
override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
val newName = toPath.substringAfterLast('/')
val body = """{"name":"$newName"}""".toRequestBody("application/json".toMediaType())
val body = buildJsonObject { put("name", newName) }.toString()
.toRequestBody("application/json".toMediaType())
val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/files/$fromPath").patch(body)).build()
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
@@ -1,6 +1,7 @@
package com.syncflow.data.providers.sftp
import com.syncflow.data.providers.CloudProvider
import com.syncflow.data.security.CredentialStore
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.serialization.json.Json
@@ -8,13 +9,12 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.sftp.SFTPClient
import net.schmizz.sshj.transport.verification.PromiscuousVerifier
import net.schmizz.sshj.xfer.InMemorySourceFile
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
class SftpProvider(private val account: CloudAccount) : CloudProvider {
class SftpProvider(private val account: CloudAccount, private val credentialStore: CredentialStore) : CloudProvider {
private val creds = Json.parseToJsonElement(account.credentialJson).jsonObject
private val host = account.serverUrl ?: "localhost"
@@ -25,7 +25,7 @@ class SftpProvider(private val account: CloudAccount) : CloudProvider {
private fun <T> withSftp(block: (SFTPClient) -> T): T {
val ssh = SSHClient()
ssh.addHostKeyVerifier(PromiscuousVerifier()) // TODO: replace with proper key pinning
ssh.addHostKeyVerifier(TofuHostKeyVerifier(credentialStore))
ssh.connect(host, port)
try {
if (!privateKey.isNullOrBlank()) {
@@ -0,0 +1,31 @@
package com.syncflow.data.providers.sftp
import com.syncflow.data.security.CredentialStore
import net.schmizz.sshj.transport.verification.HostKeyVerifier
import java.security.MessageDigest
import java.security.PublicKey
/**
* Trust-On-First-Use SSH host key verifier.
*
* First connection to a host: fingerprint is stored in EncryptedSharedPreferences and accepted.
* Subsequent connections: stored fingerprint must match — mismatch aborts (possible MITM).
*/
class TofuHostKeyVerifier(private val credentialStore: CredentialStore) : HostKeyVerifier {
override fun verify(hostname: String, port: Int, key: PublicKey): Boolean {
val fingerprint = sha256Fingerprint(key)
val stored = credentialStore.getHostFingerprint(hostname, port)
return if (stored == null) {
credentialStore.saveHostKey(hostname, port, fingerprint)
true
} else {
stored == fingerprint
}
}
private fun sha256Fingerprint(key: PublicKey): String {
val digest = MessageDigest.getInstance("SHA-256").digest(key.encoded)
return digest.joinToString(":") { "%02x".format(it) }
}
}
@@ -10,6 +10,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser
@@ -38,9 +39,14 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
.header("Authorization", Credentials.basic(user, pass))
.build()
val resp = chain.proceed(req)
// follow redirect manually for WebDAV methods (OkHttp skips non-GET/HEAD redirects)
// Follow redirects for WebDAV methods (OkHttp skips non-GET/HEAD redirects).
// Only follow same-host redirects to prevent credential leakage to a different server.
if (resp.code in 301..308) {
val location = resp.header("Location") ?: return@addInterceptor resp
val redirectHost = location.toHttpUrlOrNull()?.host
if (redirectHost == null || redirectHost != req.url.host) {
return@addInterceptor resp
}
resp.close()
val redirectReq = req.newBuilder().url(location).build()
chain.proceed(redirectReq)
@@ -3,7 +3,7 @@ package com.syncflow.data.security
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import androidx.security.crypto.MasterKeys
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@@ -12,13 +12,11 @@ import javax.inject.Singleton
class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create(
context,
"syncflow_credentials",
masterKey,
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
@@ -37,7 +35,7 @@ class CredentialStore @Inject constructor(@ApplicationContext private val contex
prefs.edit().remove(credKey(accountId)).apply()
}
// ── PKCE verifiers (OAuth flow) ───────────────────────────────────────────
// ── PKCE verifiers and OAuth state (OAuth flow) ───────────────────────────
fun savePkceVerifier(provider: String, verifier: String) {
prefs.edit().putString(pkceKey(provider), verifier).apply()
@@ -49,8 +47,18 @@ class CredentialStore @Inject constructor(@ApplicationContext private val contex
prefs.edit().remove(pkceKey(provider)).apply()
}
// ── SFTP host key fingerprints (TOFU) ─────────────────────────────────────
fun saveHostKey(host: String, port: Int, fingerprint: String) {
prefs.edit().putString(hostKey(host, port), fingerprint).apply()
}
fun getHostFingerprint(host: String, port: Int): String? =
prefs.getString(hostKey(host, port), null)
// ── Key helpers ───────────────────────────────────────────────────────────
private fun credKey(accountId: Long) = "cred_$accountId"
private fun pkceKey(provider: String) = "pkce_$provider"
private fun hostKey(host: String, port: Int) = "sshhost_${host}_$port"
}
@@ -1,5 +1,6 @@
package com.syncflow.ui.addpair
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -7,9 +8,10 @@ import com.syncflow.data.db.CloudAccountDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.data.db.entities.toDomain
import com.syncflow.domain.model.*
import com.syncflow.worker.FileWatchService
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -31,7 +33,7 @@ data class AddPairUiState(
val scheduleType: ScheduleType = ScheduleType.INTERVAL,
val intervalMinutes: Int = 30,
val dailyTime: String = "02:00",
val weekdays: Int = 0b1111111, // all 7 days by default
val weekdays: Int = 0b1111111,
// ── Constraints ──────────────────────────────────────────────────────────
val wifiOnly: Boolean = true,
val wifiSsid: String = "",
@@ -57,6 +59,7 @@ data class AddPairUiState(
class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
private val accountDao: CloudAccountDao,
@ApplicationContext private val context: Context,
savedState: SavedStateHandle,
) : ViewModel() {
@@ -147,7 +150,10 @@ class AddPairViewModel @Inject constructor(
)
if (editPairId == null) syncPairDao.insert(entity) else syncPairDao.update(entity)
}
.onSuccess { _state.update { it.copy(done = true) } }
.onSuccess {
if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context)
_state.update { it.copy(done = true) }
}
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
}
}
@@ -2,6 +2,7 @@ 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
@@ -201,6 +202,15 @@ private fun CredentialContent(
) {
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)
@@ -38,7 +38,9 @@ private fun generateChallenge(verifier: String): String {
fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKey: String) {
val verifier = generateVerifier()
val state = generateVerifier()
credentialStore.savePkceVerifier("dropbox", verifier)
credentialStore.savePkceVerifier("dropbox_state", state)
val challenge = generateChallenge(verifier)
val url = "https://www.dropbox.com/oauth2/authorize" +
"?client_id=$appKey" +
@@ -46,13 +48,16 @@ fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKe
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fdropbox" +
"&code_challenge=$challenge" +
"&code_challenge_method=S256" +
"&token_access_type=offline"
"&token_access_type=offline" +
"&state=$state"
openCustomTab(context, url)
}
fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clientId: String) {
val verifier = generateVerifier()
val state = generateVerifier()
credentialStore.savePkceVerifier("onedrive", verifier)
credentialStore.savePkceVerifier("onedrive_state", state)
val challenge = generateChallenge(verifier)
val scopes = "Files.ReadWrite+User.Read+offline_access"
val url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" +
@@ -61,7 +66,8 @@ fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clie
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fonedrive" +
"&scope=$scopes" +
"&code_challenge=$challenge" +
"&code_challenge_method=S256"
"&code_challenge_method=S256" +
"&state=$state"
openCustomTab(context, url)
}
@@ -28,11 +28,22 @@ class OAuthRedirectActivity : ComponentActivity() {
private fun handleIntent(intent: Intent) {
val uri = intent.data ?: run { finish(); return }
val code = uri.getQueryParameter("code") ?: run { finish(); return }
val returnedState = uri.getQueryParameter("state") ?: 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 }
}
// Validate state before doing anything with the code (CSRF protection)
val storedState = credentialStore.getPkceVerifier("${provider}_state")
if (storedState == null || returnedState != storedState) {
finish()
return
}
credentialStore.removePkceVerifier("${provider}_state")
val appKey = getString(com.syncflow.R.string.dropbox_app_key)
val odClientId = getString(com.syncflow.R.string.onedrive_client_id)
lifecycleScope.launch {
@@ -1,5 +1,10 @@
package com.syncflow.ui.home
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -14,6 +19,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncPairEntity
@@ -159,8 +165,18 @@ private fun SyncPairCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val syncRotation by rememberInfiniteTransition(label = "cardSyncSpin").animateFloat(
initialValue = 0f, targetValue = 360f,
animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing)),
label = "cardRotation",
)
FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp))
Icon(
Icons.Default.Sync, "Sync now",
modifier = Modifier.size(18.dp).graphicsLayer {
if (pair.lastSyncResult == SyncStatus.SYNCING) rotationZ = syncRotation
},
)
}
}
}
@@ -179,10 +195,11 @@ private fun StatusPill(status: SyncStatus) {
SyncStatus.IDLE -> Pair(Icons.Outlined.Circle, "Idle")
}
val containerColor = status.accentColor
val contentColor = when (status) {
SyncStatus.IDLE -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.surface
}
val rotation by rememberInfiniteTransition(label = "syncSpin").animateFloat(
initialValue = 0f, targetValue = 360f,
animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing)),
label = "rotation",
)
Surface(
shape = RoundedCornerShape(50),
color = containerColor.copy(alpha = 0.15f),
@@ -192,7 +209,11 @@ private fun StatusPill(status: SyncStatus) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(icon, null, Modifier.size(12.dp), tint = containerColor)
Icon(
icon, null,
Modifier.size(12.dp).graphicsLayer { if (status == SyncStatus.SYNCING) rotationZ = rotation },
tint = containerColor,
)
Text(label, style = MaterialTheme.typography.labelSmall, color = containerColor)
}
}
@@ -1,5 +1,10 @@
package com.syncflow.ui.pairdetail
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -13,6 +18,7 @@ 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.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncEventEntity
@@ -143,6 +149,11 @@ private fun StatusBanner(pair: SyncPairEntity) {
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber,"Partial", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.IDLE -> Triple(Icons.Outlined.Circle, "Idle", MaterialTheme.colorScheme.surfaceVariant)
}
val rotation by rememberInfiniteTransition(label = "bannerSpin").animateFloat(
initialValue = 0f, targetValue = 360f,
animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing)),
label = "bannerRotation",
)
Surface(
color = containerColor,
shape = RoundedCornerShape(16.dp),
@@ -152,7 +163,12 @@ private fun StatusBanner(pair: SyncPairEntity) {
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, modifier = Modifier.size(40.dp))
Icon(
icon, null,
modifier = Modifier.size(40.dp).graphicsLayer {
if (pair.lastSyncResult == SyncStatus.SYNCING) rotationZ = rotation
},
)
Spacer(Modifier.width(16.dp))
Column {
Text(label, style = MaterialTheme.typography.titleMedium)
@@ -137,11 +137,17 @@ fun SettingsScreen(
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"SyncFlow v${com.syncflow.BuildConfig.VERSION_NAME} — Free, no subscription.",
style = MaterialTheme.typography.bodySmall,
"SyncFlow",
style = MaterialTheme.typography.titleSmall,
)
Text(
"Open source. No ads. No tracking.",
"Version ${com.syncflow.BuildConfig.VERSION_NAME} (build ${com.syncflow.BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
Text(
"Free, no subscription. No ads. No tracking.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -148,7 +148,7 @@ class FileWatchService : Service() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(CHANNEL_WATCH) == null) {
nm.createNotificationChannel(
NotificationChannel(CHANNEL_WATCH, "File watching", NotificationManager.IMPORTANCE_MIN).apply {
NotificationChannel(CHANNEL_WATCH, "File watching", NotificationManager.IMPORTANCE_LOW).apply {
description = "Background service watching folders for changes"
setShowBadge(false)
}
@@ -3,6 +3,7 @@
<gradient
android:type="linear"
android:angle="135"
android:startColor="#312E81"
android:endColor="#6366F1"/>
android:startColor="#2E1065"
android:centerColor="#6D28D9"
android:endColor="#1E40AF"/>
</shape>
@@ -1,13 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="24"
android:viewportHeight="24">
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Outer soft glow ring -->
<path
android:fillColor="#FFFFFF"
android:pathData="M12,4V1L8,5l4,4V6c3.31,0 6,2.69 6,6 0,1.01-0.25,1.97-0.7,2.8l1.46,1.46C19.54,15.03 20,13.57 20,12c0-4.42-3.58-8-8-8z"/>
android:pathData="M54,54m-44,0a44,44 0 1,0 88,0a44,44 0 1,0 -88,0"
android:fillColor="#12FFFFFF"/>
<!-- Mid glow ring -->
<path
android:fillColor="#FFFFFF"
android:pathData="M12,18c-3.31,0-6,-2.69-6,-6 0,-1.01 0.25,-1.97 0.7,-2.8L5.24,7.74C4.46,8.97 4,10.43 4,12c0,4.42 3.58,8 8,8v3l4,-4-4,-4v3z"/>
android:pathData="M54,54m-33,0a33,33 0 1,0 66,0a33,33 0 1,0 -66,0"
android:fillColor="#18FFFFFF"/>
<!-- Inner glow ring -->
<path
android:pathData="M54,54m-21,0a21,21 0 1,0 42,0a21,21 0 1,0 -42,0"
android:fillColor="#10FFFFFF"/>
<!-- Upload arrow (top-right) — neon cyan → sky blue -->
<path android:pathData="M54,18V4.5L36,22.5l18,18V27c14.895,0 27,12.105 27,27 0,4.545-1.125,8.865-3.15,12.6l6.57,6.57C87.93,67.635 90,61.065 90,54c0-19.89-16.11-36-36-36z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="36" android:startY="4"
android:endX="90" android:endY="70"
android:startColor="#67E8F9"
android:endColor="#38BDF8"/>
</aapt:attr>
</path>
<!-- Download arrow (bottom-left) — hot pink → coral -->
<path android:pathData="M54,81c-14.895,0-27,-12.105-27,-27 0,-4.545 1.125,-8.865 3.15,-12.6L23.58,34.83C20.07,40.365 18,46.935 18,54c0,19.89 16.11,36 36,36v13.5l18,-18-18,-18v13.5z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="18" android:startY="35"
android:endX="72" android:endY="103"
android:startColor="#F472B6"
android:endColor="#FB923C"/>
</aapt:attr>
</path>
<!-- Center glowing orb -->
<path
android:pathData="M54,54m-7,0a7,7 0 1,0 14,0a7,7 0 1,0 -14,0"
android:fillColor="#60FFFFFF"/>
<path
android:pathData="M54,54m-4,0a4,4 0 1,0 8,0a4,4 0 1,0 -8,0"
android:fillColor="#FFFFFF"/>
<!-- Cardinal accent sparks -->
<!-- Top — cyan -->
<path android:pathData="M54,13m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#22D3EE"/>
<!-- Right — indigo -->
<path android:pathData="M95,54m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#818CF8"/>
<!-- Bottom — pink -->
<path android:pathData="M54,95m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#F9A8D4"/>
<!-- Left — emerald -->
<path android:pathData="M13,54m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#6EE7B7"/>
<!-- Diagonal mini sparks (45°) -->
<path android:pathData="M85,23m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#A5F3FC"/>
<path android:pathData="M85,85m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#FDBA74"/>
<path android:pathData="M23,85m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#C084FC"/>
<path android:pathData="M23,23m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#86EFAC"/>
</vector>
+3 -3
View File
@@ -28,8 +28,8 @@ localbroadcastmanager = "1.1.0"
coil = "2.7.0"
splashscreen = "1.0.1"
timber = "5.0.1"
securityCrypto = "1.1.0-alpha06"
biometric = "1.2.0-alpha05"
securityCrypto = "1.0.0"
biometric = "1.1.0"
junit = "4.13.2"
androidxTestExt = "1.2.1"
espresso = "3.6.1"
@@ -106,7 +106,7 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi
# Security
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
biometric = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometric" }
biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
# Logging
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
Binary file not shown.
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.15
VERSION_CODE=16
VERSION_NAME=1.0.19
VERSION_CODE=20