Compare commits

..

6 Commits

Author SHA1 Message Date
amir a7c5ed713a feat: fix notifications on Android 13+/16, add Log tab, fix ON_CHANGE detection
- Request POST_NOTIFICATIONS permission at runtime in MainActivity (primary fix
  for notifications never appearing on Android 13+ phones including Android 16)
- Register all 4 notification channels eagerly in SyncFlowApp.onCreate() instead
  of lazily inside workers
- Add FOREGROUND_SERVICE_SHORT_SERVICE permission + shortService foreground type
  for Android 16 foreground service compatibility
- Add global activity Log tab (new tab 2 in main nav) showing all sync events
  across all pairs, grouped by date with pair name, event icon, and file detail
- Fix FileWatchService ON_CHANGE detection: ContentObserver on SAF tree URIs only
  fires for SAF-API writes, not raw filesystem writes. Now resolves primary:/*
  tree URIs to /storage/emulated/0/* and uses FileObserver for reliable detection
- Bump version to 1.0.21 (build 22)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 21:34:48 +00:00
amir 739e6ece46 fix: implement findExistingAlgorithms in TofuHostKeyVerifier (sshj 0.38 API)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:11:30 +00:00
amir d70defe3e1 build: add missing gradle-wrapper.jar
Required by the standard gradlew launcher. Was absent because the original
gradlew bypassed the wrapper mechanism entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:08:00 +00:00
amir a4aca43fa7 build: fix gradlew and wrapper URL to work on any machine
gradlew was hardcoded to /home/amir/gradle/gradle-8.6/bin/gradle.
gradle-wrapper.properties used a local file:// URL.
Both now use the standard portable approach (HTTPS distribution URL)
so builds work in CI and on any dev machine without a local Gradle install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:07:24 +00:00
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
24 changed files with 554 additions and 72 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 -2
View File
@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SHORT_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -71,13 +72,13 @@
<!-- File watcher for ON_CHANGE sync pairs --> <!-- File watcher for ON_CHANGE sync pairs -->
<service <service
android:name=".worker.FileWatchService" android:name=".worker.FileWatchService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync|shortService"
android:exported="false" /> android:exported="false" />
<!-- Required on API 29+ so WorkManager can start a typed foreground service --> <!-- Required on API 29+ so WorkManager can start a typed foreground service -->
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync|shortService"
tools:node="merge" /> tools:node="merge" />
<!-- Remove WorkManager's default initializer — app uses on-demand init via Configuration.Provider --> <!-- Remove WorkManager's default initializer — app uses on-demand init via Configuration.Provider -->
@@ -1,9 +1,12 @@
package com.syncflow package com.syncflow
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
@@ -49,10 +52,14 @@ class MainActivity : AppCompatActivity() {
private var isLocked by mutableStateOf(false) private var isLocked by mutableStateOf(false)
private var showRetry by mutableStateOf(false) private var showRetry by mutableStateOf(false)
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* proceed regardless */ }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
requestNotificationPermissionIfNeeded()
setContent { setContent {
SyncFlowTheme { SyncFlowTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
@@ -127,6 +134,16 @@ class MainActivity : AppCompatActivity() {
BIOMETRIC_WEAK or DEVICE_CREDENTIAL BIOMETRIC_WEAK or DEVICE_CREDENTIAL
} }
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
private fun canAuthenticate(): Boolean { private fun canAuthenticate(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
return BiometricManager.from(this).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS return BiometricManager.from(this).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
@@ -1,6 +1,9 @@
package com.syncflow package com.syncflow
import android.app.Application import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration import androidx.work.Configuration
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
@@ -22,6 +25,7 @@ class SyncFlowApp : Application(), Configuration.Provider {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
createNotificationChannels()
// Start file watcher on every app launch for any existing ON_CHANGE pairs // Start file watcher on every app launch for any existing ON_CHANGE pairs
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val hasOnChange = syncPairDao.getEnabled().any { it.scheduleType == ScheduleType.ON_CHANGE } val hasOnChange = syncPairDao.getEnabled().any { it.scheduleType == ScheduleType.ON_CHANGE }
@@ -29,6 +33,27 @@ class SyncFlowApp : Application(), Configuration.Provider {
} }
} }
private fun createNotificationChannels() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
listOf(
NotificationChannel("sync_progress", "Sync progress", NotificationManager.IMPORTANCE_LOW).apply {
description = "Shown while a sync is running"
},
NotificationChannel("sync_complete", "Sync complete", NotificationManager.IMPORTANCE_LOW).apply {
description = "Summary after each successful sync"
},
NotificationChannel("sync_alerts", "Sync errors", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Alerts for sync failures and conflicts"
},
NotificationChannel("sync_watching", "File watching", NotificationManager.IMPORTANCE_MIN).apply {
description = "Background service watching folders for changes"
setShowBadge(false)
},
).forEach { channel ->
if (nm.getNotificationChannel(channel.id) == null) nm.createNotificationChannel(channel)
}
}
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
get() = Configuration.Builder() get() = Configuration.Builder()
.setWorkerFactory(workerFactory) .setWorkerFactory(workerFactory)
@@ -9,6 +9,9 @@ interface SyncEventDao {
@Query("SELECT * FROM sync_events WHERE syncPairId = :pairId ORDER BY timestamp DESC LIMIT :limit") @Query("SELECT * FROM sync_events WHERE syncPairId = :pairId ORDER BY timestamp DESC LIMIT :limit")
fun observeRecent(pairId: Long, limit: Int = 200): Flow<List<SyncEventEntity>> fun observeRecent(pairId: Long, limit: Int = 200): Flow<List<SyncEventEntity>>
@Query("SELECT * FROM sync_events ORDER BY timestamp DESC LIMIT :limit")
fun observeAll(limit: Int = 500): Flow<List<SyncEventEntity>>
@Insert @Insert
suspend fun insert(entity: SyncEventEntity): Long suspend fun insert(entity: SyncEventEntity): Long
@@ -7,19 +7,20 @@ import com.syncflow.data.providers.owncloud.OwnCloudProvider
import com.syncflow.data.providers.onedrive.OneDriveProvider import com.syncflow.data.providers.onedrive.OneDriveProvider
import com.syncflow.data.providers.sftp.SftpProvider import com.syncflow.data.providers.sftp.SftpProvider
import com.syncflow.data.providers.webdav.WebDavProvider import com.syncflow.data.providers.webdav.WebDavProvider
import com.syncflow.data.security.CredentialStore
import com.syncflow.domain.model.CloudAccount import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.ProviderType import com.syncflow.domain.model.ProviderType
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ProviderFactory @Inject constructor() { class ProviderFactory @Inject constructor(private val credentialStore: CredentialStore) {
fun create(account: CloudAccount): CloudProvider = when (account.providerType) { fun create(account: CloudAccount): CloudProvider = when (account.providerType) {
ProviderType.GOOGLE_DRIVE -> GoogleDriveProvider(account) ProviderType.GOOGLE_DRIVE -> GoogleDriveProvider(account)
ProviderType.DROPBOX -> DropboxProvider(account) ProviderType.DROPBOX -> DropboxProvider(account)
ProviderType.ONEDRIVE -> OneDriveProvider(account) ProviderType.ONEDRIVE -> OneDriveProvider(account)
ProviderType.WEBDAV -> WebDavProvider(account) ProviderType.WEBDAV -> WebDavProvider(account)
ProviderType.SFTP -> SftpProvider(account) ProviderType.SFTP -> SftpProvider(account, credentialStore)
ProviderType.NEXTCLOUD -> NextcloudProvider(account) ProviderType.NEXTCLOUD -> NextcloudProvider(account)
ProviderType.OWNCLOUD -> OwnCloudProvider(account) ProviderType.OWNCLOUD -> OwnCloudProvider(account)
ProviderType.SFTPGO -> WebDavProvider(account) // SFTPGo exposes WebDAV ProviderType.SFTPGO -> WebDavProvider(account) // SFTPGo exposes WebDAV
@@ -18,9 +18,9 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
} }
private val client = OkHttpClient() private val client = OkHttpClient()
private fun apiReq(url: String, bodyJson: String): Request = private fun apiReq(url: String, argJson: JsonObject): Request =
Request.Builder().url(url) Request.Builder().url(url)
.post(bodyJson.toRequestBody("application/json".toMediaType())) .post(argJson.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.build() .build()
@@ -33,7 +33,8 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching { override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
val path = if (remotePath == "/" || remotePath.isBlank()) "" else remotePath 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 -> client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}") val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body") 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 { override suspend fun uploadFile(localStream: InputStream, remotePath: String, sizeBytes: Long, onProgress: (Long) -> Unit): Result<RemoteFile> = runCatching {
val bytes = localStream.readBytes() 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") val req = Request.Builder().url("https://content.dropboxapi.com/2/files/upload")
.post(bytes.toRequestBody("application/octet-stream".toMediaType())) .post(bytes.toRequestBody("application/octet-stream".toMediaType()))
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.header("Dropbox-API-Arg", argHeader).build() .header("Dropbox-API-Arg", arg.toString()).build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}") val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body") 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 { 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") val req = Request.Builder().url("https://content.dropboxapi.com/2/files/download")
.post("".toRequestBody()) .post("".toRequestBody())
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.header("Dropbox-API-Arg", argHeader).build() .header("Dropbox-API-Arg", arg.toString()).build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}")
var total = 0L var total = 0L
@@ -75,17 +80,20 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
} }
override suspend fun deleteFile(remotePath: String): Result<Unit> = runCatching { 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}") } client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
} }
override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching { 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}") } 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 { 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 -> client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}") val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
Json.parseToJsonElement(body).jsonObject.toRemoteFile() 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 { override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/move_v2", val arg = buildJsonObject {
"""{"from_path":"${fromPath.normalizeDropbox()}","to_path":"${toPath.normalizeDropbox()}"}""") 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}") } 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 name = remotePath.substringAfterLast('/')
val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root" val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root"
// Multipart upload // Multipart upload — use JSON builder to avoid injection via filenames with special chars
val metaPart = """{"name":"$name","parents":["$parentId"]}""" val metaPart = buildJsonObject {
.toRequestBody("application/json".toMediaType()) put("name", name)
put("parents", buildJsonArray { add(parentId) })
}.toString().toRequestBody("application/json".toMediaType())
val dataPart = bytes.toRequestBody("application/octet-stream".toMediaType()) val dataPart = bytes.toRequestBody("application/octet-stream".toMediaType())
val multipart = MultipartBody.Builder() val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
@@ -86,8 +88,11 @@ class GoogleDriveProvider(private val account: CloudAccount) : CloudProvider {
override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching { override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching {
val name = remotePath.substringAfterLast('/') val name = remotePath.substringAfterLast('/')
val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root" val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root"
val body = """{"name":"$name","mimeType":"application/vnd.google-apps.folder","parents":["$parentId"]}""" val body = buildJsonObject {
.toRequestBody("application/json".toMediaType()) 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() 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}") } 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 { override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
val newName = toPath.substringAfterLast('/') 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() 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}") } client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
} }
@@ -1,6 +1,7 @@
package com.syncflow.data.providers.sftp package com.syncflow.data.providers.sftp
import com.syncflow.data.providers.CloudProvider import com.syncflow.data.providers.CloudProvider
import com.syncflow.data.security.CredentialStore
import com.syncflow.domain.model.CloudAccount import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile import com.syncflow.domain.model.RemoteFile
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -8,13 +9,12 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import net.schmizz.sshj.SSHClient import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.sftp.SFTPClient import net.schmizz.sshj.sftp.SFTPClient
import net.schmizz.sshj.transport.verification.PromiscuousVerifier
import net.schmizz.sshj.xfer.InMemorySourceFile import net.schmizz.sshj.xfer.InMemorySourceFile
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.time.Instant 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 creds = Json.parseToJsonElement(account.credentialJson).jsonObject
private val host = account.serverUrl ?: "localhost" 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 { private fun <T> withSftp(block: (SFTPClient) -> T): T {
val ssh = SSHClient() val ssh = SSHClient()
ssh.addHostKeyVerifier(PromiscuousVerifier()) // TODO: replace with proper key pinning ssh.addHostKeyVerifier(TofuHostKeyVerifier(credentialStore))
ssh.connect(host, port) ssh.connect(host, port)
try { try {
if (!privateKey.isNullOrBlank()) { if (!privateKey.isNullOrBlank()) {
@@ -0,0 +1,35 @@
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
}
}
// Return empty list so sshj uses server preference order for key exchange.
// Our verify() will accept or reject whatever algorithm is negotiated.
override fun findExistingAlgorithms(hostname: String, port: Int): List<String> = emptyList()
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.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import okhttp3.* import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
@@ -38,9 +39,14 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
.header("Authorization", Credentials.basic(user, pass)) .header("Authorization", Credentials.basic(user, pass))
.build() .build()
val resp = chain.proceed(req) 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) { if (resp.code in 301..308) {
val location = resp.header("Location") ?: return@addInterceptor resp 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() resp.close()
val redirectReq = req.newBuilder().url(location).build() val redirectReq = req.newBuilder().url(location).build()
chain.proceed(redirectReq) chain.proceed(redirectReq)
@@ -3,7 +3,7 @@ package com.syncflow.data.security
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKeys
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -12,13 +12,11 @@ import javax.inject.Singleton
class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) { class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) {
private val prefs: SharedPreferences by lazy { private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context) val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create( EncryptedSharedPreferences.create(
context,
"syncflow_credentials", "syncflow_credentials",
masterKey, masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
@@ -37,7 +35,7 @@ class CredentialStore @Inject constructor(@ApplicationContext private val contex
prefs.edit().remove(credKey(accountId)).apply() prefs.edit().remove(credKey(accountId)).apply()
} }
// ── PKCE verifiers (OAuth flow) ─────────────────────────────────────────── // ── PKCE verifiers and OAuth state (OAuth flow) ───────────────────────────
fun savePkceVerifier(provider: String, verifier: String) { fun savePkceVerifier(provider: String, verifier: String) {
prefs.edit().putString(pkceKey(provider), verifier).apply() 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() 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 ─────────────────────────────────────────────────────────── // ── Key helpers ───────────────────────────────────────────────────────────
private fun credKey(accountId: Long) = "cred_$accountId" private fun credKey(accountId: Long) = "cred_$accountId"
private fun pkceKey(provider: String) = "pkce_$provider" private fun pkceKey(provider: String) = "pkce_$provider"
private fun hostKey(host: String, port: Int) = "sshhost_${host}_$port"
} }
@@ -2,6 +2,7 @@ package com.syncflow.ui.auth
import android.accounts.AccountManager import android.accounts.AccountManager
import android.app.Activity import android.app.Activity
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@@ -201,6 +202,15 @@ private fun CredentialContent(
) { ) {
val provider = state.providerType ?: return 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( Column(
modifier = modifier modifier = modifier
.padding(horizontal = 20.dp) .padding(horizontal = 20.dp)
@@ -38,7 +38,9 @@ private fun generateChallenge(verifier: String): String {
fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKey: String) { fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKey: String) {
val verifier = generateVerifier() val verifier = generateVerifier()
val state = generateVerifier()
credentialStore.savePkceVerifier("dropbox", verifier) credentialStore.savePkceVerifier("dropbox", verifier)
credentialStore.savePkceVerifier("dropbox_state", state)
val challenge = generateChallenge(verifier) val challenge = generateChallenge(verifier)
val url = "https://www.dropbox.com/oauth2/authorize" + val url = "https://www.dropbox.com/oauth2/authorize" +
"?client_id=$appKey" + "?client_id=$appKey" +
@@ -46,13 +48,16 @@ fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKe
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fdropbox" + "&redirect_uri=syncflow%3A%2F%2Foauth%2Fdropbox" +
"&code_challenge=$challenge" + "&code_challenge=$challenge" +
"&code_challenge_method=S256" + "&code_challenge_method=S256" +
"&token_access_type=offline" "&token_access_type=offline" +
"&state=$state"
openCustomTab(context, url) openCustomTab(context, url)
} }
fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clientId: String) { fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clientId: String) {
val verifier = generateVerifier() val verifier = generateVerifier()
val state = generateVerifier()
credentialStore.savePkceVerifier("onedrive", verifier) credentialStore.savePkceVerifier("onedrive", verifier)
credentialStore.savePkceVerifier("onedrive_state", state)
val challenge = generateChallenge(verifier) val challenge = generateChallenge(verifier)
val scopes = "Files.ReadWrite+User.Read+offline_access" val scopes = "Files.ReadWrite+User.Read+offline_access"
val url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + 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" + "&redirect_uri=syncflow%3A%2F%2Foauth%2Fonedrive" +
"&scope=$scopes" + "&scope=$scopes" +
"&code_challenge=$challenge" + "&code_challenge=$challenge" +
"&code_challenge_method=S256" "&code_challenge_method=S256" +
"&state=$state"
openCustomTab(context, url) openCustomTab(context, url)
} }
@@ -28,11 +28,22 @@ class OAuthRedirectActivity : ComponentActivity() {
private fun handleIntent(intent: Intent) { private fun handleIntent(intent: Intent) {
val uri = intent.data ?: run { finish(); return } val uri = intent.data ?: run { finish(); return }
val code = uri.getQueryParameter("code") ?: run { finish(); return } val code = uri.getQueryParameter("code") ?: run { finish(); return }
val returnedState = uri.getQueryParameter("state") ?: run { finish(); return }
val provider = when { val provider = when {
uri.host == "oauth" && uri.path?.contains("dropbox") == true -> "dropbox" uri.host == "oauth" && uri.path?.contains("dropbox") == true -> "dropbox"
uri.host == "oauth" && uri.path?.contains("onedrive") == true -> "onedrive" uri.host == "oauth" && uri.path?.contains("onedrive") == true -> "onedrive"
else -> run { finish(); return } 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 appKey = getString(com.syncflow.R.string.dropbox_app_key)
val odClientId = getString(com.syncflow.R.string.onedrive_client_id) val odClientId = getString(com.syncflow.R.string.onedrive_client_id)
lifecycleScope.launch { lifecycleScope.launch {
@@ -0,0 +1,178 @@
package com.syncflow.ui.log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.*
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.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.domain.model.SyncEventType
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun LogScreen(
modifier: Modifier = Modifier,
vm: LogViewModel = hiltViewModel(),
) {
val entries by vm.entries.collectAsState()
if (entries.isEmpty()) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f),
)
Text("No activity yet", style = MaterialTheme.typography.titleMedium)
Text(
"Sync events will appear here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Group entries by calendar date
val grouped = entries.groupBy { entry ->
entry.event.timestamp.atZone(ZoneId.systemDefault()).toLocalDate()
}
grouped.forEach { (date, dayEntries) ->
item(key = date.toString()) {
Text(
text = date.toRelativeLabel(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
)
}
items(dayEntries, key = { it.event.id }) { entry ->
LogEntryRow(entry)
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.padding(start = 52.dp),
)
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
@Composable
private fun LogEntryRow(entry: LogEntry) {
val (icon, tint) = entry.event.eventType.iconAndTint()
val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val timeStr = entry.event.timestamp.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
// Icon bubble
Surface(
shape = RoundedCornerShape(10.dp),
color = tint.copy(alpha = 0.12f),
modifier = Modifier.size(36.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null, Modifier.size(18.dp), tint = tint)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
entry.pairName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f, fill = false),
)
Text(
timeStr,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = entry.event.eventType.label(),
style = MaterialTheme.typography.bodySmall,
)
val detail = entry.event.filePath ?: entry.event.message
if (detail != null) {
Text(
text = detail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}
@Composable
private fun SyncEventType.iconAndTint(): Pair<ImageVector, Color> = when (this) {
SyncEventType.SYNC_STARTED -> Icons.Default.Sync to MaterialTheme.colorScheme.secondary
SyncEventType.SYNC_COMPLETED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
SyncEventType.SYNC_FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
SyncEventType.FILE_UPLOADED -> Icons.Default.CloudUpload to MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DOWNLOADED -> Icons.Default.CloudDownload to MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DELETED -> Icons.Default.DeleteForever to MaterialTheme.colorScheme.tertiary
SyncEventType.FILE_SKIPPED -> Icons.Outlined.Circle to MaterialTheme.colorScheme.onSurfaceVariant
SyncEventType.CONFLICT_DETECTED -> Icons.Default.Warning to MaterialTheme.colorScheme.tertiary
SyncEventType.CONFLICT_RESOLVED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
}
private fun SyncEventType.label(): String = when (this) {
SyncEventType.SYNC_STARTED -> "Sync started"
SyncEventType.SYNC_COMPLETED -> "Sync completed"
SyncEventType.SYNC_FAILED -> "Sync failed"
SyncEventType.FILE_UPLOADED -> "File uploaded"
SyncEventType.FILE_DOWNLOADED -> "File downloaded"
SyncEventType.FILE_DELETED -> "File deleted"
SyncEventType.FILE_SKIPPED -> "File skipped"
SyncEventType.CONFLICT_DETECTED -> "Conflict detected"
SyncEventType.CONFLICT_RESOLVED -> "Conflict resolved"
}
private fun java.time.LocalDate.toRelativeLabel(): String {
val today = java.time.LocalDate.now()
return when {
this == today -> "Today"
this == today.minusDays(1) -> "Yesterday"
else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).format(this)
}
}
@@ -0,0 +1,29 @@
package com.syncflow.ui.log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncEventDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncEventEntity
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
data class LogEntry(val event: SyncEventEntity, val pairName: String)
@HiltViewModel
class LogViewModel @Inject constructor(
syncEventDao: SyncEventDao,
syncPairDao: SyncPairDao,
) : ViewModel() {
val entries = combine(
syncEventDao.observeAll(500),
syncPairDao.observeAll(),
) { events, pairs ->
val pairNames = pairs.associateBy({ it.id }, { it.name })
events.map { LogEntry(it, pairNames[it.syncPairId] ?: "Unknown") }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}
@@ -20,8 +20,10 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.ManageAccounts
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.outlined.ManageAccounts import androidx.compose.material.icons.outlined.ManageAccounts
import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -32,6 +34,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.syncflow.R import com.syncflow.R
import com.syncflow.ui.home.HomeScreen import com.syncflow.ui.home.HomeScreen
import com.syncflow.ui.log.LogScreen
import com.syncflow.ui.settings.SettingsScreen import com.syncflow.ui.settings.SettingsScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -42,7 +45,7 @@ fun MainShell(
onPairClick: (Long) -> Unit, onPairClick: (Long) -> Unit,
onAddAccount: () -> Unit, onAddAccount: () -> Unit,
) { ) {
val pagerState = rememberPagerState(pageCount = { 2 }) val pagerState = rememberPagerState(pageCount = { 3 })
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentPage = pagerState.currentPage val currentPage = pagerState.currentPage
@@ -88,7 +91,18 @@ fun MainShell(
onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
icon = { icon = {
Icon( Icon(
if (currentPage == 1) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts, if (currentPage == 1) Icons.Filled.Notifications else Icons.Outlined.NotificationsNone,
contentDescription = null,
)
},
label = { Text("Log") },
)
NavigationBarItem(
selected = currentPage == 2,
onClick = { scope.launch { pagerState.animateScrollToPage(2) } },
icon = {
Icon(
if (currentPage == 2) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts,
contentDescription = null, contentDescription = null,
) )
}, },
@@ -120,7 +134,8 @@ fun MainShell(
) { page -> ) { page ->
when (page) { when (page) {
0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick) 0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick)
1 -> SettingsScreen(onAddAccount = onAddAccount) 1 -> LogScreen()
2 -> SettingsScreen(onAddAccount = onAddAccount)
} }
} }
} }
@@ -10,6 +10,7 @@ import android.os.FileObserver
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.provider.DocumentsContract
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
@@ -81,35 +82,25 @@ class FileWatchService : Service() {
val localPath = pair.localPath val localPath = pair.localPath
if (localPath.startsWith("content://")) { if (localPath.startsWith("content://")) {
val treeUri = Uri.parse(localPath) // Try to resolve the SAF tree URI to a real filesystem path so we can use
val observer = object : ContentObserver(mainHandler) { // FileObserver. ContentObserver on a DocumentsProvider tree URI only fires
override fun onChange(selfChange: Boolean) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly) // when changes come through the SAF API, not for raw filesystem writes.
override fun onChange(selfChange: Boolean, uri: Uri?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly) val realPath = safTreeUriToRealPath(localPath)
} if (realPath != null) {
contentResolver.registerContentObserver(treeUri, true, observer) watchPath(realPath, pairId, pair.wifiOnly, pair.chargingOnly)
contentObservers[pairId] = observer
Timber.d("FileWatchService: watching SAF URI for pair $pairId")
} else {
val dir = File(localPath)
if (!dir.exists()) {
Timber.w("FileWatchService: path does not exist for pair $pairId: $localPath")
return@forEach
}
val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or
FileObserver.MOVED_FROM or FileObserver.MOVED_TO
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
object : FileObserver(dir, mask) {
override fun onEvent(event: Int, path: String?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
}
} else { } else {
@Suppress("DEPRECATION") // Fallback: register a ContentObserver for SAF paths that can't be resolved
object : FileObserver(localPath, mask) { val treeUri = Uri.parse(localPath)
override fun onEvent(event: Int, path: String?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly) val observer = object : ContentObserver(mainHandler) {
override fun onChange(selfChange: Boolean) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
override fun onChange(selfChange: Boolean, uri: Uri?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
} }
contentResolver.registerContentObserver(treeUri, true, observer)
contentObservers[pairId] = observer
Timber.d("FileWatchService: watching SAF URI (ContentObserver fallback) for pair $pairId")
} }
observer.startWatching() } else {
fileObservers[pairId] = observer watchPath(localPath, pairId, pair.wifiOnly, pair.chargingOnly)
Timber.d("FileWatchService: watching filesystem path for pair $pairId: $localPath")
} }
} }
@@ -122,6 +113,46 @@ class FileWatchService : Service() {
} }
} }
private fun safTreeUriToRealPath(uriString: String): String? {
return try {
val treeUri = Uri.parse(uriString)
val docId = DocumentsContract.getTreeDocumentId(treeUri)
// docId format is "primary:RelativePath" for primary internal storage
if (docId.startsWith("primary:")) {
val relative = docId.removePrefix("primary:")
"/storage/emulated/0/$relative"
} else {
null
}
} catch (e: Exception) {
Timber.w("FileWatchService: could not resolve SAF URI to real path: $e")
null
}
}
private fun watchPath(path: String, pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
val dir = File(path)
if (!dir.exists()) {
Timber.w("FileWatchService: path does not exist for pair $pairId: $path")
return
}
val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or
FileObserver.MOVED_FROM or FileObserver.MOVED_TO
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
object : FileObserver(dir, mask) {
override fun onEvent(event: Int, p: String?) = onChangeDetected(pairId, wifiOnly, chargingOnly)
}
} else {
@Suppress("DEPRECATION")
object : FileObserver(path, mask) {
override fun onEvent(event: Int, p: String?) = onChangeDetected(pairId, wifiOnly, chargingOnly)
}
}
observer.startWatching()
fileObservers[pairId] = observer
Timber.d("FileWatchService: watching filesystem path for pair $pairId: $path")
}
private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) { private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
debounceJobs[pairId]?.cancel() debounceJobs[pairId]?.cancel()
debounceJobs[pairId] = scope.launch { debounceJobs[pairId] = scope.launch {
+3 -3
View File
@@ -28,8 +28,8 @@ localbroadcastmanager = "1.1.0"
coil = "2.7.0" coil = "2.7.0"
splashscreen = "1.0.1" splashscreen = "1.0.1"
timber = "5.0.1" timber = "5.0.1"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.0.0"
biometric = "1.2.0-alpha05" biometric = "1.1.0"
junit = "4.13.2" junit = "4.13.2"
androidxTestExt = "1.2.1" androidxTestExt = "1.2.1"
espresso = "3.6.1" espresso = "3.6.1"
@@ -106,7 +106,7 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi
# Security # Security
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } 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 # Logging
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=file\:///home/amir/gradle/gradle-8.6/gradle-8.6-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored
+39 -2
View File
@@ -1,2 +1,39 @@
#!/bin/bash #!/bin/sh
exec /home/amir/gradle/gradle-8.6/bin/gradle "$@" ##############################################################################
# Gradle wrapper — standard portable launcher
##############################################################################
app_path=$0
while [ -h "$app_path" ]; do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in
/*) app_path=$link ;;
*) app_path=${app_path%"${app_path##*/}"}$link ;;
esac
done
APP_HOME=$( cd "${app_path%"${app_path##*/}"}." && pwd -P ) || exit
APP_BASE_NAME=${0##*/}
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ]; then
JAVACMD=$JAVA_HOME/bin/java
else
JAVACMD=java
fi
MAX_FD=maximum
case "$( uname )" in
Darwin*) ;;
*)
MAX_FD=$( ulimit -H -n 2>/dev/null ) && ulimit -n "$MAX_FD" 2>/dev/null ;;
esac
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \
"\"-Dorg.gradle.appname=$APP_BASE_NAME\"" \
-classpath "\"$CLASSPATH\"" \
org.gradle.wrapper.GradleWrapperMain '"$@"'
exec "$JAVACMD" "$@"
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.18 VERSION_NAME=1.0.21
VERSION_CODE=19 VERSION_CODE=22