Android 2.4.0 - Backend changes :)
This commit is contained in:
@@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.atridad.ascently"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 47
|
||||
versionName = "2.3.1"
|
||||
versionCode = 48
|
||||
versionName = "2.4.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
package com.atridad.ascently.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.core.content.edit
|
||||
import com.atridad.ascently.data.format.BackupAttempt
|
||||
import com.atridad.ascently.data.format.BackupClimbSession
|
||||
import com.atridad.ascently.data.format.BackupGym
|
||||
import com.atridad.ascently.data.format.BackupProblem
|
||||
import com.atridad.ascently.data.format.ClimbDataBackup
|
||||
import com.atridad.ascently.data.repository.ClimbRepository
|
||||
import com.atridad.ascently.data.state.DataStateManager
|
||||
import com.atridad.ascently.utils.AppLogger
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import com.atridad.ascently.utils.ImageNamingUtils
|
||||
import com.atridad.ascently.utils.ImageUtils
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
class AscentlySyncProvider(
|
||||
private val context: Context,
|
||||
private val repository: ClimbRepository
|
||||
) : SyncProvider {
|
||||
|
||||
override val type: SyncProviderType = SyncProviderType.SERVER
|
||||
|
||||
private val dataStateManager = DataStateManager(context)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AscentlySyncProvider"
|
||||
}
|
||||
|
||||
private val sharedPreferences: SharedPreferences =
|
||||
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
|
||||
|
||||
private val httpClient =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(45, TimeUnit.SECONDS)
|
||||
.readTimeout(90, TimeUnit.SECONDS)
|
||||
.writeTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
private val _isConfigured = MutableStateFlow(false)
|
||||
override val isConfigured: StateFlow<Boolean> = _isConfigured.asStateFlow()
|
||||
|
||||
private var isOfflineMode = false
|
||||
|
||||
private object Keys {
|
||||
const val SERVER_URL = "server_url"
|
||||
const val AUTH_TOKEN = "auth_token"
|
||||
const val IS_CONNECTED = "is_connected"
|
||||
const val LAST_SYNC_TIME = "last_sync_time"
|
||||
const val OFFLINE_MODE = "offline_mode"
|
||||
}
|
||||
|
||||
init {
|
||||
loadInitialState()
|
||||
updateConfiguredState()
|
||||
}
|
||||
|
||||
private fun loadInitialState() {
|
||||
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
|
||||
isOfflineMode = sharedPreferences.getBoolean(Keys.OFFLINE_MODE, false)
|
||||
}
|
||||
|
||||
private fun updateConfiguredState() {
|
||||
_isConfigured.value = serverUrl.isNotBlank() && authToken.isNotBlank()
|
||||
}
|
||||
|
||||
var serverUrl: String
|
||||
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
|
||||
set(value) {
|
||||
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
|
||||
updateConfiguredState()
|
||||
_isConnected.value = false
|
||||
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
|
||||
}
|
||||
|
||||
var authToken: String
|
||||
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
|
||||
set(value) {
|
||||
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
|
||||
updateConfiguredState()
|
||||
_isConnected.value = false
|
||||
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
|
||||
private fun isNetworkAvailable(): Boolean {
|
||||
val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return when {
|
||||
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
|
||||
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
|
||||
override suspend fun sync() {
|
||||
if (isOfflineMode) {
|
||||
AppLogger.d(TAG) { "Sync skipped: Offline mode is enabled." }
|
||||
return
|
||||
}
|
||||
if (!isNetworkAvailable()) {
|
||||
AppLogger.d(TAG) { "Sync skipped: No internet connection." }
|
||||
throw SyncException.NetworkError("No internet connection.")
|
||||
}
|
||||
if (!_isConfigured.value) {
|
||||
throw SyncException.NotConfigured
|
||||
}
|
||||
if (!_isConnected.value) {
|
||||
throw SyncException.NotConnected
|
||||
}
|
||||
|
||||
val localBackup = createBackupFromRepository()
|
||||
val serverBackup = downloadData()
|
||||
|
||||
val hasLocalData =
|
||||
localBackup.gyms.isNotEmpty() ||
|
||||
localBackup.problems.isNotEmpty() ||
|
||||
localBackup.sessions.isNotEmpty() ||
|
||||
localBackup.attempts.isNotEmpty()
|
||||
|
||||
val hasServerData =
|
||||
serverBackup.gyms.isNotEmpty() ||
|
||||
serverBackup.problems.isNotEmpty() ||
|
||||
serverBackup.sessions.isNotEmpty() ||
|
||||
serverBackup.attempts.isNotEmpty()
|
||||
|
||||
val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
||||
if (hasLocalData && hasServerData && lastSyncTimeStr != null) {
|
||||
AppLogger.d(TAG) { "Using delta sync for incremental updates" }
|
||||
performDeltaSync(lastSyncTimeStr)
|
||||
} else {
|
||||
when {
|
||||
!hasLocalData && hasServerData -> {
|
||||
AppLogger.d(TAG) { "No local data found, performing full restore from server" }
|
||||
val imagePathMapping = syncImagesFromServer(serverBackup)
|
||||
importBackupToRepository(serverBackup, imagePathMapping)
|
||||
AppLogger.d(TAG) { "Full restore completed" }
|
||||
}
|
||||
|
||||
hasLocalData && !hasServerData -> {
|
||||
AppLogger.d(TAG) { "No server data found, uploading local data to server" }
|
||||
uploadData(localBackup)
|
||||
syncImagesForBackup(localBackup)
|
||||
AppLogger.d(TAG) { "Initial upload completed" }
|
||||
}
|
||||
|
||||
hasLocalData && hasServerData -> {
|
||||
AppLogger.d(TAG) { "Both local and server data exist, merging (server wins)" }
|
||||
mergeDataSafely(serverBackup)
|
||||
AppLogger.d(TAG) { "Merge completed" }
|
||||
}
|
||||
|
||||
else -> {
|
||||
AppLogger.d(TAG) { "No data to sync" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val now = DateFormatUtils.nowISO8601()
|
||||
sharedPreferences.edit { putString(Keys.LAST_SYNC_TIME, now) }
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
serverUrl = ""
|
||||
authToken = ""
|
||||
_isConnected.value = false
|
||||
sharedPreferences.edit {
|
||||
remove(Keys.LAST_SYNC_TIME)
|
||||
putBoolean(Keys.IS_CONNECTED, false)
|
||||
}
|
||||
updateConfiguredState()
|
||||
}
|
||||
|
||||
private suspend fun performDeltaSync(lastSyncTimeStr: String) {
|
||||
AppLogger.d(TAG) { "Starting delta sync with lastSyncTime=$lastSyncTimeStr" }
|
||||
|
||||
val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0)
|
||||
|
||||
val allGyms = repository.getAllGyms().first()
|
||||
val modifiedGyms =
|
||||
allGyms
|
||||
.filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true }
|
||||
.map { BackupGym.fromGym(it) }
|
||||
|
||||
val allProblems = repository.getAllProblems().first()
|
||||
val modifiedProblems =
|
||||
allProblems
|
||||
.filter { problem ->
|
||||
parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true
|
||||
}
|
||||
.map { problem ->
|
||||
val backupProblem = BackupProblem.fromProblem(problem)
|
||||
val normalizedImagePaths =
|
||||
problem.imagePaths.mapIndexed { index, _ ->
|
||||
ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||
}
|
||||
if (normalizedImagePaths.isNotEmpty()) {
|
||||
backupProblem.copy(imagePaths = normalizedImagePaths)
|
||||
} else {
|
||||
backupProblem
|
||||
}
|
||||
}
|
||||
|
||||
val allSessions = repository.getAllSessions().first()
|
||||
val modifiedSessions =
|
||||
allSessions
|
||||
.filter { session ->
|
||||
parseISO8601(session.updatedAt)?.after(lastSyncDate) == true
|
||||
}
|
||||
.map { BackupClimbSession.fromClimbSession(it) }
|
||||
|
||||
val allAttempts = repository.getAllAttempts().first()
|
||||
val modifiedAttempts =
|
||||
allAttempts
|
||||
.filter { attempt ->
|
||||
parseISO8601(attempt.createdAt)?.after(lastSyncDate) == true
|
||||
}
|
||||
.map { BackupAttempt.fromAttempt(it) }
|
||||
|
||||
val allDeletions = repository.getDeletedItems()
|
||||
val modifiedDeletions =
|
||||
allDeletions.filter { item ->
|
||||
parseISO8601(item.deletedAt)?.after(lastSyncDate) == true
|
||||
}
|
||||
|
||||
AppLogger.d(TAG) {
|
||||
"Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}"
|
||||
}
|
||||
|
||||
val deltaRequest =
|
||||
DeltaSyncRequest(
|
||||
lastSyncTime = lastSyncTimeStr,
|
||||
gyms = modifiedGyms,
|
||||
problems = modifiedProblems,
|
||||
sessions = modifiedSessions,
|
||||
attempts = modifiedAttempts,
|
||||
deletedItems = modifiedDeletions
|
||||
)
|
||||
|
||||
val requestBody =
|
||||
json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest)
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/sync/delta")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val deltaResponse =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body?.string()
|
||||
if (!body.isNullOrEmpty()) {
|
||||
json.decodeFromString(DeltaSyncResponse.serializer(), body)
|
||||
} else {
|
||||
throw SyncException.InvalidResponse("Empty response body")
|
||||
}
|
||||
} else {
|
||||
handleHttpError(response.code)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw SyncException.NetworkError(e.message ?: "Network error")
|
||||
}
|
||||
}
|
||||
|
||||
AppLogger.d(TAG) {
|
||||
"Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}"
|
||||
}
|
||||
|
||||
applyDeltaResponse(deltaResponse)
|
||||
syncModifiedImages(modifiedProblems)
|
||||
}
|
||||
|
||||
private fun parseISO8601(dateString: String): Date? {
|
||||
return try {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
||||
format.parse(dateString)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyDeltaResponse(response: DeltaSyncResponse) {
|
||||
// SyncService handles the "isSyncing" state to prevent recursive sync triggers
|
||||
// when the repository is modified during a sync operation.
|
||||
|
||||
try {
|
||||
// Merge and apply deletions first to prevent resurrection
|
||||
val allDeletions = repository.getDeletedItems() + response.deletedItems
|
||||
val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" }
|
||||
|
||||
AppLogger.d(TAG) { "Applying ${uniqueDeletions.size} deletion records before merging data" }
|
||||
applyDeletions(uniqueDeletions)
|
||||
|
||||
// Build deleted item lookup set
|
||||
val deletedItemSet = uniqueDeletions.map { "${it.type}:${it.id}" }.toSet()
|
||||
|
||||
// Download images for new/modified problems from server
|
||||
val imagePathMapping = mutableMapOf<String, String>()
|
||||
for (problem in response.problems) {
|
||||
if (deletedItemSet.contains("problem:${problem.id}")) {
|
||||
continue
|
||||
}
|
||||
problem.imagePaths?.forEach { imagePath ->
|
||||
val serverFilename = imagePath.substringAfterLast('/')
|
||||
try {
|
||||
val localImagePath = downloadImage(serverFilename)
|
||||
if (localImagePath != null) {
|
||||
imagePathMapping[imagePath] = localImagePath
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge gyms
|
||||
val existingGyms = repository.getAllGyms().first()
|
||||
for (backupGym in response.gyms) {
|
||||
if (deletedItemSet.contains("gym:${backupGym.id}")) {
|
||||
continue
|
||||
}
|
||||
val existing = existingGyms.find { it.id == backupGym.id }
|
||||
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
|
||||
val gym = backupGym.toGym()
|
||||
if (existing != null) {
|
||||
repository.updateGym(gym)
|
||||
} else {
|
||||
repository.insertGym(gym)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge problems
|
||||
val existingProblems = repository.getAllProblems().first()
|
||||
for (backupProblem in response.problems) {
|
||||
if (deletedItemSet.contains("problem:${backupProblem.id}")) {
|
||||
continue
|
||||
}
|
||||
val updatedImagePaths =
|
||||
backupProblem.imagePaths?.map { oldPath ->
|
||||
imagePathMapping[oldPath] ?: oldPath
|
||||
}
|
||||
val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths)
|
||||
val problem = problemToMerge.toProblem()
|
||||
|
||||
val existing = existingProblems.find { it.id == backupProblem.id }
|
||||
if (existing == null || backupProblem.updatedAt >= existing.updatedAt) {
|
||||
if (existing != null) {
|
||||
repository.updateProblem(problem)
|
||||
} else {
|
||||
repository.insertProblem(problem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge sessions
|
||||
val existingSessions = repository.getAllSessions().first()
|
||||
for (backupSession in response.sessions) {
|
||||
if (deletedItemSet.contains("session:${backupSession.id}")) {
|
||||
continue
|
||||
}
|
||||
val session = backupSession.toClimbSession()
|
||||
val existing = existingSessions.find { it.id == backupSession.id }
|
||||
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
|
||||
if (existing != null) {
|
||||
repository.updateSession(session)
|
||||
} else {
|
||||
repository.insertSession(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge attempts
|
||||
val existingAttempts = repository.getAllAttempts().first()
|
||||
for (backupAttempt in response.attempts) {
|
||||
if (deletedItemSet.contains("attempt:${backupAttempt.id}")) {
|
||||
continue
|
||||
}
|
||||
val attempt = backupAttempt.toAttempt()
|
||||
val existing = existingAttempts.find { it.id == backupAttempt.id }
|
||||
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
|
||||
if (existing != null) {
|
||||
repository.updateAttempt(attempt)
|
||||
} else {
|
||||
repository.insertAttempt(attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply deletions again for safety
|
||||
applyDeletions(uniqueDeletions)
|
||||
|
||||
// Update deletion records
|
||||
repository.clearDeletedItems()
|
||||
uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyDeletions(
|
||||
deletions: List<com.atridad.ascently.data.format.DeletedItem>
|
||||
) {
|
||||
val existingGyms = repository.getAllGyms().first()
|
||||
val existingProblems = repository.getAllProblems().first()
|
||||
val existingSessions = repository.getAllSessions().first()
|
||||
val existingAttempts = repository.getAllAttempts().first()
|
||||
|
||||
for (item in deletions) {
|
||||
when (item.type) {
|
||||
"gym" -> {
|
||||
existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) }
|
||||
}
|
||||
|
||||
"problem" -> {
|
||||
existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) }
|
||||
}
|
||||
|
||||
"session" -> {
|
||||
existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) }
|
||||
}
|
||||
|
||||
"attempt" -> {
|
||||
existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncModifiedImages(modifiedProblems: List<BackupProblem>) {
|
||||
if (modifiedProblems.isEmpty()) return
|
||||
|
||||
AppLogger.d(TAG) { "Syncing images for ${modifiedProblems.size} modified problems" }
|
||||
|
||||
for (backupProblem in modifiedProblems) {
|
||||
backupProblem.imagePaths?.forEach { imagePath ->
|
||||
val filename = imagePath.substringAfterLast('/')
|
||||
uploadImage(imagePath, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadData(): ClimbDataBackup {
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/sync")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body?.string()
|
||||
if (!body.isNullOrEmpty()) {
|
||||
json.decodeFromString(body)
|
||||
} else {
|
||||
ClimbDataBackup(
|
||||
exportedAt = DateFormatUtils.nowISO8601(),
|
||||
gyms = emptyList(),
|
||||
problems = emptyList(),
|
||||
sessions = emptyList(),
|
||||
attempts = emptyList()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
handleHttpError(response.code)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw SyncException.NetworkError(e.message ?: "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadData(backup: ClimbDataBackup) {
|
||||
val requestBody =
|
||||
json.encodeToString(backup).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/sync")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.put(requestBody)
|
||||
.build()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
handleHttpError(response.code)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw SyncException.NetworkError(e.message ?: "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> {
|
||||
val imagePathMapping = mutableMapOf<String, String>()
|
||||
val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 }
|
||||
AppLogger.d(TAG) { "Starting image download from server for $totalImages images" }
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
backup.problems.forEach { problem ->
|
||||
problem.imagePaths?.forEach { imagePath ->
|
||||
val serverFilename = imagePath.substringAfterLast('/')
|
||||
try {
|
||||
val localImagePath = downloadImage(serverFilename)
|
||||
if (localImagePath != null) {
|
||||
imagePathMapping[imagePath] = localImagePath
|
||||
}
|
||||
} catch (_: SyncException.ImageNotFound) {
|
||||
AppLogger.w(TAG) { "Image not found on server: $imagePath" }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return imagePathMapping
|
||||
}
|
||||
|
||||
private suspend fun downloadImage(serverFilename: String): String? {
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/images/download?filename=$serverFilename")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.build()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
response.body?.bytes()?.let {
|
||||
ImageUtils.saveImageFromBytesWithFilename(context, it, serverFilename)
|
||||
}
|
||||
} else {
|
||||
if (response.code == 404) throw SyncException.ImageNotFound
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
AppLogger.e(TAG, e) { "Network error downloading image $serverFilename" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
|
||||
AppLogger.d(TAG) { "Starting image sync for backup with ${backup.problems.size} problems" }
|
||||
withContext(Dispatchers.IO) {
|
||||
backup.problems.forEach { problem ->
|
||||
problem.imagePaths?.forEach { localPath ->
|
||||
val filename = localPath.substringAfterLast('/')
|
||||
uploadImage(localPath, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadImage(localPath: String, filename: String) {
|
||||
val file = ImageUtils.getImageFile(context, localPath)
|
||||
if (!file.exists()) {
|
||||
AppLogger.w(TAG) { "Local image file not found, cannot upload: $localPath" }
|
||||
return
|
||||
}
|
||||
|
||||
val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType())
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/images/upload?filename=$filename")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
AppLogger.d(TAG) { "Successfully uploaded image: $filename" }
|
||||
} else {
|
||||
AppLogger.w(TAG) {
|
||||
"Failed to upload image $filename. Server responded with ${response.code}"
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
AppLogger.e(TAG, e) { "Network error uploading image $filename" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createBackupFromRepository(): ClimbDataBackup {
|
||||
return withContext(Dispatchers.Default) {
|
||||
ClimbDataBackup(
|
||||
exportedAt = dataStateManager.getLastModified(),
|
||||
gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) },
|
||||
problems =
|
||||
repository.getAllProblems().first().map { problem ->
|
||||
val backupProblem = BackupProblem.fromProblem(problem)
|
||||
val normalizedImagePaths =
|
||||
problem.imagePaths.mapIndexed { index, _ ->
|
||||
ImageNamingUtils.generateImageFilename(
|
||||
problem.id,
|
||||
index
|
||||
)
|
||||
}
|
||||
if (normalizedImagePaths.isNotEmpty()) {
|
||||
backupProblem.copy(imagePaths = normalizedImagePaths)
|
||||
} else {
|
||||
backupProblem
|
||||
}
|
||||
},
|
||||
sessions =
|
||||
repository.getAllSessions().first().map {
|
||||
BackupClimbSession.fromClimbSession(it)
|
||||
},
|
||||
attempts =
|
||||
repository.getAllAttempts().first().map {
|
||||
BackupAttempt.fromAttempt(it)
|
||||
},
|
||||
deletedItems = repository.getDeletedItems()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun importBackupToRepository(
|
||||
backup: ClimbDataBackup,
|
||||
imagePathMapping: Map<String, String>
|
||||
) {
|
||||
val gyms = backup.gyms.map { it.toGym() }
|
||||
val problems =
|
||||
backup.problems.map { backupProblem ->
|
||||
val imagePaths = backupProblem.imagePaths
|
||||
val updatedImagePaths =
|
||||
imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath }
|
||||
backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
|
||||
}
|
||||
val sessions = backup.sessions.map { it.toClimbSession() }
|
||||
val attempts = backup.attempts.map { it.toAttempt() }
|
||||
|
||||
repository.resetAllData()
|
||||
|
||||
gyms.forEach { repository.insertGymWithoutSync(it) }
|
||||
problems.forEach { repository.insertProblemWithoutSync(it) }
|
||||
sessions.forEach { repository.insertSessionWithoutSync(it) }
|
||||
attempts.forEach { repository.insertAttemptWithoutSync(it) }
|
||||
|
||||
repository.clearDeletedItems()
|
||||
}
|
||||
|
||||
private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) {
|
||||
AppLogger.d(TAG) { "Server data will overwrite local data. Performing full restore." }
|
||||
val imagePathMapping = syncImagesFromServer(serverBackup)
|
||||
importBackupToRepository(serverBackup, imagePathMapping)
|
||||
}
|
||||
|
||||
private fun handleHttpError(code: Int): Nothing {
|
||||
when (code) {
|
||||
401 -> throw SyncException.Unauthorized
|
||||
in 500..599 -> throw SyncException.ServerError(code)
|
||||
else -> throw SyncException.InvalidResponse("HTTP error code: $code")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun testConnection() {
|
||||
if (!_isConfigured.value) {
|
||||
_isConnected.value = false
|
||||
throw SyncException.NotConfigured
|
||||
}
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/sync")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.head()
|
||||
.build()
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
_isConnected.value = response.isSuccessful || response.code == 405
|
||||
}
|
||||
}
|
||||
if (!_isConnected.value) {
|
||||
throw SyncException.NotConnected
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_isConnected.value = false
|
||||
throw SyncException.NetworkError(e.message ?: "Connection error")
|
||||
} finally {
|
||||
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.atridad.ascently.data.sync
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.Serializable
|
||||
|
||||
sealed class SyncException(message: String) : IOException(message), Serializable {
|
||||
object NotConfigured :
|
||||
SyncException("Sync is not configured. Please set server URL and auth token.")
|
||||
|
||||
object NotConnected : SyncException("Not connected to server. Please test connection first.")
|
||||
|
||||
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
|
||||
|
||||
object ImageNotFound : SyncException("Image not found on server")
|
||||
|
||||
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
|
||||
data class InvalidResponse(val details: String) :
|
||||
SyncException("Invalid server response: $details")
|
||||
|
||||
data class NetworkError(val details: String) : SyncException("Network error: $details")
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.atridad.ascently.data.sync
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface SyncProvider {
|
||||
val type: SyncProviderType
|
||||
val isConfigured: StateFlow<Boolean>
|
||||
val isConnected: StateFlow<Boolean>
|
||||
|
||||
suspend fun sync()
|
||||
suspend fun testConnection()
|
||||
fun disconnect()
|
||||
}
|
||||
|
||||
enum class SyncProviderType {
|
||||
NONE,
|
||||
SERVER
|
||||
}
|
||||
@@ -2,27 +2,9 @@ package com.atridad.ascently.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.core.content.edit
|
||||
import com.atridad.ascently.data.format.BackupAttempt
|
||||
import com.atridad.ascently.utils.AppLogger
|
||||
import com.atridad.ascently.data.format.BackupClimbSession
|
||||
import com.atridad.ascently.data.format.BackupGym
|
||||
import com.atridad.ascently.data.format.BackupProblem
|
||||
import com.atridad.ascently.data.format.ClimbDataBackup
|
||||
import com.atridad.ascently.data.repository.ClimbRepository
|
||||
import com.atridad.ascently.data.state.DataStateManager
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import com.atridad.ascently.utils.ImageNamingUtils
|
||||
import com.atridad.ascently.utils.ImageUtils
|
||||
import java.io.IOException
|
||||
import java.io.Serializable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import com.atridad.ascently.utils.AppLogger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -31,43 +13,21 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
class SyncService(private val context: Context, private val repository: ClimbRepository) {
|
||||
|
||||
private val dataStateManager = DataStateManager(context)
|
||||
private val syncMutex = Mutex()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val syncMutex = Mutex()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SyncService"
|
||||
}
|
||||
|
||||
private val sharedPreferences: SharedPreferences =
|
||||
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
|
||||
|
||||
private val httpClient =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(45, TimeUnit.SECONDS)
|
||||
.readTimeout(90, TimeUnit.SECONDS)
|
||||
.writeTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
// Currently we only support one provider, but this allows for future expansion
|
||||
private val provider: SyncProvider = AscentlySyncProvider(context, repository)
|
||||
|
||||
// State
|
||||
private val _isSyncing = MutableStateFlow(false)
|
||||
@@ -79,11 +39,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
private val _syncError = MutableStateFlow<String?>(null)
|
||||
val syncError: StateFlow<String?> = _syncError.asStateFlow()
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
private val _isConfigured = MutableStateFlow(false)
|
||||
val isConfiguredFlow: StateFlow<Boolean> = _isConfigured.asStateFlow()
|
||||
// Delegate to provider
|
||||
val isConnected: StateFlow<Boolean> = provider.isConnected
|
||||
val isConfiguredFlow: StateFlow<Boolean> = provider.isConfigured
|
||||
|
||||
private val _isTesting = MutableStateFlow(false)
|
||||
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
|
||||
@@ -91,56 +49,40 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
private val _isAutoSyncEnabled = MutableStateFlow(true)
|
||||
val isAutoSyncEnabled: StateFlow<Boolean> = _isAutoSyncEnabled.asStateFlow()
|
||||
|
||||
private var isOfflineMode = false
|
||||
|
||||
// Debounced sync properties
|
||||
private var syncJob: Job? = null
|
||||
private var pendingChanges = false
|
||||
private val syncDebounceDelay = 2000L // 2 seconds
|
||||
|
||||
// Configuration keys
|
||||
private val sharedPreferences: SharedPreferences =
|
||||
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
|
||||
|
||||
private object Keys {
|
||||
const val SERVER_URL = "server_url"
|
||||
const val AUTH_TOKEN = "auth_token"
|
||||
const val IS_CONNECTED = "is_connected"
|
||||
const val LAST_SYNC_TIME = "last_sync_time"
|
||||
const val AUTO_SYNC_ENABLED = "auto_sync_enabled"
|
||||
const val OFFLINE_MODE = "offline_mode"
|
||||
}
|
||||
|
||||
init {
|
||||
loadInitialState()
|
||||
updateConfiguredState()
|
||||
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
|
||||
}
|
||||
|
||||
private fun loadInitialState() {
|
||||
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
||||
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
|
||||
_isAutoSyncEnabled.value = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
|
||||
isOfflineMode = sharedPreferences.getBoolean(Keys.OFFLINE_MODE, false)
|
||||
}
|
||||
|
||||
private fun updateConfiguredState() {
|
||||
_isConfigured.value = serverUrl.isNotBlank() && authToken.isNotBlank()
|
||||
}
|
||||
|
||||
// Proxy properties for Ascently provider configuration
|
||||
var serverUrl: String
|
||||
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
|
||||
get() = (provider as? AscentlySyncProvider)?.serverUrl ?: ""
|
||||
set(value) {
|
||||
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
|
||||
updateConfiguredState()
|
||||
_isConnected.value = false
|
||||
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
|
||||
(provider as? AscentlySyncProvider)?.serverUrl = value
|
||||
}
|
||||
|
||||
var authToken: String
|
||||
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
|
||||
get() = (provider as? AscentlySyncProvider)?.authToken ?: ""
|
||||
set(value) {
|
||||
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
|
||||
updateConfiguredState()
|
||||
_isConnected.value = false
|
||||
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
|
||||
(provider as? AscentlySyncProvider)?.authToken = value
|
||||
}
|
||||
|
||||
fun setAutoSyncEnabled(enabled: Boolean) {
|
||||
@@ -148,93 +90,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
sharedPreferences.edit { putBoolean(Keys.AUTO_SYNC_ENABLED, enabled) }
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
|
||||
private fun isNetworkAvailable(): Boolean {
|
||||
val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return when {
|
||||
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
|
||||
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
|
||||
suspend fun syncWithServer() {
|
||||
if (isOfflineMode) {
|
||||
AppLogger.d(TAG) { "Sync skipped: Offline mode is enabled." }
|
||||
return
|
||||
}
|
||||
if (!isNetworkAvailable()) {
|
||||
_syncError.value = "No internet connection."
|
||||
AppLogger.d(TAG) { "Sync skipped: No internet connection." }
|
||||
return
|
||||
}
|
||||
if (!_isConfigured.value) {
|
||||
if (!isConfiguredFlow.value) {
|
||||
throw SyncException.NotConfigured
|
||||
}
|
||||
if (!_isConnected.value) {
|
||||
throw SyncException.NotConnected
|
||||
}
|
||||
|
||||
syncMutex.withLock {
|
||||
_isSyncing.value = true
|
||||
_syncError.value = null
|
||||
|
||||
try {
|
||||
val localBackup = createBackupFromRepository()
|
||||
val serverBackup = downloadData()
|
||||
provider.sync()
|
||||
|
||||
val hasLocalData =
|
||||
localBackup.gyms.isNotEmpty() ||
|
||||
localBackup.problems.isNotEmpty() ||
|
||||
localBackup.sessions.isNotEmpty() ||
|
||||
localBackup.attempts.isNotEmpty()
|
||||
// Update last sync time from shared prefs (provider updates it)
|
||||
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
||||
|
||||
val hasServerData =
|
||||
serverBackup.gyms.isNotEmpty() ||
|
||||
serverBackup.problems.isNotEmpty() ||
|
||||
serverBackup.sessions.isNotEmpty() ||
|
||||
serverBackup.attempts.isNotEmpty()
|
||||
|
||||
// If both client and server have been synced before, use delta sync
|
||||
val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
||||
if (hasLocalData && hasServerData && lastSyncTimeStr != null) {
|
||||
AppLogger.d(TAG) { "Using delta sync for incremental updates" }
|
||||
performDeltaSync(lastSyncTimeStr)
|
||||
} else {
|
||||
when {
|
||||
!hasLocalData && hasServerData -> {
|
||||
AppLogger.d(TAG) { "No local data found, performing full restore from server" }
|
||||
val imagePathMapping = syncImagesFromServer(serverBackup)
|
||||
importBackupToRepository(serverBackup, imagePathMapping)
|
||||
AppLogger.d(TAG) { "Full restore completed" }
|
||||
}
|
||||
|
||||
hasLocalData && !hasServerData -> {
|
||||
AppLogger.d(TAG) { "No server data found, uploading local data to server" }
|
||||
uploadData(localBackup)
|
||||
syncImagesForBackup(localBackup)
|
||||
AppLogger.d(TAG) { "Initial upload completed" }
|
||||
}
|
||||
|
||||
hasLocalData && hasServerData -> {
|
||||
AppLogger.d(TAG) { "Both local and server data exist, merging (server wins)" }
|
||||
mergeDataSafely(serverBackup)
|
||||
AppLogger.d(TAG) { "Merge completed" }
|
||||
}
|
||||
|
||||
else -> {
|
||||
AppLogger.d(TAG) { "No data to sync" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val now = DateFormatUtils.nowISO8601()
|
||||
_lastSyncTime.value = now
|
||||
sharedPreferences.edit { putString(Keys.LAST_SYNC_TIME, now) }
|
||||
} catch (e: Exception) {
|
||||
_syncError.value = e.message
|
||||
throw e
|
||||
@@ -244,550 +114,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performDeltaSync(lastSyncTimeStr: String) {
|
||||
AppLogger.d(TAG) { "Starting delta sync with lastSyncTime=$lastSyncTimeStr" }
|
||||
|
||||
// Parse last sync time to filter modified items
|
||||
val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0)
|
||||
|
||||
// Collect items modified since last sync
|
||||
val allGyms = repository.getAllGyms().first()
|
||||
val modifiedGyms =
|
||||
allGyms
|
||||
.filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true }
|
||||
.map { BackupGym.fromGym(it) }
|
||||
|
||||
val allProblems = repository.getAllProblems().first()
|
||||
val modifiedProblems =
|
||||
allProblems
|
||||
.filter { problem ->
|
||||
parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true
|
||||
}
|
||||
.map { problem ->
|
||||
val backupProblem = BackupProblem.fromProblem(problem)
|
||||
val normalizedImagePaths =
|
||||
problem.imagePaths.mapIndexed { index, _ ->
|
||||
ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||
}
|
||||
if (normalizedImagePaths.isNotEmpty()) {
|
||||
backupProblem.copy(imagePaths = normalizedImagePaths)
|
||||
} else {
|
||||
backupProblem
|
||||
}
|
||||
}
|
||||
|
||||
val allSessions = repository.getAllSessions().first()
|
||||
val modifiedSessions =
|
||||
allSessions
|
||||
.filter { session ->
|
||||
parseISO8601(session.updatedAt)?.after(lastSyncDate) == true
|
||||
}
|
||||
.map { BackupClimbSession.fromClimbSession(it) }
|
||||
|
||||
val allAttempts = repository.getAllAttempts().first()
|
||||
val modifiedAttempts =
|
||||
allAttempts
|
||||
.filter { attempt ->
|
||||
parseISO8601(attempt.createdAt)?.after(lastSyncDate) == true
|
||||
}
|
||||
.map { BackupAttempt.fromAttempt(it) }
|
||||
|
||||
val allDeletions = repository.getDeletedItems()
|
||||
val modifiedDeletions =
|
||||
allDeletions.filter { item ->
|
||||
parseISO8601(item.deletedAt)?.after(lastSyncDate) == true
|
||||
}
|
||||
|
||||
AppLogger.d(TAG) {
|
||||
"Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}"
|
||||
}
|
||||
|
||||
// Create delta request
|
||||
val deltaRequest =
|
||||
DeltaSyncRequest(
|
||||
lastSyncTime = lastSyncTimeStr,
|
||||
gyms = modifiedGyms,
|
||||
problems = modifiedProblems,
|
||||
sessions = modifiedSessions,
|
||||
attempts = modifiedAttempts,
|
||||
deletedItems = modifiedDeletions
|
||||
)
|
||||
|
||||
val requestBody =
|
||||
json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest)
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/sync/delta")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val deltaResponse =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body?.string()
|
||||
if (!body.isNullOrEmpty()) {
|
||||
json.decodeFromString(DeltaSyncResponse.serializer(), body)
|
||||
} else {
|
||||
throw SyncException.InvalidResponse("Empty response body")
|
||||
}
|
||||
} else {
|
||||
handleHttpError(response.code)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw SyncException.NetworkError(e.message ?: "Network error")
|
||||
}
|
||||
}
|
||||
|
||||
AppLogger.d(TAG) {
|
||||
"Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}"
|
||||
}
|
||||
|
||||
// Apply server changes to local data
|
||||
applyDeltaResponse(deltaResponse)
|
||||
|
||||
// Sync only modified problem images
|
||||
syncModifiedImages(modifiedProblems)
|
||||
}
|
||||
|
||||
private fun parseISO8601(dateString: String): Date? {
|
||||
return try {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
||||
format.parse(dateString)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyDeltaResponse(response: DeltaSyncResponse) {
|
||||
// Temporarily disable auto-sync to prevent recursive sync triggers
|
||||
repository.setAutoSyncCallback(null)
|
||||
|
||||
try {
|
||||
// Merge and apply deletions first to prevent resurrection
|
||||
val allDeletions = repository.getDeletedItems() + response.deletedItems
|
||||
val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" }
|
||||
|
||||
AppLogger.d(TAG) { "Applying ${uniqueDeletions.size} deletion records before merging data" }
|
||||
applyDeletions(uniqueDeletions)
|
||||
|
||||
// Build deleted item lookup set
|
||||
val deletedItemSet = uniqueDeletions.map { "${it.type}:${it.id}" }.toSet()
|
||||
|
||||
// Download images for new/modified problems from server
|
||||
val imagePathMapping = mutableMapOf<String, String>()
|
||||
for (problem in response.problems) {
|
||||
if (deletedItemSet.contains("problem:${problem.id}")) {
|
||||
continue
|
||||
}
|
||||
problem.imagePaths?.forEach { imagePath ->
|
||||
val serverFilename = imagePath.substringAfterLast('/')
|
||||
try {
|
||||
val localImagePath = downloadImage(serverFilename)
|
||||
if (localImagePath != null) {
|
||||
imagePathMapping[imagePath] = localImagePath
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge gyms
|
||||
val existingGyms = repository.getAllGyms().first()
|
||||
for (backupGym in response.gyms) {
|
||||
if (deletedItemSet.contains("gym:${backupGym.id}")) {
|
||||
continue
|
||||
}
|
||||
val existing = existingGyms.find { it.id == backupGym.id }
|
||||
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
|
||||
val gym = backupGym.toGym()
|
||||
if (existing != null) {
|
||||
repository.updateGym(gym)
|
||||
} else {
|
||||
repository.insertGym(gym)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge problems
|
||||
val existingProblems = repository.getAllProblems().first()
|
||||
for (backupProblem in response.problems) {
|
||||
if (deletedItemSet.contains("problem:${backupProblem.id}")) {
|
||||
continue
|
||||
}
|
||||
val updatedImagePaths =
|
||||
backupProblem.imagePaths?.map { oldPath ->
|
||||
imagePathMapping[oldPath] ?: oldPath
|
||||
}
|
||||
val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths)
|
||||
val problem = problemToMerge.toProblem()
|
||||
|
||||
val existing = existingProblems.find { it.id == backupProblem.id }
|
||||
if (existing == null || backupProblem.updatedAt >= existing.updatedAt) {
|
||||
if (existing != null) {
|
||||
repository.updateProblem(problem)
|
||||
} else {
|
||||
repository.insertProblem(problem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge sessions
|
||||
val existingSessions = repository.getAllSessions().first()
|
||||
for (backupSession in response.sessions) {
|
||||
if (deletedItemSet.contains("session:${backupSession.id}")) {
|
||||
continue
|
||||
}
|
||||
val session = backupSession.toClimbSession()
|
||||
val existing = existingSessions.find { it.id == backupSession.id }
|
||||
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
|
||||
if (existing != null) {
|
||||
repository.updateSession(session)
|
||||
} else {
|
||||
repository.insertSession(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge attempts
|
||||
val existingAttempts = repository.getAllAttempts().first()
|
||||
for (backupAttempt in response.attempts) {
|
||||
if (deletedItemSet.contains("attempt:${backupAttempt.id}")) {
|
||||
continue
|
||||
}
|
||||
val attempt = backupAttempt.toAttempt()
|
||||
val existing = existingAttempts.find { it.id == backupAttempt.id }
|
||||
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
|
||||
if (existing != null) {
|
||||
repository.updateAttempt(attempt)
|
||||
} else {
|
||||
repository.insertAttempt(attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply deletions again for safety
|
||||
applyDeletions(uniqueDeletions)
|
||||
|
||||
// Update deletion records
|
||||
repository.clearDeletedItems()
|
||||
uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) }
|
||||
} finally {
|
||||
// Re-enable auto-sync
|
||||
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyDeletions(
|
||||
deletions: List<com.atridad.ascently.data.format.DeletedItem>
|
||||
) {
|
||||
val existingGyms = repository.getAllGyms().first()
|
||||
val existingProblems = repository.getAllProblems().first()
|
||||
val existingSessions = repository.getAllSessions().first()
|
||||
val existingAttempts = repository.getAllAttempts().first()
|
||||
|
||||
for (item in deletions) {
|
||||
when (item.type) {
|
||||
"gym" -> {
|
||||
existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) }
|
||||
}
|
||||
|
||||
"problem" -> {
|
||||
existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) }
|
||||
}
|
||||
|
||||
"session" -> {
|
||||
existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) }
|
||||
}
|
||||
|
||||
"attempt" -> {
|
||||
existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncModifiedImages(modifiedProblems: List<BackupProblem>) {
|
||||
if (modifiedProblems.isEmpty()) return
|
||||
|
||||
AppLogger.d(TAG) { "Syncing images for ${modifiedProblems.size} modified problems" }
|
||||
|
||||
for (backupProblem in modifiedProblems) {
|
||||
backupProblem.imagePaths?.forEach { imagePath ->
|
||||
val filename = imagePath.substringAfterLast('/')
|
||||
uploadImage(imagePath, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadData(): ClimbDataBackup {
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/sync")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body?.string()
|
||||
if (!body.isNullOrEmpty()) {
|
||||
json.decodeFromString(body)
|
||||
} else {
|
||||
ClimbDataBackup(
|
||||
exportedAt = DateFormatUtils.nowISO8601(),
|
||||
gyms = emptyList(),
|
||||
problems = emptyList(),
|
||||
sessions = emptyList(),
|
||||
attempts = emptyList()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
handleHttpError(response.code)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw SyncException.NetworkError(e.message ?: "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadData(backup: ClimbDataBackup) {
|
||||
val requestBody =
|
||||
json.encodeToString(backup).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/sync")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.put(requestBody)
|
||||
.build()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
handleHttpError(response.code)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw SyncException.NetworkError(e.message ?: "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> {
|
||||
val imagePathMapping = mutableMapOf<String, String>()
|
||||
val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 }
|
||||
AppLogger.d(TAG) { "Starting image download from server for $totalImages images" }
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
backup.problems.forEach { problem ->
|
||||
problem.imagePaths?.forEach { imagePath ->
|
||||
val serverFilename = imagePath.substringAfterLast('/')
|
||||
try {
|
||||
val localImagePath = downloadImage(serverFilename)
|
||||
if (localImagePath != null) {
|
||||
imagePathMapping[imagePath] = localImagePath
|
||||
}
|
||||
} catch (_: SyncException.ImageNotFound) {
|
||||
AppLogger.w(TAG) { "Image not found on server: $imagePath" }
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return imagePathMapping
|
||||
}
|
||||
|
||||
private suspend fun downloadImage(serverFilename: String): String? {
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/images/download?filename=$serverFilename")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.build()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
response.body?.bytes()?.let {
|
||||
ImageUtils.saveImageFromBytesWithFilename(context, it, serverFilename)
|
||||
}
|
||||
} else {
|
||||
if (response.code == 404) throw SyncException.ImageNotFound
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
AppLogger.e(TAG, e) { "Network error downloading image $serverFilename" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
|
||||
AppLogger.d(TAG) { "Starting image sync for backup with ${backup.problems.size} problems" }
|
||||
withContext(Dispatchers.IO) {
|
||||
backup.problems.forEach { problem ->
|
||||
problem.imagePaths?.forEach { localPath ->
|
||||
val filename = localPath.substringAfterLast('/')
|
||||
uploadImage(localPath, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadImage(localPath: String, filename: String) {
|
||||
val file = ImageUtils.getImageFile(context, localPath)
|
||||
if (!file.exists()) {
|
||||
AppLogger.w(TAG) { "Local image file not found, cannot upload: $localPath" }
|
||||
return
|
||||
}
|
||||
|
||||
val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType())
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/images/upload?filename=$filename")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
AppLogger.d(TAG) { "Successfully uploaded image: $filename" }
|
||||
} else {
|
||||
AppLogger.w(TAG) {
|
||||
"Failed to upload image $filename. Server responded with ${response.code}"
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
AppLogger.e(TAG, e) { "Network error uploading image $filename" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createBackupFromRepository(): ClimbDataBackup {
|
||||
return withContext(Dispatchers.Default) {
|
||||
ClimbDataBackup(
|
||||
exportedAt = dataStateManager.getLastModified(),
|
||||
gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) },
|
||||
problems =
|
||||
repository.getAllProblems().first().map { problem ->
|
||||
val backupProblem = BackupProblem.fromProblem(problem)
|
||||
val normalizedImagePaths =
|
||||
problem.imagePaths.mapIndexed { index, _ ->
|
||||
ImageNamingUtils.generateImageFilename(
|
||||
problem.id,
|
||||
index
|
||||
)
|
||||
}
|
||||
if (normalizedImagePaths.isNotEmpty()) {
|
||||
backupProblem.copy(imagePaths = normalizedImagePaths)
|
||||
} else {
|
||||
backupProblem
|
||||
}
|
||||
},
|
||||
sessions =
|
||||
repository.getAllSessions().first().map {
|
||||
BackupClimbSession.fromClimbSession(it)
|
||||
},
|
||||
attempts =
|
||||
repository.getAllAttempts().first().map {
|
||||
BackupAttempt.fromAttempt(it)
|
||||
},
|
||||
deletedItems = repository.getDeletedItems()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun importBackupToRepository(
|
||||
backup: ClimbDataBackup,
|
||||
imagePathMapping: Map<String, String>
|
||||
) {
|
||||
val gyms = backup.gyms.map { it.toGym() }
|
||||
val problems =
|
||||
backup.problems.map { backupProblem ->
|
||||
val imagePaths = backupProblem.imagePaths
|
||||
val updatedImagePaths =
|
||||
imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath }
|
||||
backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
|
||||
}
|
||||
val sessions = backup.sessions.map { it.toClimbSession() }
|
||||
val attempts = backup.attempts.map { it.toAttempt() }
|
||||
|
||||
repository.resetAllData()
|
||||
|
||||
gyms.forEach { repository.insertGymWithoutSync(it) }
|
||||
problems.forEach { repository.insertProblemWithoutSync(it) }
|
||||
sessions.forEach { repository.insertSessionWithoutSync(it) }
|
||||
attempts.forEach { repository.insertAttemptWithoutSync(it) }
|
||||
|
||||
repository.clearDeletedItems()
|
||||
}
|
||||
|
||||
private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) {
|
||||
AppLogger.d(TAG) { "Server data will overwrite local data. Performing full restore." }
|
||||
val imagePathMapping = syncImagesFromServer(serverBackup)
|
||||
importBackupToRepository(serverBackup, imagePathMapping)
|
||||
}
|
||||
|
||||
private fun handleHttpError(code: Int): Nothing {
|
||||
when (code) {
|
||||
401 -> throw SyncException.Unauthorized
|
||||
in 500..599 -> throw SyncException.ServerError(code)
|
||||
else -> throw SyncException.InvalidResponse("HTTP error code: $code")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun testConnection() {
|
||||
if (!_isConfigured.value) {
|
||||
_isConnected.value = false
|
||||
_syncError.value = "Server URL or Auth Token is not set."
|
||||
return
|
||||
}
|
||||
_isTesting.value = true
|
||||
_syncError.value = null
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url("$serverUrl/sync")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.head()
|
||||
.build()
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
_isConnected.value = response.isSuccessful || response.code == 405
|
||||
}
|
||||
}
|
||||
if (!_isConnected.value) {
|
||||
_syncError.value = "Connection failed. Check URL and token."
|
||||
}
|
||||
provider.testConnection()
|
||||
} catch (e: Exception) {
|
||||
_isConnected.value = false
|
||||
_syncError.value = "Connection error: ${e.message}"
|
||||
throw e
|
||||
} finally {
|
||||
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) }
|
||||
_isTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerAutoSync() {
|
||||
if (!_isConfigured.value || !_isConnected.value || !_isAutoSyncEnabled.value) {
|
||||
if (!isConfiguredFlow.value || !isConnected.value || !_isAutoSyncEnabled.value) {
|
||||
return
|
||||
}
|
||||
if (_isSyncing.value) {
|
||||
@@ -812,30 +153,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
|
||||
fun clearConfiguration() {
|
||||
syncJob?.cancel()
|
||||
serverUrl = ""
|
||||
authToken = ""
|
||||
provider.disconnect()
|
||||
setAutoSyncEnabled(true)
|
||||
_lastSyncTime.value = null
|
||||
_isConnected.value = false
|
||||
_syncError.value = null
|
||||
sharedPreferences.edit { clear() }
|
||||
updateConfiguredState()
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SyncException(message: String) : IOException(message), Serializable {
|
||||
object NotConfigured :
|
||||
SyncException("Sync is not configured. Please set server URL and auth token.")
|
||||
|
||||
object NotConnected : SyncException("Not connected to server. Please test connection first.")
|
||||
|
||||
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
|
||||
|
||||
object ImageNotFound : SyncException("Image not found on server")
|
||||
|
||||
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
|
||||
data class InvalidResponse(val details: String) :
|
||||
SyncException("Invalid server response: $details")
|
||||
|
||||
data class NetworkError(val details: String) : SyncException("Network error: $details")
|
||||
}
|
||||
|
||||
@@ -216,9 +216,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
// Manual Sync Button
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
viewModel.performManualSync()
|
||||
}
|
||||
},
|
||||
enabled = isConnected && !isSyncing
|
||||
) {
|
||||
|
||||
@@ -411,13 +411,15 @@ class ClimbViewModel(
|
||||
}
|
||||
|
||||
// Sync-related methods
|
||||
suspend fun performManualSync() {
|
||||
fun performManualSync() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
syncService.syncWithServer()
|
||||
} catch (e: Exception) {
|
||||
setError("Sync failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun testSyncConnection() {
|
||||
try {
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user