Files
Ascently/ios/OpenClimb/Services/SyncService.swift

825 lines
30 KiB
Swift

import Combine
import Foundation
@MainActor
class SyncService: ObservableObject {
@Published var isSyncing = false
@Published var lastSyncTime: Date?
@Published var syncError: String?
@Published var isConnected = false
@Published var isTesting = false
private let userDefaults = UserDefaults.standard
private enum Keys {
static let serverURL = "sync_server_url"
static let authToken = "sync_auth_token"
static let lastSyncTime = "last_sync_time"
static let isConnected = "sync_is_connected"
static let autoSyncEnabled = "auto_sync_enabled"
}
var serverURL: String {
get { userDefaults.string(forKey: Keys.serverURL) ?? "" }
set { userDefaults.set(newValue, forKey: Keys.serverURL) }
}
var authToken: String {
get { userDefaults.string(forKey: Keys.authToken) ?? "" }
set { userDefaults.set(newValue, forKey: Keys.authToken) }
}
var isConfigured: Bool {
return !serverURL.isEmpty && !authToken.isEmpty
}
var isAutoSyncEnabled: Bool {
get { userDefaults.bool(forKey: Keys.autoSyncEnabled) }
set { userDefaults.set(newValue, forKey: Keys.autoSyncEnabled) }
}
init() {
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
self.lastSyncTime = lastSync
}
self.isConnected = userDefaults.bool(forKey: Keys.isConnected)
}
func downloadData() async throws -> ClimbDataBackup {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/sync") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
break
case 401:
throw SyncError.unauthorized
default:
throw SyncError.serverError(httpResponse.statusCode)
}
do {
let backup = try JSONDecoder().decode(ClimbDataBackup.self, from: data)
return backup
} catch {
throw SyncError.decodingError(error)
}
}
func uploadData(_ backup: ClimbDataBackup) async throws -> ClimbDataBackup {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/sync") else {
throw SyncError.invalidURL
}
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let jsonData = try encoder.encode(backup)
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = jsonData
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
break
case 401:
throw SyncError.unauthorized
case 400:
throw SyncError.badRequest
default:
throw SyncError.serverError(httpResponse.statusCode)
}
do {
let responseBackup = try JSONDecoder().decode(ClimbDataBackup.self, from: data)
return responseBackup
} catch {
throw SyncError.decodingError(error)
}
}
func uploadImage(filename: String, imageData: Data) async throws {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/images/upload?filename=\(filename)") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.httpBody = imageData
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
break
case 401:
throw SyncError.unauthorized
default:
throw SyncError.serverError(httpResponse.statusCode)
}
}
func downloadImage(filename: String) async throws -> Data {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/images/download?filename=\(filename)") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
return data
case 401:
throw SyncError.unauthorized
case 404:
throw SyncError.imageNotFound
default:
throw SyncError.serverError(httpResponse.statusCode)
}
}
func syncWithServer(dataManager: ClimbingDataManager) async throws {
guard isConfigured else {
throw SyncError.notConfigured
}
guard isConnected else {
throw SyncError.notConnected
}
isSyncing = true
syncError = nil
defer {
isSyncing = false
}
do {
// Get local backup data
let localBackup = createBackupFromDataManager(dataManager)
// Download server data
let serverBackup = try await downloadData()
// Check if we have any local data
let hasLocalData =
!dataManager.gyms.isEmpty || !dataManager.problems.isEmpty
|| !dataManager.sessions.isEmpty || !dataManager.attempts.isEmpty
let hasServerData =
!serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty
|| !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty
if !hasLocalData && hasServerData {
// Case 1: No local data - do full restore from server
print("iOS SYNC: Case 1 - No local data, performing full restore from server")
print("Syncing images from server first...")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
print("Importing data after images...")
try importBackupToDataManager(
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
print("Full restore completed")
} else if hasLocalData && !hasServerData {
// Case 2: No server data - upload local data to server
print("iOS SYNC: Case 2 - No server data, uploading local data to server")
let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup)
print("Uploading local images to server...")
try await syncImagesToServer(dataManager: dataManager)
print("Initial upload completed")
} else if hasLocalData && hasServerData {
// Case 3: Both have data - compare timestamps (last writer wins)
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt)
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt)
print("DEBUG iOS Timestamp Comparison:")
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)")
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)")
print(
" DataStateManager last modified: '\(DataStateManager.shared.getLastModified())'"
)
print(" Comparison result: local=\(localTimestamp), server=\(serverTimestamp)")
if localTimestamp > serverTimestamp {
// Local is newer - replace server with local data
print("iOS SYNC: Case 3a - Local data is newer, replacing server content")
let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup)
try await syncImagesToServer(dataManager: dataManager)
print("Server replaced with local data")
} else if serverTimestamp > localTimestamp {
// Server is newer - replace local with server data
print("iOS SYNC: Case 3b - Server data is newer, replacing local content")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
try importBackupToDataManager(
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
print("Local data replaced with server data")
} else {
// Timestamps are equal - no sync needed
print(
"iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
)
}
} else {
print("No data to sync")
}
// Update last sync time
lastSyncTime = Date()
userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime)
} catch {
syncError = error.localizedDescription
throw error
}
}
/// Parses ISO8601 timestamp to milliseconds for comparison
private func parseISO8601ToMillis(timestamp: String) -> Int64 {
let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: timestamp) {
return Int64(date.timeIntervalSince1970 * 1000)
}
print("Failed to parse timestamp: \(timestamp), using 0")
return 0
}
private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager)
async throws -> [String: String]
{
var imagePathMapping: [String: String] = [:]
// Process images by problem to maintain consistent naming
for problem in backup.problems {
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
for (index, imagePath) in imagePaths.enumerated() {
let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent
do {
let imageData = try await downloadImage(filename: serverFilename)
// Generate consistent filename if needed
let consistentFilename =
ImageNamingUtils.isValidImageFilename(serverFilename)
? serverFilename
: ImageNamingUtils.generateImageFilename(
problemId: problem.id, imageIndex: index)
// Save image with consistent filename
let imageManager = ImageManager.shared
_ = try imageManager.saveImportedImage(
imageData, filename: consistentFilename)
// Map server filename to consistent local filename
imagePathMapping[serverFilename] = consistentFilename
print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)")
} catch SyncError.imageNotFound {
print("Image not found on server: \(serverFilename)")
continue
} catch {
print("Failed to download image \(serverFilename): \(error)")
continue
}
}
}
return imagePathMapping
}
private func syncImagesToServer(dataManager: ClimbingDataManager) async throws {
// Process images by problem to ensure consistent naming
for problem in dataManager.problems {
guard !problem.imagePaths.isEmpty else { continue }
for (index, imagePath) in problem.imagePaths.enumerated() {
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
// Ensure filename follows consistent naming convention
let consistentFilename =
ImageNamingUtils.isValidImageFilename(filename)
? filename
: ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
// Load image data
let imageManager = ImageManager.shared
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
do {
// If filename changed, rename local file
if filename != consistentFilename {
let newPath = imageManager.imagesDirectory.appendingPathComponent(
consistentFilename
).path
do {
try FileManager.default.moveItem(atPath: fullPath, toPath: newPath)
print("Renamed local image: \(filename) -> \(consistentFilename)")
// Update problem's image path in memory for consistency
// Note: This would require updating the problem in the data manager
} catch {
print("Failed to rename local image, using original: \(error)")
}
}
try await uploadImage(filename: consistentFilename, imageData: imageData)
print("Successfully uploaded image: \(consistentFilename)")
} catch {
print("Failed to upload image \(consistentFilename): \(error)")
// Continue with other images even if one fails
}
}
}
}
}
private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup
{
return ClimbDataBackup(
exportedAt: DataStateManager.shared.getLastModified(),
gyms: dataManager.gyms.map { BackupGym(from: $0) },
problems: dataManager.problems.map { BackupProblem(from: $0) },
sessions: dataManager.sessions.map { BackupClimbSession(from: $0) },
attempts: dataManager.attempts.map { BackupAttempt(from: $0) }
)
}
private func importBackupToDataManager(
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
imagePathMapping: [String: String] = [:]
) throws {
do {
// Update problem image paths to point to downloaded images
let updatedBackup: ClimbDataBackup
if !imagePathMapping.isEmpty {
let updatedProblems = backup.problems.map { problem in
let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
}
return BackupProblem(
id: problem.id,
gymId: problem.gymId,
name: problem.name,
description: problem.description,
climbType: problem.climbType,
difficulty: problem.difficulty,
tags: problem.tags,
location: problem.location,
imagePaths: updatedImagePaths,
isActive: problem.isActive,
dateSet: problem.dateSet,
notes: problem.notes,
createdAt: problem.createdAt,
updatedAt: problem.updatedAt
)
}
updatedBackup = ClimbDataBackup(
exportedAt: backup.exportedAt,
version: backup.version,
formatVersion: backup.formatVersion,
gyms: backup.gyms,
problems: updatedProblems,
sessions: backup.sessions,
attempts: backup.attempts
)
} else {
updatedBackup = backup
}
// Create a minimal ZIP with just the JSON data for existing import mechanism
let zipData = try createMinimalZipFromBackup(updatedBackup)
// Use existing import method which properly handles data restoration
try dataManager.importData(from: zipData, showSuccessMessage: false)
// Update local data state to match imported data timestamp
DataStateManager.shared.setLastModified(backup.exportedAt)
print("Data state synchronized to imported timestamp: \(backup.exportedAt)")
} catch {
throw SyncError.importFailed(error)
}
}
private func createMinimalZipFromBackup(_ backup: ClimbDataBackup) throws -> Data {
// Create JSON data
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
encoder.dateEncodingStrategy = .custom { date, encoder in
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
var container = encoder.singleValueContainer()
try container.encode(formatter.string(from: date))
}
let jsonData = try encoder.encode(backup)
// Collect all downloaded images from ImageManager
let imageManager = ImageManager.shared
var imageFiles: [(filename: String, data: Data)] = []
let imagePaths = Set(backup.problems.flatMap { $0.imagePaths ?? [] })
for imagePath in imagePaths {
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
imageFiles.append((filename: filename, data: imageData))
}
}
// Create ZIP with data.json, metadata, and images
var zipData = Data()
var fileEntries: [(name: String, data: Data, offset: UInt32)] = []
var currentOffset: UInt32 = 0
// Add data.json to ZIP
try addFileToMinimalZip(
filename: "data.json",
fileData: jsonData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
// Add metadata with correct image count
let metadata = "export_version=2.0\nformat_version=2.0\nimage_count=\(imageFiles.count)"
let metadataData = metadata.data(using: .utf8) ?? Data()
try addFileToMinimalZip(
filename: "metadata.txt",
fileData: metadataData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
// Add images to ZIP in images/ directory
for imageFile in imageFiles {
try addFileToMinimalZip(
filename: "images/\(imageFile.filename)",
fileData: imageFile.data,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
}
// Add central directory
var centralDirectory = Data()
for entry in fileEntries {
centralDirectory.append(createCentralDirectoryHeader(entry: entry))
}
// Add end of central directory record
let endOfCentralDir = createEndOfCentralDirectoryRecord(
fileCount: UInt16(fileEntries.count),
centralDirSize: UInt32(centralDirectory.count),
centralDirOffset: currentOffset
)
zipData.append(centralDirectory)
zipData.append(endOfCentralDir)
return zipData
}
private func addFileToMinimalZip(
filename: String,
fileData: Data,
zipData: inout Data,
fileEntries: inout [(name: String, data: Data, offset: UInt32)],
currentOffset: inout UInt32
) throws {
let localFileHeader = createLocalFileHeader(
filename: filename, fileSize: UInt32(fileData.count))
fileEntries.append((name: filename, data: fileData, offset: currentOffset))
zipData.append(localFileHeader)
zipData.append(fileData)
currentOffset += UInt32(localFileHeader.count + fileData.count)
}
private func createLocalFileHeader(filename: String, fileSize: UInt32) -> Data {
var header = Data()
// Local file header signature
header.append(Data([0x50, 0x4b, 0x03, 0x04]))
// Version needed to extract (2.0)
header.append(Data([0x14, 0x00]))
// General purpose bit flag
header.append(Data([0x00, 0x00]))
// Compression method (no compression)
header.append(Data([0x00, 0x00]))
// Last mod file time & date (dummy values)
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// CRC-32 (dummy - we're not compressing)
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// Compressed size
withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) }
// Uncompressed size
withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) }
// File name length
let filenameData = filename.data(using: .utf8) ?? Data()
let filenameLength = UInt16(filenameData.count)
withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) }
// Extra field length
header.append(Data([0x00, 0x00]))
// File name
header.append(filenameData)
return header
}
private func createCentralDirectoryHeader(entry: (name: String, data: Data, offset: UInt32))
-> Data
{
var header = Data()
// Central directory signature
header.append(Data([0x50, 0x4b, 0x01, 0x02]))
// Version made by
header.append(Data([0x14, 0x00]))
// Version needed to extract
header.append(Data([0x14, 0x00]))
// General purpose bit flag
header.append(Data([0x00, 0x00]))
// Compression method
header.append(Data([0x00, 0x00]))
// Last mod file time & date
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// CRC-32
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// Compressed size
let compressedSize = UInt32(entry.data.count)
withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) }
// Uncompressed size
withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) }
// File name length
let filenameData = entry.name.data(using: .utf8) ?? Data()
let filenameLength = UInt16(filenameData.count)
withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) }
// Extra field length
header.append(Data([0x00, 0x00]))
// File comment length
header.append(Data([0x00, 0x00]))
// Disk number start
header.append(Data([0x00, 0x00]))
// Internal file attributes
header.append(Data([0x00, 0x00]))
// External file attributes
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// Relative offset of local header
withUnsafeBytes(of: entry.offset.littleEndian) { header.append(Data($0)) }
// File name
header.append(filenameData)
return header
}
private func createEndOfCentralDirectoryRecord(
fileCount: UInt16, centralDirSize: UInt32, centralDirOffset: UInt32
) -> Data {
var record = Data()
// End of central dir signature
record.append(Data([0x50, 0x4b, 0x05, 0x06]))
// Number of this disk
record.append(Data([0x00, 0x00]))
// Number of the disk with the start of the central directory
record.append(Data([0x00, 0x00]))
// Total number of entries in the central directory on this disk
withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) }
// Total number of entries in the central directory
withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) }
// Size of the central directory
withUnsafeBytes(of: centralDirSize.littleEndian) { record.append(Data($0)) }
// Offset of start of central directory
withUnsafeBytes(of: centralDirOffset.littleEndian) { record.append(Data($0)) }
// ZIP file comment length
record.append(Data([0x00, 0x00]))
return record
}
func testConnection() async throws {
guard isConfigured else {
throw SyncError.notConfigured
}
isTesting = true
defer { isTesting = false }
guard let url = URL(string: "\(serverURL)/health") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 10
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw SyncError.serverError(httpResponse.statusCode)
}
// Connection successful, mark as connected
isConnected = true
userDefaults.set(true, forKey: Keys.isConnected)
}
func triggerAutoSync(dataManager: ClimbingDataManager) {
// Early exit if sync cannot proceed - don't set isSyncing
guard isConnected && isConfigured && isAutoSyncEnabled else {
// Ensure isSyncing is false when sync is not possible
if isSyncing {
isSyncing = false
}
return
}
// Prevent multiple simultaneous syncs
guard !isSyncing else {
return
}
Task {
do {
try await syncWithServer(dataManager: dataManager)
} catch {
await MainActor.run {
self.isSyncing = false
}
}
}
}
func disconnect() {
isConnected = false
lastSyncTime = nil
syncError = nil
userDefaults.set(false, forKey: Keys.isConnected)
userDefaults.removeObject(forKey: Keys.lastSyncTime)
}
func clearConfiguration() {
serverURL = ""
authToken = ""
lastSyncTime = nil
isConnected = false
isAutoSyncEnabled = true
userDefaults.removeObject(forKey: Keys.lastSyncTime)
userDefaults.removeObject(forKey: Keys.isConnected)
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
}
}
enum SyncError: LocalizedError {
case notConfigured
case notConnected
case invalidURL
case invalidResponse
case unauthorized
case badRequest
case serverError(Int)
case decodingError(Error)
case exportFailed
case importFailed(Error)
case imageNotFound
case imageUploadFailed
var errorDescription: String? {
switch self {
case .notConfigured:
return "Sync server not configured. Please set server URL and auth token."
case .notConnected:
return "Not connected to sync server. Please test connection first."
case .invalidURL:
return "Invalid server URL."
case .invalidResponse:
return "Invalid response from server."
case .unauthorized:
return "Authentication failed. Check your auth token."
case .badRequest:
return "Bad request. Check your data format."
case .serverError(let code):
return "Server error (code \(code))."
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
case .exportFailed:
return "Failed to export local data."
case .importFailed(let error):
return "Failed to import data: \(error.localizedDescription)"
case .imageNotFound:
return "Image not found on server."
case .imageUploadFailed:
return "Failed to upload image to server."
}
}
}