Compare commits

...

9 Commits

Author SHA1 Message Date
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
amir f751b26a9e v1.0.15: ON_CHANGE file watching, browser fix, rich notifications
- Add FileWatchService for real-time ON_CHANGE sync (FileObserver for
  direct paths, ContentObserver for SAF content:// URIs), 5s debounce
- Fix remote browser stuck spinner: cancel in-flight jobs on navigation,
  reset entries immediately, add Retry button on error
- Fix browser reuse bug: LaunchedEffect key now includes initialPath
- Fix WebDavProvider: rethrow XML parse errors (no more silent Empty
  folder) and URL-decode file names from href
- Notifications now use BigTextStyle showing per-file-type counts
  (Uploaded/Downloaded/Deleted) matching Autosync notification style
- Wire FileWatchService into BootReceiver and HomeViewModel toggle
- Register FileWatchService in AndroidManifest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:55:19 +00:00
amir e22db9bced feat: rich sync notifications (progress + result + error)
Three notification channels:
- sync_progress (LOW): foreground notification while syncing, shows pair name
- sync_complete (LOW): result after success — "↑X ↓X" or "Up to date"
- sync_alerts (DEFAULT): error notification with message on failure

Notifications respect per-pair notifyOnComplete / notifyOnError settings.
All notifications tap-through to MainActivity. Foreground info now names the
pair being synced instead of the generic "Syncing…" text.

Bump to 1.0.14 (code 15).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:35:45 +00:00
amir 21d8f0dca2 redesign: modern indigo UI, new app icon, edge-to-edge theme
App icon: deep indigo-to-violet gradient background with white sync arrows;
replaced flat #2196F3 with layered adaptive icon.

Theme: disabled dynamic color; rich indigo/teal/amber Material3 palette;
edge-to-edge with transparent status bar; tighter typography letterSpacing.

HomeScreen: colored left accent bar per status; URL-decoded SAF paths;
relative timestamps (Just now / N min ago / N hr ago); indigo status pills;
FilledTonalButton empty state.

PairDetailScreen: hero StatusBanner with large icon and relative time;
InfoCard as bordered grid with icon backgrounds; colored dot event timeline;
URL-decoded local path display.

SettingsScreen: section headers with primary left bar; AccountCard with
primaryContainer icon backgrounds; Security/About in bordered cards.

Bump version to 1.0.13 (code 14).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:31:44 +00:00
amir 3d7a8b5f3d fix: remote deletions not mirrored when file has no state record
When a file was uploaded before state-tracking worked (getFileMetadata was
broken), its SyncFileStateEntity was never saved. On next sync the engine
saw !local + remote + known=null and downloaded it back instead of deleting
it remotely, creating an infinite re-download loop.

Fix: syncDecide() now accepts hasPriorSyncState (derived from whether the
pair has any known states at all). On initial sync (no prior state) unknown
remote files are downloaded as before. Once the pair has been synced, unknown
remote-only files are treated as mirror-eligible deletions — same as if known
state existed — so locally-deleted files propagate to the remote correctly.

Verified live: 3 remote-only orphan files deleted from Nextcloud on sync.
Bump version to 1.0.12 (code 13).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:18:27 +00:00
amir 1d6a80e43d fix: SAF delete crash, getFileMetadata drop-first, MKCOL before upload
- LocalAccessor.Saf.delete() now uses docIdCache (same as openInputStream)
  and catches IllegalStateException from DocumentsContract.deleteDocument
  instead of propagating it through awaitAll() and crashing the whole sync
- WebDavProvider.getFileMetadata() passes dropFirst=false to parsePropfind
  since Depth:0 returns exactly 1 result (the file); drop(1) was discarding it
- SyncEngine.performSync() calls ensureRemoteDirs() before each upload so
  MKCOL is issued for any missing parent directories (405=exists is success)
- Bump version to 1.0.11 (code 12)

Verified against live Nextcloud: baseline ↑0 ↓0 ✗0, upload detection ↑1 ↓0 ✗0,
download detection ↑0 ↓1 ✗0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:07:54 +00:00
amir a9322d3214 fix: incremental sync + unit tests for decide() logic
Sync change detection (3rd attempt — now correct):
- After UPLOAD: save null remote metadata (server mtime unknown until
  next listing); decide() treats null remoteModifiedAt as "not changed"
- After DOWNLOAD: read actual local mtime via accessor.lastModifiedMs()
  so the stored value matches what walkFiles() sees on next scan
- SKIP reconciliation: if known state has null timestamps and both sides
  exist, fill them in — stabilises state within 2 syncs after first transfer
- Extract syncDecide() as internal top-level function for testability

Unit tests (14 cases covering all key scenarios):
- First sync decisions (upload/download/conflict)
- Second sync after upload with null remote metadata → SKIP
- Second sync after download with recorded local mtime → SKIP
- Epoch-millis precision: same ms = SKIP, +1ms = change detected
- Regression: epoch-second stored value would have differed → now correct
- Delete behaviour (MIRROR vs KEEP)
- Direction filters (UPLOAD_ONLY, DOWNLOAD_ONLY)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:13:00 +00:00
33 changed files with 1403 additions and 365 deletions
+6
View File
@@ -68,6 +68,12 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- File watcher for ON_CHANGE sync pairs -->
<service
android:name=".worker.FileWatchService"
android:foregroundServiceType="dataSync"
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"
@@ -132,7 +132,10 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
val req = Request.Builder().url(url(remotePath)).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build() val req = Request.Builder().url(url(remotePath)).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (resp.code != 207) throw Exception("HTTP ${resp.code}") if (resp.code != 207) throw Exception("HTTP ${resp.code}")
parsePropfind(resp.body?.string() ?: "", remotePath.substringBeforeLast('/')) // Depth:0 returns exactly the requested resource as the single response entry.
// parsePropfind normally drops the first entry (the parent dir) for Depth:1
// directory listings, so pass dropFirst=false here.
parsePropfind(resp.body?.string() ?: "", remotePath.substringBeforeLast('/'), dropFirst = false)
.firstOrNull() ?: throw Exception("File not found") .firstOrNull() ?: throw Exception("File not found")
} }
} }
@@ -153,9 +156,8 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
protected fun url(path: String) = "$baseUrl/${path.trimStart('/')}" protected fun url(path: String) = "$baseUrl/${path.trimStart('/')}"
private fun parsePropfind(xml: String, parentPath: String): List<RemoteFile> { private fun parsePropfind(xml: String, parentPath: String, dropFirst: Boolean = true): List<RemoteFile> {
val results = mutableListOf<RemoteFile>() val results = mutableListOf<RemoteFile>()
try {
val factory = XmlPullParserFactory.newInstance() val factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true factory.isNamespaceAware = true
val parser = factory.newPullParser() val parser = factory.newPullParser()
@@ -182,7 +184,8 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
XmlPullParser.END_TAG -> when (tag) { XmlPullParser.END_TAG -> when (tag) {
"prop" -> inProp = false "prop" -> inProp = false
"response" -> if (inResponse && href.isNotBlank()) { "response" -> if (inResponse && href.isNotBlank()) {
val name = href.trimEnd('/').substringAfterLast('/') val rawName = href.trimEnd('/').substringAfterLast('/')
val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName }
val relPath = "$parentPath/$name".replace("//", "/") val relPath = "$parentPath/$name".replace("//", "/")
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType)) results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
inResponse = false inResponse = false
@@ -191,8 +194,7 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
} }
eventType = parser.next() eventType = parser.next()
} }
} catch (_: Exception) {} return if (dropFirst) results.drop(1) else results
return results.drop(1) // drop the parent folder itself
} }
private fun parseHttpDate(value: String): Instant = try { private fun parseHttpDate(value: String): Instant = try {
@@ -63,7 +63,13 @@ sealed class LocalAccessor {
class Saf(private val treeUri: Uri, private val resolver: ContentResolver) : LocalAccessor() { class Saf(private val treeUri: Uri, private val resolver: ContentResolver) : LocalAccessor() {
// Populated by walkFiles so openInputStream can skip the re-query for files that
// already exist locally (uploads). Root-level files are the common failure case
// when findDocUri re-queries: the cache sidesteps the issue entirely.
private val docIdCache = mutableMapOf<String, String>()
override fun walkFiles(pair: SyncPair): Map<String, LocalFileInfo> { override fun walkFiles(pair: SyncPair): Map<String, LocalFileInfo> {
docIdCache.clear()
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri) val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, rootDocId) val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, rootDocId)
return cursorWalk(childrenUri, "", pair) return cursorWalk(childrenUri, "", pair)
@@ -110,6 +116,7 @@ sealed class LocalAccessor {
if (ext in excludeExts) continue if (ext in excludeExts) continue
if (size !in minBytes..maxBytes) continue if (size !in minBytes..maxBytes) continue
result[rel] = LocalFileInfo(rel, size, modified) result[rel] = LocalFileInfo(rel, size, modified)
docIdCache[rel] = docId
} }
} }
} }
@@ -117,7 +124,10 @@ sealed class LocalAccessor {
} }
override fun openInputStream(relativePath: String): InputStream? { override fun openInputStream(relativePath: String): InputStream? {
val docUri = findDocUri(relativePath) ?: return null val docUri = docIdCache[relativePath]
?.let { DocumentsContract.buildDocumentUriUsingTree(treeUri, it) }
?: findDocUri(relativePath)
?: return null
return resolver.openInputStream(docUri) return resolver.openInputStream(docUri)
} }
@@ -155,8 +165,15 @@ sealed class LocalAccessor {
} }
override fun delete(relativePath: String): Boolean { override fun delete(relativePath: String): Boolean {
val docUri = findDocUri(relativePath) ?: return false val docUri = docIdCache[relativePath]
return DocumentsContract.deleteDocument(resolver, docUri) ?.let { DocumentsContract.buildDocumentUriUsingTree(treeUri, it) }
?: findDocUri(relativePath)
?: return false
return try {
DocumentsContract.deleteDocument(resolver, docUri)
} catch (e: Exception) {
false
}
} }
override fun lastModifiedMs(relativePath: String): Long { override fun lastModifiedMs(relativePath: String): Long {
@@ -8,6 +8,7 @@ import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncConflictEntity import com.syncflow.data.db.entities.SyncConflictEntity
import com.syncflow.data.db.entities.SyncEventEntity import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.data.providers.CloudProvider import com.syncflow.data.providers.CloudProvider
import com.syncflow.domain.model.ConflictStrategy import com.syncflow.domain.model.ConflictStrategy
import com.syncflow.domain.model.DeleteBehavior import com.syncflow.domain.model.DeleteBehavior
@@ -69,6 +70,7 @@ class SyncEngine @Inject constructor(
val localFiles = accessor.walkFiles(pair) val localFiles = accessor.walkFiles(pair)
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet() val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
val hasPriorSyncState = knownStates.isNotEmpty()
val semaphore = Semaphore(4) val semaphore = Semaphore(4)
// Each async block returns its outcome; no shared mutable state across coroutines. // Each async block returns its outcome; no shared mutable state across coroutines.
@@ -76,7 +78,7 @@ class SyncEngine @Inject constructor(
val uploaded: Int = 0, val downloaded: Int = 0, val deleted: Int = 0, val uploaded: Int = 0, val downloaded: Int = 0, val deleted: Int = 0,
val skipped: Int = 0, val failed: Int = 0, val conflicts: Int = 0, val skipped: Int = 0, val failed: Int = 0, val conflicts: Int = 0,
val bytesTransferred: Long = 0L, val bytesTransferred: Long = 0L,
val newState: com.syncflow.data.db.entities.SyncFileStateEntity? = null, val newState: SyncFileStateEntity? = null,
) )
val outcomes: List<FileOutcome> = coroutineScope { val outcomes: List<FileOutcome> = coroutineScope {
@@ -86,13 +88,15 @@ class SyncEngine @Inject constructor(
val local = localFiles[rel] val local = localFiles[rel]
val remote = remoteFiles[rel] val remote = remoteFiles[rel]
val known = knownStates[rel] val known = knownStates[rel]
val decision = decide(pair.syncDirection, pair.conflictStrategy, pair.deleteBehavior, local, remote, known) val decision = syncDecide(pair.syncDirection, pair.conflictStrategy, pair.deleteBehavior, local, remote, known, hasPriorSyncState)
when (decision) { when (decision) {
SyncDecision.UPLOAD -> { SyncDecision.UPLOAD -> {
var uploadedRemoteFile: RemoteFile? = null
val bytes = runCatching { val bytes = runCatching {
ensureRemoteDirs(provider, pair.remotePath, rel)
accessor.openInputStream(rel)?.use { stream -> accessor.openInputStream(rel)?.use { stream ->
provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { } uploadedRemoteFile = provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }.getOrThrow()
} }
local!!.sizeBytes local!!.sizeBytes
}.getOrElse { e -> }.getOrElse { e ->
@@ -101,7 +105,8 @@ class SyncEngine @Inject constructor(
return@withPermit FileOutcome(failed = 1) return@withPermit FileOutcome(failed = 1)
} }
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes) logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
FileOutcome(uploaded = 1, bytesTransferred = bytes, newState = buildState(pair.id, rel, local!!, remote)) FileOutcome(uploaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = uploadedRemoteFile))
} }
SyncDecision.DOWNLOAD -> { SyncDecision.DOWNLOAD -> {
val bytes = runCatching { val bytes = runCatching {
@@ -114,8 +119,14 @@ class SyncEngine @Inject constructor(
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0) logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0)
return@withPermit FileOutcome(failed = 1) return@withPermit FileOutcome(failed = 1)
} }
// Read the actual local mtime written by the OS/SAF after download.
val localMtime = runCatching { accessor.lastModifiedMs(rel) }
.getOrDefault(System.currentTimeMillis()).takeIf { it > 0L }
?: System.currentTimeMillis()
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes) logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
FileOutcome(downloaded = 1, bytesTransferred = bytes, newState = buildState(pair.id, rel, null, remote)) FileOutcome(downloaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel,
LocalFileInfo(rel, remote!!.sizeBytes, localMtime), remoteAfterTransfer = remote))
} }
SyncDecision.DELETE_LOCAL -> { SyncDecision.DELETE_LOCAL -> {
accessor.delete(rel) accessor.delete(rel)
@@ -143,7 +154,21 @@ class SyncEngine @Inject constructor(
logEvent(pair.id, SyncEventType.CONFLICT_DETECTED, rel, null, 0) logEvent(pair.id, SyncEventType.CONFLICT_DETECTED, rel, null, 0)
FileOutcome(conflicts = 1) FileOutcome(conflicts = 1)
} }
SyncDecision.SKIP -> FileOutcome(skipped = 1) SyncDecision.SKIP -> {
// Save state whenever both sides are present and state is absent or
// incomplete (post-upload null metadata). Without a baseline record,
// a subsequent local deletion would look like an unseen remote file
// and be re-downloaded instead of triggering DELETE_REMOTE.
val saveState = local != null && remote != null && (
known == null ||
known.remoteModifiedAt == null || known.localModifiedAt == null
)
if (saveState) {
FileOutcome(skipped = 1, newState = buildState(pair.id, rel, local, remoteAfterTransfer = remote))
} else {
FileOutcome(skipped = 1)
}
}
} }
} }
} }
@@ -162,19 +187,61 @@ class SyncEngine @Inject constructor(
) )
} }
private fun decide( private suspend fun ensureRemoteDirs(provider: CloudProvider, remotePairPath: String, rel: String) {
val parts = rel.replace('\\', '/').split('/')
var currentPath = remotePairPath
for (part in parts.dropLast(1)) {
currentPath = "$currentPath/$part"
provider.createDirectory(currentPath).onFailure { e ->
Timber.w("MKCOL $currentPath: ${e.message}")
}
}
}
private fun buildState(
pairId: Long,
rel: String,
local: LocalFileInfo?,
remoteAfterTransfer: RemoteFile?,
) = SyncFileStateEntity(
syncPairId = pairId,
relativePath = rel,
localModifiedAt = local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) },
localSizeBytes = local?.sizeBytes ?: 0L,
localHash = null,
remoteModifiedAt = remoteAfterTransfer?.modifiedAt,
remoteSizeBytes = remoteAfterTransfer?.sizeBytes ?: 0L,
remoteEtag = remoteAfterTransfer?.etag,
lastSyncedAt = Instant.now(),
syncedHash = null,
)
private suspend fun logEvent(pairId: Long, type: SyncEventType, file: String?, msg: String?, bytes: Long) {
eventDao.insert(SyncEventEntity(syncPairId = pairId, timestamp = Instant.now(), eventType = type, filePath = file, message = msg, bytesTransferred = bytes))
}
}
// Top-level so unit tests can call it directly without instantiating SyncEngine.
internal fun syncDecide(
direction: SyncDirection, direction: SyncDirection,
conflictStrategy: ConflictStrategy, conflictStrategy: ConflictStrategy,
deleteBehavior: DeleteBehavior, deleteBehavior: DeleteBehavior,
local: LocalFileInfo?, local: LocalFileInfo?,
remote: RemoteFile?, remote: RemoteFile?,
known: com.syncflow.data.db.entities.SyncFileStateEntity?, known: SyncFileStateEntity?,
): SyncDecision { hasPriorSyncState: Boolean = false,
): SyncDecision {
val localExists = local != null val localExists = local != null
val remoteExists = remote != null val remoteExists = remote != null
val localChanged = known == null || (localExists && local!!.lastModifiedMs != known.localModifiedAt?.toEpochMilli()) // Treat null known timestamps as "not yet recorded" — don't treat as changed.
val remoteChanged = known == null || (remoteExists && (remote!!.etag != known.remoteEtag || remote.modifiedAt != known.remoteModifiedAt)) // The SKIP reconciliation pass will fill them in on the next sync.
val localChanged = known == null ||
(localExists && known.localModifiedAt != null &&
local!!.lastModifiedMs != known.localModifiedAt.toEpochMilli())
val remoteChanged = known == null ||
(remoteExists && known.remoteModifiedAt != null &&
(remote!!.etag != known.remoteEtag || remote.modifiedAt != known.remoteModifiedAt))
return when { return when {
!localExists && !remoteExists -> SyncDecision.SKIP !localExists && !remoteExists -> SyncDecision.SKIP
@@ -192,10 +259,22 @@ class SyncEngine @Inject constructor(
} }
!localExists && remoteExists -> when { !localExists && remoteExists -> when {
known == null -> when (direction) { known == null -> if (!hasPriorSyncState) {
// Initial sync: no history at all — remote files are new, download them.
when (direction) {
SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP else -> SyncDecision.SKIP
} }
} else {
// Pair has been synced before but this file has no state record
// (e.g. uploaded before state-tracking was fixed). Treat the same
// as a known remote-deletion: apply mirror/keep behavior.
when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE
else -> SyncDecision.SKIP
}
}
else -> when { else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE
@@ -226,29 +305,6 @@ class SyncEngine @Inject constructor(
} }
else -> SyncDecision.SKIP else -> SyncDecision.SKIP
} }
}
private fun buildState(
pairId: Long,
rel: String,
local: LocalFileInfo?,
remote: RemoteFile?,
) = com.syncflow.data.db.entities.SyncFileStateEntity(
syncPairId = pairId,
relativePath = rel,
localModifiedAt = local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) },
localSizeBytes = local?.sizeBytes ?: 0L,
localHash = null,
remoteModifiedAt = remote?.modifiedAt,
remoteSizeBytes = remote?.sizeBytes ?: 0L,
remoteEtag = remote?.etag,
lastSyncedAt = Instant.now(),
syncedHash = null,
)
private suspend fun logEvent(pairId: Long, type: SyncEventType, file: String?, msg: String?, bytes: Long) {
eventDao.insert(SyncEventEntity(syncPairId = pairId, timestamp = Instant.now(), eventType = type, filePath = file, message = msg, bytesTransferred = bytes))
}
} }
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP } enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }
@@ -1,5 +1,6 @@
package com.syncflow.ui.addpair package com.syncflow.ui.addpair
import android.content.Context
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.SyncPairDao
import com.syncflow.data.db.entities.CloudAccountEntity import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.data.db.entities.toDomain
import com.syncflow.domain.model.* import com.syncflow.domain.model.*
import com.syncflow.worker.FileWatchService
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -31,7 +33,7 @@ data class AddPairUiState(
val scheduleType: ScheduleType = ScheduleType.INTERVAL, val scheduleType: ScheduleType = ScheduleType.INTERVAL,
val intervalMinutes: Int = 30, val intervalMinutes: Int = 30,
val dailyTime: String = "02:00", val dailyTime: String = "02:00",
val weekdays: Int = 0b1111111, // all 7 days by default val weekdays: Int = 0b1111111,
// ── Constraints ────────────────────────────────────────────────────────── // ── Constraints ──────────────────────────────────────────────────────────
val wifiOnly: Boolean = true, val wifiOnly: Boolean = true,
val wifiSsid: String = "", val wifiSsid: String = "",
@@ -57,6 +59,7 @@ data class AddPairUiState(
class AddPairViewModel @Inject constructor( class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao, private val syncPairDao: SyncPairDao,
private val accountDao: CloudAccountDao, private val accountDao: CloudAccountDao,
@ApplicationContext private val context: Context,
savedState: SavedStateHandle, savedState: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -147,7 +150,10 @@ class AddPairViewModel @Inject constructor(
) )
if (editPairId == null) syncPairDao.insert(entity) else syncPairDao.update(entity) 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) } } .onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
} }
} }
@@ -27,7 +27,7 @@ fun RemoteBrowserDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
vm: RemoteBrowserViewModel = hiltViewModel(), vm: RemoteBrowserViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(accountId) { vm.init(accountId, initialPath) } LaunchedEffect(accountId, initialPath) { vm.init(accountId, initialPath) }
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
@@ -81,6 +81,7 @@ fun RemoteBrowserDialog(
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error) Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
Text(state.error!!, color = MaterialTheme.colorScheme.error) Text(state.error!!, color = MaterialTheme.colorScheme.error)
FilledTonalButton(onClick = { vm.retry() }) { Text("Retry") }
} }
} }
state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -6,6 +6,7 @@ import com.syncflow.data.providers.ProviderFactory
import com.syncflow.data.repository.AccountRepository import com.syncflow.data.repository.AccountRepository
import com.syncflow.domain.model.RemoteFile import com.syncflow.domain.model.RemoteFile
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -30,15 +31,19 @@ class RemoteBrowserViewModel @Inject constructor(
private val _state = MutableStateFlow(BrowserState()) private val _state = MutableStateFlow(BrowserState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private var loadJob: Job? = null
fun init(accountId: Long, startPath: String = "/") { fun init(accountId: Long, startPath: String = "/") {
_state.update { it.copy(accountId = accountId, currentPath = startPath, pathStack = listOf(startPath)) } loadJob?.cancel()
loadPath(accountId, startPath) _state.value = BrowserState(accountId = accountId, currentPath = startPath, pathStack = listOf(startPath), isLoading = true)
loadJob = loadPath(accountId, startPath)
} }
fun navigateTo(path: String) { fun navigateTo(path: String) {
val accountId = _state.value.accountId val accountId = _state.value.accountId
_state.update { s -> s.copy(currentPath = path, pathStack = s.pathStack + path) } loadJob?.cancel()
loadPath(accountId, path) _state.update { s -> s.copy(currentPath = path, pathStack = s.pathStack + path, isLoading = true, entries = emptyList(), error = null) }
loadJob = loadPath(accountId, path)
} }
fun navigateUp(): Boolean { fun navigateUp(): Boolean {
@@ -46,21 +51,28 @@ class RemoteBrowserViewModel @Inject constructor(
if (stack.size <= 1) return false if (stack.size <= 1) return false
val newStack = stack.dropLast(1) val newStack = stack.dropLast(1)
val parent = newStack.last() val parent = newStack.last()
_state.update { it.copy(currentPath = parent, pathStack = newStack) } loadJob?.cancel()
loadPath(_state.value.accountId, parent) _state.update { it.copy(currentPath = parent, pathStack = newStack, isLoading = true, entries = emptyList(), error = null) }
loadJob = loadPath(_state.value.accountId, parent)
return true return true
} }
private fun loadPath(accountId: Long, path: String) { fun retry() {
viewModelScope.launch { val s = _state.value
if (s.accountId == -1L) return
loadJob?.cancel()
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null) }
loadJob = loadPath(s.accountId, s.currentPath)
}
private fun loadPath(accountId: Long, path: String): Job = viewModelScope.launch {
val account = accountRepository.getAccount(accountId) val account = accountRepository.getAccount(accountId)
if (account == null) { if (account == null) {
_state.update { it.copy(isLoading = false, error = "Account not found") } _state.update { it.copy(isLoading = false, error = "Account not found") }
return@launch return@launch
} }
val provider = runCatching { providerFactory.create(account) }.getOrElse { e -> val provider = runCatching { providerFactory.create(account) }.getOrElse { e ->
_state.update { it.copy(isLoading = false, error = e.message) } _state.update { it.copy(isLoading = false, error = e.message ?: "Failed to create provider") }
return@launch return@launch
} }
provider.listFiles(path) provider.listFiles(path)
@@ -71,5 +83,4 @@ class RemoteBrowserViewModel @Inject constructor(
_state.update { it.copy(isLoading = false, error = e.message ?: "Failed to list files") } _state.update { it.copy(isLoading = false, error = e.message ?: "Failed to list files") }
} }
} }
}
} }
@@ -1,9 +1,16 @@
package com.syncflow.ui.home 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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.outlined.*
@@ -11,11 +18,13 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncStatus import com.syncflow.domain.model.SyncStatus
import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -58,49 +67,117 @@ private fun SyncPairCard(
onSync: () -> Unit, onSync: () -> Unit,
onToggle: () -> Unit, onToggle: () -> Unit,
) { ) {
ElevatedCard( val accentColor = pair.lastSyncResult.accentColor
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Row(modifier = Modifier.fillMaxWidth()) {
// Colored left accent bar
Box(
modifier = Modifier
.width(3.dp)
.height(IntrinsicSize.Min)
.defaultMinSize(minHeight = 80.dp),
) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(3.dp),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = accentColor,
) {}
}
}
Column(modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(pair.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(2.dp))
Text( Text(
pair.localPath.takeLast(40), pair.name,
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(2.dp))
val localShortName = pair.localPath.toDisplayPath()
Text(
localShortName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.ArrowForward,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(4.dp))
Text(
pair.remotePath,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Switch(checked = pair.isEnabled, onCheckedChange = { onToggle() })
} }
Spacer(Modifier.height(10.dp)) Switch(
checked = pair.isEnabled,
onCheckedChange = { onToggle() },
)
}
Spacer(Modifier.height(8.dp))
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
StatusChip(pair.lastSyncResult) StatusPill(pair.lastSyncResult)
if (pair.pendingConflicts > 0) { if (pair.pendingConflicts > 0) {
AssistChip( Surface(
onClick = {}, shape = RoundedCornerShape(50),
label = { Text("${pair.pendingConflicts} conflicts") }, color = MaterialTheme.colorScheme.errorContainer,
leadingIcon = { Icon(Icons.Default.Warning, null, Modifier.size(16.dp)) }, ) {
colors = AssistChipDefaults.assistChipColors( Row(
containerColor = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(Icons.Default.Warning, null, Modifier.size(12.dp), tint = MaterialTheme.colorScheme.error)
Text(
"${pair.pendingConflicts} conflicts",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
) )
} }
}
}
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
pair.lastSyncAt?.let { at -> pair.lastSyncAt?.let { at ->
Text( Text(
at.formatRelative(), at.toRelativeString(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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)) { 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
},
)
}
} }
} }
} }
@@ -108,21 +185,38 @@ private fun SyncPairCard(
} }
@Composable @Composable
private fun StatusChip(status: SyncStatus) { private fun StatusPill(status: SyncStatus) {
val (icon, label, color) = when (status) { val (icon, label) = when (status) {
SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer) SyncStatus.SUCCESS -> Pair(Icons.Default.CheckCircle, "Synced")
SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer) SyncStatus.SYNCING -> Pair(Icons.Default.Sync, "Syncing…")
SyncStatus.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer) SyncStatus.FAILED -> Pair(Icons.Default.Error, "Failed")
SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer) SyncStatus.CONFLICT -> Pair(Icons.Default.Warning, "Conflict")
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber, "Partial", MaterialTheme.colorScheme.tertiaryContainer) SyncStatus.PARTIAL -> Pair(Icons.Default.WarningAmber, "Partial")
SyncStatus.IDLE -> Triple(Icons.Outlined.Circle, "Idle", MaterialTheme.colorScheme.surfaceVariant) SyncStatus.IDLE -> Pair(Icons.Outlined.Circle, "Idle")
} }
AssistChip( val containerColor = status.accentColor
onClick = {}, val rotation by rememberInfiniteTransition(label = "syncSpin").animateFloat(
label = { Text(label) }, initialValue = 0f, targetValue = 360f,
leadingIcon = { Icon(icon, null, Modifier.size(16.dp)) }, animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing)),
colors = AssistChipDefaults.assistChipColors(containerColor = color), label = "rotation",
) )
Surface(
shape = RoundedCornerShape(50),
color = containerColor.copy(alpha = 0.15f),
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
icon, null,
Modifier.size(12.dp).graphicsLayer { if (status == SyncStatus.SYNCING) rotationZ = rotation },
tint = containerColor,
)
Text(label, style = MaterialTheme.typography.labelSmall, color = containerColor)
}
}
} }
@Composable @Composable
@@ -132,15 +226,49 @@ private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Icon(Icons.Outlined.CloudSync, null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.primary) Icon(
Icons.Outlined.CloudSync,
null,
modifier = Modifier.size(100.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f),
)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("No sync pairs yet", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Medium) Text("No sync pairs yet", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Tap + to create your first sync", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(
"Tap + to create your first sync",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Button(onClick = onAdd) { Text("Add Sync Pair") } FilledTonalButton(onClick = onAdd) { Text("Add Sync Pair") }
} }
} }
private val fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) private val SyncStatus.accentColor: Color
private fun Instant.formatRelative(): String = fmt.format(atZone(ZoneId.systemDefault())) @Composable get() = when (this) {
SyncStatus.SUCCESS -> MaterialTheme.colorScheme.primary
SyncStatus.SYNCING -> MaterialTheme.colorScheme.secondary
SyncStatus.FAILED -> MaterialTheme.colorScheme.error
SyncStatus.CONFLICT,
SyncStatus.PARTIAL -> MaterialTheme.colorScheme.tertiary
SyncStatus.IDLE -> MaterialTheme.colorScheme.outline
}
private fun String.toDisplayPath(): String {
// For SAF content:// URIs, decode the last path segment (e.g. primary%3ASyncFlowTest → SyncFlowTest)
val decoded = java.net.URLDecoder.decode(this, "UTF-8")
return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
}
private fun Instant.toRelativeString(): String {
val diff = Duration.between(this, Instant.now()).abs()
return when {
diff.toMinutes() < 1 -> "Just now"
diff.toMinutes() < 60 -> "${diff.toMinutes()} min ago"
diff.toHours() < 24 -> "${diff.toHours()} hr ago"
diff.toDays() == 1L -> "Yesterday"
else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.format(atZone(ZoneId.systemDefault()))
}
}
@@ -1,13 +1,17 @@
package com.syncflow.ui.home package com.syncflow.ui.home
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.ScheduleType import com.syncflow.domain.model.ScheduleType
import com.syncflow.worker.FileWatchService
import com.syncflow.worker.SyncWorker import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -17,6 +21,7 @@ import javax.inject.Inject
class HomeViewModel @Inject constructor( class HomeViewModel @Inject constructor(
private val syncPairDao: SyncPairDao, private val syncPairDao: SyncPairDao,
private val workManager: WorkManager, private val workManager: WorkManager,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
val syncPairs = syncPairDao.observeAll() val syncPairs = syncPairDao.observeAll()
@@ -29,21 +34,28 @@ class HomeViewModel @Inject constructor(
fun toggleEnabled(pair: SyncPairEntity) { fun toggleEnabled(pair: SyncPairEntity) {
viewModelScope.launch { viewModelScope.launch {
syncPairDao.update(pair.copy(isEnabled = !pair.isEnabled)) val nowEnabled = !pair.isEnabled
if (!pair.isEnabled && pair.scheduleType != ScheduleType.MANUAL) { syncPairDao.update(pair.copy(isEnabled = nowEnabled))
if (nowEnabled) {
when (pair.scheduleType) {
ScheduleType.ON_CHANGE -> FileWatchService.start(context)
ScheduleType.MANUAL -> { /* nothing */ }
else -> {
val req = SyncWorker.buildPeriodicRequest( val req = SyncWorker.buildPeriodicRequest(
pair.id, pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15), pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly, pair.wifiOnly,
pair.chargingOnly, pair.chargingOnly,
) )
workManager.enqueueUniquePeriodicWork( workManager.enqueueUniquePeriodicWork("periodic_${pair.id}", ExistingPeriodicWorkPolicy.UPDATE, req)
"periodic_${pair.id}", }
androidx.work.ExistingPeriodicWorkPolicy.UPDATE, }
req,
)
} else { } else {
workManager.cancelAllWorkByTag("sync_${pair.id}") workManager.cancelAllWorkByTag("sync_${pair.id}")
// Refresh watcher (it will stop itself if no ON_CHANGE pairs remain)
if (pair.scheduleType == ScheduleType.ON_CHANGE) {
FileWatchService.start(context)
}
} }
} }
} }
@@ -6,8 +6,15 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -19,8 +26,11 @@ 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
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.syncflow.R
import com.syncflow.ui.home.HomeScreen import com.syncflow.ui.home.HomeScreen
import com.syncflow.ui.settings.SettingsScreen import com.syncflow.ui.settings.SettingsScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -38,11 +48,26 @@ fun MainShell(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( CenterAlignedTopAppBar(
title = { Text("SyncFlow", fontWeight = FontWeight.Bold) }, title = {
colors = TopAppBarDefaults.topAppBarColors( Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.ic_sync),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(8.dp))
Text(
"SyncFlow",
style = MaterialTheme.typography.titleLarge,
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
), ),
modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars),
) )
}, },
bottomBar = { bottomBar = {
@@ -81,6 +106,8 @@ fun MainShell(
text = { Text("Add Sync") }, text = { Text("Add Sync") },
icon = { Icon(Icons.Default.Add, null) }, icon = { Icon(Icons.Default.Add, null) },
onClick = onAddPair, onClick = onAddPair,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) )
} }
}, },
@@ -1,18 +1,32 @@
package com.syncflow.ui.pairdetail 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncEventEntity import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncEventType import com.syncflow.domain.model.SyncEventType
import com.syncflow.domain.model.SyncStatus
import java.time.Duration
import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
@@ -36,7 +50,10 @@ fun PairDetailScreen(
title = { Text("Delete sync pair?") }, title = { Text("Delete sync pair?") },
text = { Text("This removes the pair and all sync history. Files are NOT deleted.") }, text = { Text("This removes the pair and all sync history. Files are NOT deleted.") },
confirmButton = { confirmButton = {
TextButton(onClick = { vm.delete(); onBack() }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) { TextButton(
onClick = { vm.delete(); onBack() },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("Delete") Text("Delete")
} }
}, },
@@ -60,8 +77,12 @@ fun PairDetailScreen(
LazyColumn( LazyColumn(
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item {
pair?.let { p -> StatusBanner(p) }
}
item { item {
pair?.let { p -> pair?.let { p ->
InfoCard(p.localPath, p.remotePath, p.syncDirection.name, p.scheduleType.name) InfoCard(p.localPath, p.remotePath, p.syncDirection.name, p.scheduleType.name)
@@ -85,8 +106,22 @@ fun PairDetailScreen(
} }
item { item {
Text("Activity", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) Row(
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 4.dp),
) {
Box(
modifier = Modifier
.width(3.dp)
.height(16.dp)
.clip(RoundedCornerShape(2.dp)),
) {
Surface(color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxSize()) {}
}
Spacer(Modifier.width(8.dp))
Text("Activity", style = MaterialTheme.typography.titleSmall)
}
HorizontalDivider(modifier = Modifier.padding(top = 4.dp))
} }
if (events.isEmpty()) { if (events.isEmpty()) {
@@ -105,24 +140,94 @@ fun PairDetailScreen(
} }
@Composable @Composable
private fun InfoCard(localPath: String, remotePath: String, direction: String, schedule: String) { private fun StatusBanner(pair: SyncPairEntity) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { val (icon, label, containerColor) = when (pair.lastSyncResult) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer)
InfoRow(Icons.Default.PhoneAndroid, "Local", localPath) SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer)
InfoRow(Icons.Default.Cloud, "Remote", remotePath) SyncStatus.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer)
InfoRow(Icons.Default.SwapHoriz, "Direction", direction) SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer)
InfoRow(Icons.Default.Schedule, "Schedule", schedule) 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),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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)
pair.lastSyncAt?.let {
Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall)
}
}
} }
} }
} }
@Composable @Composable
private fun InfoRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) { private fun InfoCard(localPath: String, remotePath: String, direction: String, schedule: String) {
Row(verticalAlignment = Alignment.CenterVertically) { Card(
Icon(icon, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
InfoRow(Icons.Default.PhoneAndroid, "Local", localPath.toDisplayPath())
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
InfoRow(Icons.Default.Cloud, "Remote", remotePath)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
Row {
InfoRow(Icons.Default.SwapHoriz, "Direction", direction, modifier = Modifier.weight(1f))
InfoRow(Icons.Default.Schedule, "Schedule", schedule, modifier = Modifier.weight(1f))
}
}
}
}
@Composable
private fun InfoRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
value: String,
modifier: Modifier = Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(28.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
icon,
null,
Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("$label: ", style = MaterialTheme.typography.labelMedium) Column {
Text(value, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodySmall)
}
} }
} }
@@ -130,38 +235,65 @@ private fun InfoRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label
private fun EventRow(event: SyncEventEntity) { private fun EventRow(event: SyncEventEntity) {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val zone = ZoneId.systemDefault() val zone = ZoneId.systemDefault()
val (icon, tint) = eventIcon(event.eventType) val dotColor = eventColor(event.eventType)
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(icon, null, Modifier.size(16.dp), tint = tint) // Colored dot indicator
Spacer(Modifier.width(8.dp)) Surface(
shape = RoundedCornerShape(50),
color = dotColor,
modifier = Modifier.size(8.dp),
) {}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(event.filePath ?: event.message ?: event.eventType.name, style = MaterialTheme.typography.bodySmall) Text(
event.filePath ?: event.message ?: event.eventType.name,
style = MaterialTheme.typography.bodySmall,
)
event.message?.takeIf { event.filePath != null }?.let { event.message?.takeIf { event.filePath != null }?.let {
Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
Text(fmt.format(event.timestamp.atZone(zone)), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(
fmt.format(event.timestamp.atZone(zone)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
@Composable @Composable
private fun eventIcon(type: SyncEventType): Pair<androidx.compose.ui.graphics.vector.ImageVector, androidx.compose.ui.graphics.Color> { private fun eventColor(type: SyncEventType): Color = when (type) {
val green = MaterialTheme.colorScheme.primary SyncEventType.SYNC_STARTED -> MaterialTheme.colorScheme.primary
val red = MaterialTheme.colorScheme.error SyncEventType.SYNC_COMPLETED -> MaterialTheme.colorScheme.primary
val orange = MaterialTheme.colorScheme.tertiary SyncEventType.SYNC_FAILED -> MaterialTheme.colorScheme.error
val grey = MaterialTheme.colorScheme.onSurfaceVariant SyncEventType.FILE_UPLOADED -> MaterialTheme.colorScheme.secondary
return when (type) { SyncEventType.FILE_DOWNLOADED -> MaterialTheme.colorScheme.secondary
SyncEventType.SYNC_STARTED -> Pair(Icons.Default.PlayArrow, green) SyncEventType.FILE_DELETED -> MaterialTheme.colorScheme.tertiary
SyncEventType.SYNC_COMPLETED -> Pair(Icons.Default.CheckCircle, green) SyncEventType.FILE_SKIPPED -> MaterialTheme.colorScheme.onSurfaceVariant
SyncEventType.SYNC_FAILED -> Pair(Icons.Default.Error, red) SyncEventType.CONFLICT_DETECTED -> MaterialTheme.colorScheme.tertiary
SyncEventType.FILE_UPLOADED -> Pair(Icons.Default.Upload, green) SyncEventType.CONFLICT_RESOLVED -> MaterialTheme.colorScheme.primary
SyncEventType.FILE_DOWNLOADED -> Pair(Icons.Default.Download, green) }
SyncEventType.FILE_DELETED -> Pair(Icons.Default.Delete, orange)
SyncEventType.FILE_SKIPPED -> Pair(Icons.Default.SkipNext, grey) private fun String.toDisplayPath(): String {
SyncEventType.CONFLICT_DETECTED -> Pair(Icons.Default.Warning, orange) val decoded = java.net.URLDecoder.decode(this, "UTF-8")
SyncEventType.CONFLICT_RESOLVED -> Pair(Icons.Default.CheckCircle, green) return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
}
private fun Instant.toRelativeString(): String {
val diff = Duration.between(this, Instant.now()).abs()
return when {
diff.toMinutes() < 1 -> "Just now"
diff.toMinutes() < 60 -> "${diff.toMinutes()} min ago"
diff.toHours() < 24 -> "${diff.toHours()} hr ago"
diff.toDays() == 1L -> "Yesterday"
else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.format(atZone(ZoneId.systemDefault()))
} }
} }
@@ -1,14 +1,17 @@
package com.syncflow.ui.settings package com.syncflow.ui.settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.CloudAccountEntity import com.syncflow.data.db.entities.CloudAccountEntity
@@ -45,15 +48,18 @@ fun SettingsScreen(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
item { item {
Row(verticalAlignment = Alignment.CenterVertically) { SectionHeader(title = "Cloud Accounts")
Text("Cloud Accounts", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
FilledTonalButton(onClick = onAddAccount) { FilledTonalButton(onClick = onAddAccount) {
Icon(Icons.Default.Add, null, Modifier.size(18.dp)) Icon(Icons.Default.Add, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("Add Account") Text("Add Account")
} }
} }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
} }
if (accounts.isEmpty()) { if (accounts.isEmpty()) {
@@ -63,9 +69,22 @@ fun SettingsScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Icon(Icons.Default.CloudOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(
Text("No accounts yet", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Icons.Default.CloudOff,
Text("Add a cloud account to start syncing", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) null,
Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Text(
"No accounts yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"Add a cloud account to start syncing",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = onAddAccount) { OutlinedButton(onClick = onAddAccount) {
Icon(Icons.Default.Add, null, Modifier.size(16.dp)) Icon(Icons.Default.Add, null, Modifier.size(16.dp))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
@@ -80,11 +99,17 @@ fun SettingsScreen(
} }
item { item {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(8.dp))
Text("Security", style = MaterialTheme.typography.titleMedium) SectionHeader(title = "Security")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Spacer(Modifier.height(4.dp))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@@ -98,22 +123,78 @@ fun SettingsScreen(
Switch(checked = biometricEnabled, onCheckedChange = { vm.setBiometricLock(it) }) Switch(checked = biometricEnabled, onCheckedChange = { vm.setBiometricLock(it) })
} }
} }
}
item { item {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(8.dp))
Text("About", style = MaterialTheme.typography.titleMedium) SectionHeader(title = "About")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Spacer(Modifier.height(4.dp))
Text("SyncFlow v${com.syncflow.BuildConfig.VERSION_NAME} — Free, no subscription.", style = MaterialTheme.typography.bodySmall) Card(
Text("Open source. No ads. No tracking.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
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,
)
Text(
"Open source. No ads. No tracking.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} }
} }
} }
@Composable
private fun SectionHeader(title: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.width(3.dp)
.height(16.dp)
.clip(RoundedCornerShape(2.dp)),
) {
Surface(color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxSize()) {}
}
Spacer(Modifier.width(8.dp))
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
@Composable @Composable
private fun AccountCard(acct: CloudAccountEntity, onDelete: () -> Unit) { private fun AccountCard(acct: CloudAccountEntity, onDelete: () -> Unit) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(providerIcon(acct.providerType), null, Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary) // Icon with primaryContainer background
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(40.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
providerIcon(acct.providerType),
null,
Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(acct.displayName, style = MaterialTheme.typography.bodyMedium) Text(acct.displayName, style = MaterialTheme.typography.bodyMedium)
@@ -2,8 +2,27 @@ package com.syncflow.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val SyncBlue = Color(0xFF2196F3) // Primary — indigo
val SyncGreen = Color(0xFF4CAF50) val Indigo600 = Color(0xFF4F46E5)
val SyncOrange = Color(0xFFFF9800) val Indigo900 = Color(0xFF312E81)
val SyncRed = Color(0xFFF44336) val Indigo100 = Color(0xFFE0E7FF)
val SyncPurple = Color(0xFF9C27B0) val Indigo50 = Color(0xFFEEF2FF)
// Secondary — teal
val Teal600 = Color(0xFF0D9488)
val Teal100 = Color(0xFFCCFBF1)
// Tertiary — amber
val Amber500 = Color(0xFFF59E0B)
val Amber100 = Color(0xFFFEF3C7)
// Neutrals
val Slate50 = Color(0xFFF8FAFC)
val Slate100 = Color(0xFFF1F5F9)
val Slate200 = Color(0xFFE2E8F0)
val Slate600 = Color(0xFF475569)
val Slate900 = Color(0xFF0F172A)
// Semantic
val GreenSuccess = Color(0xFF16A34A)
val RedError = Color(0xFFDC2626)
@@ -1,50 +1,77 @@
package com.syncflow.ui.theme package com.syncflow.ui.theme
import android.app.Activity import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
private val LightColors = lightColorScheme( private val LightColors = lightColorScheme(
primary = SyncBlue, primary = Indigo600,
onPrimary = androidx.compose.ui.graphics.Color.White, onPrimary = Color.White,
secondary = SyncGreen, primaryContainer = Indigo100,
tertiary = SyncPurple, onPrimaryContainer = Indigo900,
secondary = Teal600,
onSecondary = Color.White,
secondaryContainer = Teal100,
tertiary = Amber500,
tertiaryContainer = Amber100,
background = Slate50,
surface = Color.White,
surfaceVariant = Slate100,
onSurfaceVariant = Slate600,
error = RedError,
errorContainer = Color(0xFFFEE2E2),
outline = Slate200,
) )
private val DarkColors = darkColorScheme( private val DarkColors = darkColorScheme(
primary = SyncBlue, primary = Color(0xFF818CF8),
secondary = SyncGreen, onPrimary = Indigo900,
tertiary = SyncPurple, primaryContainer = Color(0xFF3730A3),
onPrimaryContainer = Indigo100,
secondary = Color(0xFF2DD4BF),
onSecondary = Color(0xFF003731),
secondaryContainer = Color(0xFF00504A),
tertiary = Amber500,
tertiaryContainer = Color(0xFF92400E),
background = Color(0xFF0F0F1A),
surface = Color(0xFF1A1A2E),
surfaceVariant = Color(0xFF252538),
onSurfaceVariant = Color(0xFF94A3B8),
error = Color(0xFFF87171),
errorContainer = Color(0xFF7F1D1D),
outline = Color(0xFF334155),
)
private val AppTypography = Typography(
titleLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 22.sp, letterSpacing = (-0.5).sp),
titleMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp, letterSpacing = (-0.25).sp),
titleSmall = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 14.sp, letterSpacing = 0.sp),
labelMedium = TextStyle(fontWeight = FontWeight.Medium, fontSize = 12.sp, letterSpacing = 0.1.sp),
labelSmall = TextStyle(fontWeight = FontWeight.Medium, fontSize = 11.sp, letterSpacing = 0.1.sp),
) )
@Composable @Composable
fun SyncFlowTheme( fun SyncFlowTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colorScheme = when { val colorScheme = if (darkTheme) DarkColors else LightColors
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val ctx = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
}
darkTheme -> DarkColors
else -> LightColors
}
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb() WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = android.graphics.Color.TRANSPARENT
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
} }
} }
MaterialTheme(colorScheme = colorScheme, typography = Typography(), content = content) MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
} }
@@ -3,6 +3,7 @@ package com.syncflow.worker
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -22,17 +23,24 @@ class BootReceiver : BroadcastReceiver() {
val pending = goAsync() val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
syncPairDao.getEnabled() val pairs = syncPairDao.getEnabled()
.filter { it.scheduleType != ScheduleType.MANUAL && it.scheduleType != ScheduleType.ON_CHANGE } var hasOnChange = false
.forEach { pair -> pairs.forEach { pair ->
when (pair.scheduleType) {
ScheduleType.ON_CHANGE -> hasOnChange = true
ScheduleType.MANUAL -> { /* nothing */ }
else -> {
val req = SyncWorker.buildPeriodicRequest( val req = SyncWorker.buildPeriodicRequest(
pair.id, pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15), pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly, pair.wifiOnly,
pair.chargingOnly, pair.chargingOnly,
) )
wm.enqueueUniquePeriodicWork("periodic_${pair.id}", androidx.work.ExistingPeriodicWorkPolicy.UPDATE, req) wm.enqueueUniquePeriodicWork("periodic_${pair.id}", ExistingPeriodicWorkPolicy.UPDATE, req)
} }
}
}
if (hasOnChange) FileWatchService.start(context)
} finally { } finally {
pending.finish() pending.finish()
} }
@@ -0,0 +1,182 @@
package com.syncflow.worker
import android.app.*
import android.content.Context
import android.content.Intent
import android.database.ContentObserver
import android.net.Uri
import android.os.Build
import android.os.FileObserver
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
import com.syncflow.MainActivity
import com.syncflow.R
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@AndroidEntryPoint
class FileWatchService : Service() {
@Inject lateinit var syncPairDao: SyncPairDao
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper())
private val fileObservers = mutableMapOf<Long, FileObserver>()
private val contentObservers = mutableMapOf<Long, ContentObserver>()
private val debounceJobs = mutableMapOf<Long, Job>()
companion object {
const val CHANNEL_WATCH = "sync_watching"
private const val NOTIFICATION_ID = 1002
fun start(context: Context) {
val intent = Intent(context, FileWatchService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, FileWatchService::class.java))
}
}
override fun onCreate() {
super.onCreate()
ensureChannel()
startForeground(NOTIFICATION_ID, buildNotification(0))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
scope.launch { refresh() }
return START_STICKY
}
override fun onDestroy() {
clearWatchers()
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private suspend fun refresh() {
clearWatchers()
val pairs = syncPairDao.getEnabled().filter { it.scheduleType == ScheduleType.ON_CHANGE }
pairs.forEach { pair ->
val pairId = pair.id
val localPath = pair.localPath
if (localPath.startsWith("content://")) {
val treeUri = Uri.parse(localPath)
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 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 {
@Suppress("DEPRECATION")
object : FileObserver(localPath, mask) {
override fun onEvent(event: Int, path: String?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
}
}
observer.startWatching()
fileObservers[pairId] = observer
Timber.d("FileWatchService: watching filesystem path for pair $pairId: $localPath")
}
}
val count = fileObservers.size + contentObservers.size
updateNotification(count)
if (count == 0) {
Timber.d("FileWatchService: no ON_CHANGE pairs, stopping")
stopSelf()
}
}
private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
debounceJobs[pairId]?.cancel()
debounceJobs[pairId] = scope.launch {
delay(5_000)
val pair = syncPairDao.getById(pairId)
if (pair == null || !pair.isEnabled) return@launch
Timber.d("FileWatchService: triggering sync for pair $pairId after debounce")
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly)
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req)
}
}
private fun clearWatchers() {
fileObservers.values.forEach { it.stopWatching() }
fileObservers.clear()
contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) }
contentObservers.clear()
debounceJobs.values.forEach { it.cancel() }
debounceJobs.clear()
}
private fun ensureChannel() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(CHANNEL_WATCH) == null) {
nm.createNotificationChannel(
NotificationChannel(CHANNEL_WATCH, "File watching", NotificationManager.IMPORTANCE_LOW).apply {
description = "Background service watching folders for changes"
setShowBadge(false)
}
)
}
}
private fun buildNotification(count: Int): Notification {
val tapIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_WATCH)
.setContentTitle("SyncFlow")
.setContentText(
if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes"
else "Starting file watcher…"
)
.setSmallIcon(R.drawable.ic_sync)
.setContentIntent(tapIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
}
private fun updateNotification(count: Int) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(count))
}
}
@@ -2,12 +2,15 @@ package com.syncflow.worker
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.* import androidx.work.*
import com.syncflow.MainActivity
import com.syncflow.R import com.syncflow.R
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.toDomain import com.syncflow.data.db.entities.toDomain
@@ -33,44 +36,121 @@ class SyncWorker @AssistedInject constructor(
val pairId = inputData.getLong(KEY_PAIR_ID, -1L) val pairId = inputData.getLong(KEY_PAIR_ID, -1L)
if (pairId == -1L) return Result.failure() if (pairId == -1L) return Result.failure()
setForeground(buildForegroundInfo("Syncing…"))
val pair = syncPairDao.getById(pairId) ?: return Result.failure() val pair = syncPairDao.getById(pairId) ?: return Result.failure()
val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure() val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure()
ensureChannels()
setForeground(buildForegroundInfo(pair.name, "Syncing…"))
return try { return try {
val domainPair = pair.toDomain() val domainPair = pair.toDomain()
val provider = providerFactory.create(account) val provider = providerFactory.create(account)
syncEngine.sync(domainPair, provider) val result = syncEngine.sync(domainPair, provider)
if (result.error != null && pair.notifyOnError) {
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
title = "${pair.name} — Sync failed",
text = result.error.message ?: "Unknown error",
priority = NotificationCompat.PRIORITY_DEFAULT,
)
} else if (pair.notifyOnComplete && result.error == null) {
val lines = buildList {
if (result.uploaded > 0) add("↑ Uploaded: ${result.uploaded} file${if (result.uploaded != 1) "s" else ""}")
if (result.downloaded > 0) add("↓ Downloaded: ${result.downloaded} file${if (result.downloaded != 1) "s" else ""}")
if (result.deleted > 0) add("🗑 Deleted: ${result.deleted} file${if (result.deleted != 1) "s" else ""}")
if (result.conflicts > 0) add("${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}")
}
val summary = if (lines.isEmpty()) "Up to date — nothing to sync" else lines.joinToString("\n")
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_COMPLETE,
title = "${pair.name} — Changes synced",
text = if (lines.isEmpty()) summary else lines.first(),
bigText = summary,
priority = NotificationCompat.PRIORITY_LOW,
)
}
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "SyncWorker failed for pair $pairId") Timber.e(e, "SyncWorker failed for pair $pairId")
if (pair.notifyOnError) {
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
title = "${pair.name} — Sync failed",
text = e.message ?: "Unknown error",
priority = NotificationCompat.PRIORITY_DEFAULT,
)
}
if (runAttemptCount < 3) Result.retry() else Result.failure() if (runAttemptCount < 3) Result.retry() else Result.failure()
} }
} }
private fun buildForegroundInfo(progress: String): ForegroundInfo { private fun ensureChannels() {
val channelId = "sync_channel"
val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(channelId) == null) { if (nm.getNotificationChannel(CHANNEL_PROGRESS) == null)
nm.createNotificationChannel(NotificationChannel(channelId, "Sync", NotificationManager.IMPORTANCE_LOW)) nm.createNotificationChannel(NotificationChannel(CHANNEL_PROGRESS, "Sync progress", NotificationManager.IMPORTANCE_LOW).apply {
description = "Shown while a sync is running"
})
if (nm.getNotificationChannel(CHANNEL_COMPLETE) == null)
nm.createNotificationChannel(NotificationChannel(CHANNEL_COMPLETE, "Sync complete", NotificationManager.IMPORTANCE_LOW).apply {
description = "Summary after each successful sync"
})
if (nm.getNotificationChannel(CHANNEL_ALERTS) == null)
nm.createNotificationChannel(NotificationChannel(CHANNEL_ALERTS, "Sync errors", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Alerts for sync failures and conflicts"
})
} }
val notification = NotificationCompat.Builder(applicationContext, channelId)
.setContentTitle("SyncFlow") private fun buildForegroundInfo(pairName: String, status: String): ForegroundInfo {
.setContentText(progress) val tapIntent = PendingIntent.getActivity(
applicationContext, 0,
Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_PROGRESS)
.setContentTitle(pairName)
.setContentText(status)
.setSmallIcon(R.drawable.ic_sync) .setSmallIcon(R.drawable.ic_sync)
.setContentIntent(tapIntent)
.setOngoing(true) .setOngoing(true)
.build() .build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else { else
ForegroundInfo(NOTIFICATION_ID, notification) ForegroundInfo(NOTIFICATION_ID, notification)
} }
private fun notify(id: Int, channelId: String, title: String, text: String, priority: Int, bigText: String? = null) {
val tapIntent = PendingIntent.getActivity(
applicationContext, id,
Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val builder = NotificationCompat.Builder(applicationContext, channelId)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(R.drawable.ic_sync)
.setPriority(priority)
.setContentIntent(tapIntent)
.setAutoCancel(true)
if (bigText != null) {
builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
}
nm.notify(id, builder.build())
} }
companion object { companion object {
const val KEY_PAIR_ID = "pair_id" const val KEY_PAIR_ID = "pair_id"
private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID = 1001
private const val RESULT_ID_OFFSET = 2000
private const val CHANNEL_PROGRESS = "sync_progress"
private const val CHANNEL_COMPLETE = "sync_complete"
private const val CHANNEL_ALERTS = "sync_alerts"
fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean): OneTimeWorkRequest { fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean): OneTimeWorkRequest {
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:type="linear"
android:angle="135"
android:startColor="#2E1065"
android:centerColor="#6D28D9"
android:endColor="#1E40AF"/>
</shape>
@@ -0,0 +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="108"
android:viewportHeight="108">
<!-- Outer soft glow ring -->
<path
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: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>
+2 -2
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
+2 -2
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -0,0 +1,164 @@
package com.syncflow.domain.sync
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.domain.model.*
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.Instant
class SyncDecideTest {
private val MS = 1_716_000_000_000L
private val MS2 = MS + 5_000L
private fun local(ms: Long = MS, size: Long = 100L) = LocalFileInfo("test.txt", size, ms)
private fun remote(ms: Long = MS, etag: String? = "abc", size: Long = 100L) =
RemoteFile(
path = "path/test.txt", name = "test.txt", isDirectory = false,
sizeBytes = size, modifiedAt = Instant.ofEpochMilli(ms),
etag = etag, mimeType = null,
)
private fun state(localMs: Long? = MS, remoteMs: Long? = MS, etag: String? = "abc") =
SyncFileStateEntity(
syncPairId = 1L, relativePath = "test.txt",
localModifiedAt = localMs?.let { Instant.ofEpochMilli(it) },
localSizeBytes = 100L, localHash = null,
remoteModifiedAt = remoteMs?.let { Instant.ofEpochMilli(it) },
remoteSizeBytes = 100L, remoteEtag = etag,
lastSyncedAt = Instant.now(), syncedHash = null,
)
private fun decide(
local: LocalFileInfo?, remote: RemoteFile?, known: SyncFileStateEntity? = null,
dir: SyncDirection = SyncDirection.TWO_WAY,
conflict: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
delete: DeleteBehavior = DeleteBehavior.MIRROR,
hasPriorState: Boolean = known != null,
) = syncDecide(dir, conflict, delete, local, remote, known, hasPriorState)
// ── first sync (no known state) ───────────────────────────────────────────
@Test fun `first sync both exist local newer uploads`() =
assertEquals(SyncDecision.UPLOAD, decide(local(MS2), remote(MS)))
@Test fun `first sync both exist remote newer downloads`() =
assertEquals(SyncDecision.DOWNLOAD, decide(local(MS), remote(MS2)))
@Test fun `first sync local only TWO_WAY uploads`() =
assertEquals(SyncDecision.UPLOAD, decide(local(), null))
@Test fun `first sync remote only TWO_WAY downloads`() =
assertEquals(SyncDecision.DOWNLOAD, decide(null, remote()))
// ── after upload: remote metadata null in state ───────────────────────────
@Test fun `second sync after upload remote metadata null skips`() {
// State saved after upload: local mtime known, remote unknown (null).
val known = state(localMs = MS, remoteMs = null, etag = null)
// Remote listing shows a new mtime (server assigned), but we treat null as "no change".
assertEquals(SyncDecision.SKIP, decide(local(MS), remote(MS2), known))
}
@Test fun `after upload local changed again re-uploads`() {
val known = state(localMs = MS, remoteMs = null, etag = null)
assertEquals(SyncDecision.UPLOAD, decide(local(MS2), remote(MS2), known))
}
// ── after download: local mtime recorded ─────────────────────────────────
@Test fun `second sync fully recorded skips`() {
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.SKIP, decide(local(MS), remote(MS, etag = "abc"), known))
}
@Test fun `remote changed after download downloads`() {
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.DOWNLOAD, decide(local(MS), remote(MS2, etag = "xyz"), known))
}
@Test fun `local changed after download uploads`() {
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.UPLOAD, decide(local(MS2), remote(MS, etag = "abc"), known))
}
// ── epoch-millis precision ────────────────────────────────────────────────
@Test fun `same millisecond timestamp treated as unchanged`() {
val ts = 1_716_393_136_789L
assertEquals(SyncDecision.SKIP,
decide(local(ts), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
}
@Test fun `1ms difference detected as local change`() {
val ts = 1_716_393_136_789L
assertEquals(SyncDecision.UPLOAD,
decide(local(ts + 1), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
}
@Test fun `epoch-second stored value differs from millis comparison`() {
// If we stored 1716393136 (seconds) and compare to 1716393136000 (millis) they differ →
// This was the original bug — now we store millis so they should match.
val ms = 1_716_393_136_000L // exact second boundary, no sub-second component
assertEquals(SyncDecision.SKIP,
decide(local(ms), remote(ms, etag = "e"), state(localMs = ms, remoteMs = ms, etag = "e")))
}
// ── delete behaviour ──────────────────────────────────────────────────────
@Test fun `local exists remote deleted TWO_WAY MIRROR deletes local`() =
assertEquals(SyncDecision.DELETE_LOCAL, decide(local(), null, state(), delete = DeleteBehavior.MIRROR))
@Test fun `local exists remote deleted KEEP skips`() =
assertEquals(SyncDecision.SKIP, decide(local(), null, state(), delete = DeleteBehavior.KEEP))
@Test fun `remote deleted UPLOAD_ONLY skips local deletion`() =
assertEquals(SyncDecision.SKIP,
decide(local(), null, state(), dir = SyncDirection.UPLOAD_ONLY))
@Test fun `local deleted TWO_WAY MIRROR deletes remote`() =
assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), state(), delete = DeleteBehavior.MIRROR))
@Test fun `local deleted TWO_WAY KEEP skips`() =
assertEquals(SyncDecision.SKIP, decide(null, remote(), state(), delete = DeleteBehavior.KEEP))
@Test fun `local deleted DOWNLOAD_ONLY skips remote deletion`() =
assertEquals(SyncDecision.SKIP,
decide(null, remote(), state(), dir = SyncDirection.DOWNLOAD_ONLY))
// ── local deleted, no state record (uploaded in broken version) ──────────
@Test fun `local deleted no known state but pair has prior history deletes remote`() =
// hasPriorState=true means the pair has been synced before; file has no state
// because it was uploaded when getFileMetadata was broken. Should still mirror deletion.
assertEquals(SyncDecision.DELETE_REMOTE,
decide(null, remote(), known = null, delete = DeleteBehavior.MIRROR, hasPriorState = true))
@Test fun `initial sync remote only no prior state downloads`() =
assertEquals(SyncDecision.DOWNLOAD,
decide(null, remote(), known = null, hasPriorState = false))
// ── first-seen SKIP saves baseline so later deletions are detected ────────
@Test fun `first sync both exist same mtime uploads local wins tie`() =
assertEquals(SyncDecision.UPLOAD, decide(local(MS), remote(MS, etag = "abc")))
@Test fun `after first-seen skip local deleted deletes remote`() {
// Simulate: first sync saw both sides identical → SKIP (state saved by engine).
// Then local file deleted → known is now present → DELETE_REMOTE.
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.DELETE_REMOTE,
decide(null, remote(MS, etag = "abc"), known, delete = DeleteBehavior.MIRROR))
}
// ── directions ────────────────────────────────────────────────────────────
@Test fun `UPLOAD_ONLY ignores remote changes`() =
assertEquals(SyncDecision.SKIP,
decide(local(MS), remote(MS2, etag = "new"), state(), dir = SyncDirection.UPLOAD_ONLY))
@Test fun `DOWNLOAD_ONLY ignores local changes`() =
assertEquals(SyncDecision.SKIP,
decide(local(MS2), remote(MS, etag = "abc"), state(), dir = SyncDirection.DOWNLOAD_ONLY))
}
Binary file not shown.
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.5 VERSION_NAME=1.0.17
VERSION_CODE=6 VERSION_CODE=18