From b365b967b26db34243648f7244c8d47154313485 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Wed, 3 Dec 2025 15:41:45 -0700 Subject: [PATCH] Android 2.4.0 - Backend changes :) --- android/app/build.gradle.kts | 4 +- .../data/sync/AscentlySyncProvider.kt | 738 ++++++++++++++++++ .../ascently/data/sync/SyncException.kt | 21 + .../ascently/data/sync/SyncProvider.kt | 18 + .../atridad/ascently/data/sync/SyncService.kt | 730 +---------------- .../ascently/ui/screens/SettingsScreen.kt | 4 +- .../ascently/ui/viewmodel/ClimbViewModel.kt | 12 +- .../main/res/xml/data_extraction_rules.xml | 4 - .../UserInterfaceState.xcuserstate | Bin 281889 -> 282229 bytes 9 files changed, 812 insertions(+), 719 deletions(-) create mode 100644 android/app/src/main/java/com/atridad/ascently/data/sync/AscentlySyncProvider.kt create mode 100644 android/app/src/main/java/com/atridad/ascently/data/sync/SyncException.kt create mode 100644 android/app/src/main/java/com/atridad/ascently/data/sync/SyncProvider.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 103c27c..862303b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" } diff --git a/android/app/src/main/java/com/atridad/ascently/data/sync/AscentlySyncProvider.kt b/android/app/src/main/java/com/atridad/ascently/data/sync/AscentlySyncProvider.kt new file mode 100644 index 0000000..882685b --- /dev/null +++ b/android/app/src/main/java/com/atridad/ascently/data/sync/AscentlySyncProvider.kt @@ -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 = _isConnected.asStateFlow() + + private val _isConfigured = MutableStateFlow(false) + override val isConfigured: StateFlow = _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() + 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 + ) { + 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) { + 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 { + val imagePathMapping = mutableMapOf() + 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 + ) { + 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) } + } + } +} diff --git a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncException.kt b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncException.kt new file mode 100644 index 0000000..040e470 --- /dev/null +++ b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncException.kt @@ -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") +} diff --git a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncProvider.kt b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncProvider.kt new file mode 100644 index 0000000..ab3bcf1 --- /dev/null +++ b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncProvider.kt @@ -0,0 +1,18 @@ +package com.atridad.ascently.data.sync + +import kotlinx.coroutines.flow.StateFlow + +interface SyncProvider { + val type: SyncProviderType + val isConfigured: StateFlow + val isConnected: StateFlow + + suspend fun sync() + suspend fun testConnection() + fun disconnect() +} + +enum class SyncProviderType { + NONE, + SERVER +} diff --git a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt index c4be4f0..baa2d91 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt @@ -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(null) val syncError: StateFlow = _syncError.asStateFlow() - private val _isConnected = MutableStateFlow(false) - val isConnected: StateFlow = _isConnected.asStateFlow() - - private val _isConfigured = MutableStateFlow(false) - val isConfiguredFlow: StateFlow = _isConfigured.asStateFlow() + // Delegate to provider + val isConnected: StateFlow = provider.isConnected + val isConfiguredFlow: StateFlow = provider.isConfigured private val _isTesting = MutableStateFlow(false) val isTesting: StateFlow = _isTesting.asStateFlow() @@ -91,56 +49,40 @@ class SyncService(private val context: Context, private val repository: ClimbRep private val _isAutoSyncEnabled = MutableStateFlow(true) val isAutoSyncEnabled: StateFlow = _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() - - 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() - - // 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) } + provider.sync() + + // Update last sync time from shared prefs (provider updates it) + _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) + } 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() - 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 - ) { - 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) { - 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 { - val imagePathMapping = mutableMapOf() - 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 - ) { - 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") -} diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt index 348d013..d3874ba 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt @@ -216,9 +216,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) { // Manual Sync Button TextButton( onClick = { - coroutineScope.launch { - viewModel.performManualSync() - } + viewModel.performManualSync() }, enabled = isConnected && !isSyncing ) { diff --git a/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt b/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt index c88b01f..06ee5a8 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt @@ -411,11 +411,13 @@ class ClimbViewModel( } // Sync-related methods - suspend fun performManualSync() { - try { - syncService.syncWithServer() - } catch (e: Exception) { - setError("Sync failed: ${e.message}") + fun performManualSync() { + viewModelScope.launch { + try { + syncService.syncWithServer() + } catch (e: Exception) { + setError("Sync failed: ${e.message}") + } } } diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997..0a6d946 100644 --- a/android/app/src/main/res/xml/data_extraction_rules.xml +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -5,10 +5,6 @@ --> -