Merge pull request '2.3.0 - Unified logging and app intents' (#6) from logging into main
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 6m59s
Ascently - Sync Deploy / build-and-push (push) Successful in 2m0s

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2025-11-21 04:01:43 +00:00
38 changed files with 2352 additions and 1852 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.ascently" applicationId = "com.atridad.ascently"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 46 versionCode = 4
versionName = "2.2.1" versionName = "2.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -38,7 +38,10 @@ android {
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
buildFeatures { compose = true } buildFeatures {
compose = true
buildConfig = true
}
} }
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }

View File

@@ -3,7 +3,7 @@ package com.atridad.ascently.data.health
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import com.atridad.ascently.utils.AppLogger
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController import androidx.health.connect.client.PermissionController
@@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.flow
class HealthConnectManager(private val context: Context) { class HealthConnectManager(private val context: Context) {
private val preferences: SharedPreferences = private val preferences: SharedPreferences =
context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE) context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE)
private val _isEnabled = MutableStateFlow(preferences.getBoolean("enabled", false)) private val _isEnabled = MutableStateFlow(preferences.getBoolean("enabled", false))
private val _hasPermissions = MutableStateFlow(preferences.getBoolean("permissions", false)) private val _hasPermissions = MutableStateFlow(preferences.getBoolean("permissions", false))
@@ -46,21 +46,21 @@ class HealthConnectManager(private val context: Context) {
private const val TAG = "HealthConnectManager" private const val TAG = "HealthConnectManager"
val REQUIRED_PERMISSIONS = val REQUIRED_PERMISSIONS =
setOf( setOf(
HealthPermission.getReadPermission(ExerciseSessionRecord::class), HealthPermission.getReadPermission(ExerciseSessionRecord::class),
HealthPermission.getWritePermission(ExerciseSessionRecord::class), HealthPermission.getWritePermission(ExerciseSessionRecord::class),
HealthPermission.getReadPermission(HeartRateRecord::class), HealthPermission.getReadPermission(HeartRateRecord::class),
HealthPermission.getWritePermission(HeartRateRecord::class), HealthPermission.getWritePermission(HeartRateRecord::class),
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class) HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class)
) )
} }
private val healthConnectClient by lazy { private val healthConnectClient by lazy {
try { try {
HealthConnectClient.getOrCreate(context) HealthConnectClient.getOrCreate(context)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create Health Connect client", e) AppLogger.e(TAG, e) { "Failed to create Health Connect client" }
_isCompatible.value = false _isCompatible.value = false
null null
} }
@@ -75,7 +75,7 @@ class HealthConnectManager(private val context: Context) {
val status = HealthConnectClient.getSdkStatus(context) val status = HealthConnectClient.getSdkStatus(context)
emit(status == HealthConnectClient.SDK_AVAILABLE) emit(status == HealthConnectClient.SDK_AVAILABLE)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect availability", e) AppLogger.e(TAG, e) { "Error checking Health Connect availability" }
_isCompatible.value = false _isCompatible.value = false
emit(false) emit(false)
} }
@@ -90,10 +90,10 @@ class HealthConnectManager(private val context: Context) {
try { try {
val alreadyHasPermissions = hasAllPermissions() val alreadyHasPermissions = hasAllPermissions()
if (!alreadyHasPermissions) { if (!alreadyHasPermissions) {
Log.d(TAG, "Health Connect enabled - permissions will be requested by UI") AppLogger.d(TAG) { "Health Connect enabled - permissions will be requested by UI" }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error checking permissions when enabling Health Connect", e) AppLogger.w(TAG, e) { "Error checking permissions when enabling Health Connect" }
} }
} else if (!enabled) { } else if (!enabled) {
setPermissionsGranted(false) setPermissionsGranted(false)
@@ -111,15 +111,15 @@ class HealthConnectManager(private val context: Context) {
return false return false
} }
val grantedPermissions = val grantedPermissions =
healthConnectClient!!.permissionController.getGrantedPermissions() healthConnectClient!!.permissionController.getGrantedPermissions()
val hasAll = val hasAll =
REQUIRED_PERMISSIONS.all { permission -> REQUIRED_PERMISSIONS.all { permission ->
grantedPermissions.contains(permission) grantedPermissions.contains(permission)
} }
setPermissionsGranted(hasAll) setPermissionsGranted(hasAll)
hasAll hasAll
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking permissions", e) AppLogger.e(TAG, e) { "Error checking permissions" }
setPermissionsGranted(false) setPermissionsGranted(false)
false false
} }
@@ -128,14 +128,14 @@ class HealthConnectManager(private val context: Context) {
suspend fun isReady(): Boolean { suspend fun isReady(): Boolean {
return try { return try {
if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null) if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null)
return false return false
val isAvailable = val isAvailable =
HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
val hasPerms = if (isAvailable) hasAllPermissions() else false val hasPerms = if (isAvailable) hasAllPermissions() else false
isAvailable && hasPerms isAvailable && hasPerms
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect readiness", e) AppLogger.e(TAG, e) { "Error checking Health Connect readiness" }
false false
} }
} }
@@ -148,27 +148,27 @@ class HealthConnectManager(private val context: Context) {
return try { return try {
REQUIRED_PERMISSIONS.map { it }.toSet() REQUIRED_PERMISSIONS.map { it }.toSet()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error getting required permissions", e) AppLogger.e(TAG, e) { "Error getting required permissions" }
emptySet() emptySet()
} }
} }
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
suspend fun syncCompletedSession( suspend fun syncCompletedSession(
session: ClimbSession, session: ClimbSession,
gymName: String, gymName: String,
attemptCount: Int = 0 attemptCount: Int = 0
): Result<Unit> { ): Result<Unit> {
return try { return try {
if (!isReady() || !_autoSync.value) { if (!isReady() || !_autoSync.value) {
return Result.failure( return Result.failure(
IllegalStateException("Health Connect not ready or auto-sync disabled") IllegalStateException("Health Connect not ready or auto-sync disabled")
) )
} }
if (session.status != SessionStatus.COMPLETED) { if (session.status != SessionStatus.COMPLETED) {
return Result.failure( return Result.failure(
IllegalArgumentException("Only completed sessions can be synced") IllegalArgumentException("Only completed sessions can be synced")
) )
} }
@@ -177,29 +177,29 @@ class HealthConnectManager(private val context: Context) {
if (startTime == null || endTime == null) { if (startTime == null || endTime == null) {
return Result.failure( return Result.failure(
IllegalArgumentException("Session must have valid start and end times") IllegalArgumentException("Session must have valid start and end times")
) )
} }
Log.d(TAG, "Attempting to sync session '${session.id}' to Health Connect...") AppLogger.d(TAG) { "Attempting to sync session '${session.id}' to Health Connect..." }
val records = mutableListOf<androidx.health.connect.client.records.Record>() val records = mutableListOf<androidx.health.connect.client.records.Record>()
try { try {
val exerciseSession = val exerciseSession =
ExerciseSessionRecord( ExerciseSessionRecord(
startTime = startTime, startTime = startTime,
startZoneOffset = startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime), ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime, endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
exerciseType = exerciseType =
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
title = "Rock Climbing at $gymName" title = "Rock Climbing at $gymName"
) )
records.add(exerciseSession) records.add(exerciseSession)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to create exercise session record", e) AppLogger.w(TAG, e) { "Failed to create exercise session record" }
} }
try { try {
@@ -208,75 +208,74 @@ class HealthConnectManager(private val context: Context) {
if (estimatedCalories > 0) { if (estimatedCalories > 0) {
val caloriesRecord = val caloriesRecord =
TotalCaloriesBurnedRecord( TotalCaloriesBurnedRecord(
startTime = startTime, startTime = startTime,
startZoneOffset = startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime), ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime, endTime = endTime,
endZoneOffset = endZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(endTime), ZoneOffset.systemDefault().rules.getOffset(endTime),
energy = Energy.calories(estimatedCalories) energy = Energy.calories(estimatedCalories)
) )
records.add(caloriesRecord) records.add(caloriesRecord)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to create calories record", e) AppLogger.w(TAG, e) { "Failed to create calories record" }
} }
try { try {
val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount) val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount)
heartRateRecord?.let { records.add(it) } heartRateRecord?.let { records.add(it) }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to create heart rate record", e) AppLogger.w(TAG, e) { "Failed to create heart rate record" }
} }
if (records.isNotEmpty() && healthConnectClient != null) { if (records.isNotEmpty() && healthConnectClient != null) {
Log.d(TAG, "Writing ${records.size} records to Health Connect...") AppLogger.d(TAG) { "Writing ${records.size} records to Health Connect..." }
healthConnectClient!!.insertRecords(records) healthConnectClient!!.insertRecords(records)
Log.i( AppLogger.i(TAG) {
TAG, "Successfully synced ${records.size} records for session '${session.id}' to Health Connect"
"Successfully synced ${records.size} records for session '${session.id}' to Health Connect" }
)
preferences preferences
.edit() .edit()
.putString("last_sync_success", DateFormatUtils.nowISO8601()) .putString("last_sync_success", DateFormatUtils.nowISO8601())
.apply() .apply()
} else { } else {
val reason = val reason =
when { when {
records.isEmpty() -> "No records created" records.isEmpty() -> "No records created"
healthConnectClient == null -> "Health Connect client unavailable" healthConnectClient == null -> "Health Connect client unavailable"
else -> "Unknown reason" else -> "Unknown reason"
} }
Log.w(TAG, "Sync failed for session '${session.id}': $reason") AppLogger.w(TAG) { "Sync failed for session '${session.id}': $reason" }
return Result.failure(Exception("Sync failed: $reason")) return Result.failure(Exception("Sync failed: $reason"))
} }
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error syncing climbing session to Health Connect", e) AppLogger.e(TAG, e) { "Error syncing climbing session to Health Connect" }
Result.failure(e) Result.failure(e)
} }
} }
suspend fun autoSyncCompletedSession( suspend fun autoSyncCompletedSession(
session: ClimbSession, session: ClimbSession,
gymName: String, gymName: String,
attemptCount: Int = 0 attemptCount: Int = 0
): Result<Unit> { ): Result<Unit> {
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) { return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
Log.d(TAG, "Auto-syncing completed session '${session.id}' to Health Connect...") AppLogger.d(TAG) { "Auto-syncing completed session '${session.id}' to Health Connect..." }
syncCompletedSession(session, gymName, attemptCount) syncCompletedSession(session, gymName, attemptCount)
} else { } else {
val reason = val reason =
when { when {
session.status != SessionStatus.COMPLETED -> "session not completed" session.status != SessionStatus.COMPLETED -> "session not completed"
!_autoSync.value -> "auto-sync disabled" !_autoSync.value -> "auto-sync disabled"
!isReady() -> "Health Connect not ready" !isReady() -> "Health Connect not ready"
else -> "unknown reason" else -> "unknown reason"
} }
Log.d(TAG, "Auto-sync skipped for session '${session.id}': $reason") AppLogger.d(TAG) { "Auto-sync skipped for session '${session.id}': $reason" }
Result.success(Unit) Result.success(Unit)
} }
} }
@@ -284,30 +283,30 @@ class HealthConnectManager(private val context: Context) {
private fun estimateCaloriesForClimbing(durationMinutes: Long, attemptCount: Int): Double { private fun estimateCaloriesForClimbing(durationMinutes: Long, attemptCount: Int): Double {
val baseCaloriesPerMinute = 8.0 val baseCaloriesPerMinute = 8.0
val intensityMultiplier = val intensityMultiplier =
when { when {
attemptCount >= 20 -> 1.3 attemptCount >= 20 -> 1.3
attemptCount >= 10 -> 1.1 attemptCount >= 10 -> 1.1
else -> 0.9 else -> 0.9
} }
return durationMinutes * baseCaloriesPerMinute * intensityMultiplier return durationMinutes * baseCaloriesPerMinute * intensityMultiplier
} }
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
private fun createHeartRateRecord( private fun createHeartRateRecord(
startTime: Instant, startTime: Instant,
endTime: Instant, endTime: Instant,
attemptCount: Int attemptCount: Int
): HeartRateRecord? { ): HeartRateRecord? {
return try { return try {
val samples = mutableListOf<HeartRateRecord.Sample>() val samples = mutableListOf<HeartRateRecord.Sample>()
val intervalMinutes = 5L val intervalMinutes = 5L
val baseHeartRate = val baseHeartRate =
when { when {
attemptCount >= 20 -> 155L attemptCount >= 20 -> 155L
attemptCount >= 10 -> 145L attemptCount >= 10 -> 145L
else -> 135L else -> 135L
} }
var currentTime = startTime var currentTime = startTime
while (currentTime.isBefore(endTime)) { while (currentTime.isBefore(endTime)) {
@@ -321,14 +320,14 @@ class HealthConnectManager(private val context: Context) {
if (samples.isEmpty()) return null if (samples.isEmpty()) return null
HeartRateRecord( HeartRateRecord(
startTime = startTime, startTime = startTime,
startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime), startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime, endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
samples = samples samples = samples
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error creating heart rate record", e) AppLogger.e(TAG, e) { "Error creating heart rate record" }
null null
} }
} }

View File

@@ -13,6 +13,7 @@ import com.atridad.ascently.data.format.DeletedItem
import com.atridad.ascently.data.model.* import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.state.DataStateManager import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ZipExportImportUtils import com.atridad.ascently.utils.ZipExportImportUtils
import java.io.File import java.io.File
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -26,7 +27,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
private val attemptDao = database.attemptDao() private val attemptDao = database.attemptDao()
private val dataStateManager = DataStateManager(context) private val dataStateManager = DataStateManager(context)
private val deletionPreferences: SharedPreferences = private val deletionPreferences: SharedPreferences =
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE) context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
private var autoSyncCallback: (() -> Unit)? = null private var autoSyncCallback: (() -> Unit)? = null
@@ -43,11 +44,13 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync() triggerAutoSync()
} }
suspend fun updateGym(gym: Gym) { suspend fun updateGym(gym: Gym) {
gymDao.updateGym(gym) gymDao.updateGym(gym)
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync() triggerAutoSync()
} }
suspend fun deleteGym(gym: Gym) { suspend fun deleteGym(gym: Gym) {
gymDao.deleteGym(gym) gymDao.deleteGym(gym)
trackDeletion(gym.id, "gym") trackDeletion(gym.id, "gym")
@@ -63,10 +66,12 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
problemDao.insertProblem(problem) problemDao.insertProblem(problem)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun updateProblem(problem: Problem) { suspend fun updateProblem(problem: Problem) {
problemDao.updateProblem(problem) problemDao.updateProblem(problem)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun deleteProblem(problem: Problem) { suspend fun deleteProblem(problem: Problem) {
problemDao.deleteProblem(problem) problemDao.deleteProblem(problem)
trackDeletion(problem.id, "problem") trackDeletion(problem.id, "problem")
@@ -77,7 +82,8 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions() fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId) sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow() fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
suspend fun insertSession(session: ClimbSession) { suspend fun insertSession(session: ClimbSession) {
@@ -88,6 +94,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync() triggerAutoSync()
} }
} }
suspend fun updateSession(session: ClimbSession) { suspend fun updateSession(session: ClimbSession) {
sessionDao.updateSession(session) sessionDao.updateSession(session)
dataStateManager.updateDataState() dataStateManager.updateDataState()
@@ -96,12 +103,14 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync() triggerAutoSync()
} }
} }
suspend fun deleteSession(session: ClimbSession) { suspend fun deleteSession(session: ClimbSession) {
sessionDao.deleteSession(session) sessionDao.deleteSession(session)
trackDeletion(session.id, "session") trackDeletion(session.id, "session")
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync() triggerAutoSync()
} }
suspend fun getLastUsedGym(): Gym? { suspend fun getLastUsedGym(): Gym? {
val recentSessions = sessionDao.getRecentSessions(1).first() val recentSessions = sessionDao.getRecentSessions(1).first()
return if (recentSessions.isNotEmpty()) { return if (recentSessions.isNotEmpty()) {
@@ -114,17 +123,21 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
// Attempt operations // Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts() fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId) attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsByProblem(problemId) attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) { suspend fun insertAttempt(attempt: Attempt) {
attemptDao.insertAttempt(attempt) attemptDao.insertAttempt(attempt)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun updateAttempt(attempt: Attempt) { suspend fun updateAttempt(attempt: Attempt) {
attemptDao.updateAttempt(attempt) attemptDao.updateAttempt(attempt)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun deleteAttempt(attempt: Attempt) { suspend fun deleteAttempt(attempt: Attempt) {
attemptDao.deleteAttempt(attempt) attemptDao.deleteAttempt(attempt)
trackDeletion(attempt.id, "attempt") trackDeletion(attempt.id, "attempt")
@@ -141,38 +154,38 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val backupData = val backupData =
ClimbDataBackup( ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(), exportedAt = DateFormatUtils.nowISO8601(),
version = "2.0", version = "2.0",
formatVersion = "2.0", formatVersion = "2.0",
gyms = allGyms.map { BackupGym.fromGym(it) }, gyms = allGyms.map { BackupGym.fromGym(it) },
problems = allProblems.map { BackupProblem.fromProblem(it) }, problems = allProblems.map { BackupProblem.fromProblem(it) },
sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) }, sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) } attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
) )
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths = val validImagePaths =
referencedImagePaths referencedImagePaths
.filter { imagePath -> .filter { imagePath ->
try { try {
val imageFile = val imageFile =
com.atridad.ascently.utils.ImageUtils.getImageFile( com.atridad.ascently.utils.ImageUtils.getImageFile(
context, context,
imagePath imagePath
) )
imageFile.exists() && imageFile.length() > 0 imageFile.exists() && imageFile.length() > 0
} catch (_: Exception) { } catch (_: Exception) {
false false
} }
} }
.toSet() .toSet()
ZipExportImportUtils.createExportZipToUri( ZipExportImportUtils.createExportZipToUri(
context = context, context = context,
uri = uri, uri = uri,
exportData = backupData, exportData = backupData,
referencedImagePaths = validImagePaths referencedImagePaths = validImagePaths
) )
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Export failed: ${e.message}") throw Exception("Export failed: ${e.message}")
@@ -192,11 +205,11 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
} }
val importData = val importData =
try { try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent) json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Invalid data format: ${e.message}") throw Exception("Invalid data format: ${e.message}")
} }
validateImportData(importData) validateImportData(importData)
@@ -214,17 +227,17 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
} }
val updatedBackupProblems = val updatedBackupProblems =
ZipExportImportUtils.updateProblemImagePaths( ZipExportImportUtils.updateProblemImagePaths(
importData.problems, importData.problems,
importResult.importedImagePaths importResult.importedImagePaths
) )
updatedBackupProblems.forEach { backupProblem -> updatedBackupProblems.forEach { backupProblem ->
try { try {
problemDao.insertProblem(backupProblem.toProblem()) problemDao.insertProblem(backupProblem.toProblem())
} catch (e: Exception) { } catch (e: Exception) {
throw Exception( throw Exception(
"Failed to import problem '${backupProblem.name}': ${e.message}" "Failed to import problem '${backupProblem.name}': ${e.message}"
) )
} }
} }
@@ -262,7 +275,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
fun trackDeletion(itemId: String, itemType: String) { fun trackDeletion(itemId: String, itemType: String) {
val currentDeletions = getDeletedItems().toMutableList() val currentDeletions = getDeletedItems().toMutableList()
val newDeletion = val newDeletion =
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601()) DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
currentDeletions.add(newDeletion) currentDeletions.add(newDeletion)
val json = json.encodeToString(newDeletion) val json = json.encodeToString(newDeletion)
@@ -292,23 +305,23 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
} }
private fun validateDataIntegrity( private fun validateDataIntegrity(
gyms: List<Gym>, gyms: List<Gym>,
problems: List<Problem>, problems: List<Problem>,
sessions: List<ClimbSession>, sessions: List<ClimbSession>,
attempts: List<Attempt> attempts: List<Attempt>
) { ) {
val gymIds = gyms.map { it.id }.toSet() val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds } val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) { if (invalidProblems.isNotEmpty()) {
throw Exception( throw Exception(
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms" "Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
) )
} }
val invalidSessions = sessions.filter { it.gymId !in gymIds } val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) { if (invalidSessions.isNotEmpty()) {
throw Exception( throw Exception(
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms" "Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
) )
} }
@@ -316,10 +329,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
val sessionIds = sessions.map { it.id }.toSet() val sessionIds = sessions.map { it.id }.toSet()
val invalidAttempts = val invalidAttempts =
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds } attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
if (invalidAttempts.isNotEmpty()) { if (invalidAttempts.isNotEmpty()) {
throw Exception( throw Exception(
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions" "Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
) )
} }
} }
@@ -334,9 +347,9 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
} }
if (importData.gyms.size > 1000 || if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 || importData.problems.size > 10000 ||
importData.sessions.size > 10000 || importData.sessions.size > 10000 ||
importData.attempts.size > 100000 importData.attempts.size > 100000
) { ) {
throw Exception("Import data is too large: possible corruption or malicious file") throw Exception("Import data is too large: possible corruption or malicious file")
} }
@@ -386,10 +399,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
if (imagesDir.exists() && imagesDir.isDirectory) { if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0 val deletedCount = imagesDir.listFiles()?.size ?: 0
imagesDir.deleteRecursively() imagesDir.deleteRecursively()
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files") AppLogger.i("ClimbRepository") { "Cleared $deletedCount image files" }
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}") AppLogger.w("ClimbRepository", e) { "Failed to clear some images: ${e.message}" }
} }
} }
} }

View File

@@ -2,8 +2,8 @@ package com.atridad.ascently.data.state
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
/** /**
@@ -20,13 +20,13 @@ class DataStateManager(context: Context) {
} }
private val prefs: SharedPreferences = private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
init { init {
if (!isInitialized()) { if (!isInitialized()) {
updateDataState() updateDataState()
markAsInitialized() markAsInitialized()
Log.d(TAG, "DataStateManager initialized with timestamp: ${getLastModified()}") AppLogger.d(TAG) { "DataStateManager initialized with timestamp: ${getLastModified()}" }
} }
} }
@@ -37,7 +37,7 @@ class DataStateManager(context: Context) {
fun updateDataState() { fun updateDataState() {
val now = DateFormatUtils.nowISO8601() val now = DateFormatUtils.nowISO8601()
prefs.edit { putString(KEY_LAST_MODIFIED, now) } prefs.edit { putString(KEY_LAST_MODIFIED, now) }
Log.d(TAG, "Data state updated to: $now") AppLogger.d(TAG) { "Data state updated to: $now" }
} }
/** /**
@@ -46,7 +46,7 @@ class DataStateManager(context: Context) {
*/ */
fun getLastModified(): String { fun getLastModified(): String {
return prefs.getString(KEY_LAST_MODIFIED, DateFormatUtils.nowISO8601()) return prefs.getString(KEY_LAST_MODIFIED, DateFormatUtils.nowISO8601())
?: DateFormatUtils.nowISO8601() ?: DateFormatUtils.nowISO8601()
} }
/** Checks if the data state has been initialized. */ /** Checks if the data state has been initialized. */

View File

@@ -4,10 +4,10 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.util.Log
import androidx.annotation.RequiresPermission import androidx.annotation.RequiresPermission
import androidx.core.content.edit import androidx.core.content.edit
import com.atridad.ascently.data.format.BackupAttempt 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.BackupClimbSession
import com.atridad.ascently.data.format.BackupGym import com.atridad.ascently.data.format.BackupGym
import com.atridad.ascently.data.format.BackupProblem import com.atridad.ascently.data.format.BackupProblem
@@ -53,14 +53,14 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
private val sharedPreferences: SharedPreferences = private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE) context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
private val httpClient = private val httpClient =
OkHttpClient.Builder() OkHttpClient.Builder()
.connectTimeout(45, TimeUnit.SECONDS) .connectTimeout(45, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS) .readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS) .writeTimeout(90, TimeUnit.SECONDS)
.build() .build()
private val json = Json { private val json = Json {
prettyPrint = true prettyPrint = true
@@ -151,7 +151,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
private fun isNetworkAvailable(): Boolean { private fun isNetworkAvailable(): Boolean {
val connectivityManager = val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false val network = connectivityManager.activeNetwork ?: return false
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
return when { return when {
@@ -164,12 +164,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
suspend fun syncWithServer() { suspend fun syncWithServer() {
if (isOfflineMode) { if (isOfflineMode) {
Log.d(TAG, "Sync skipped: Offline mode is enabled.") AppLogger.d(TAG) { "Sync skipped: Offline mode is enabled." }
return return
} }
if (!isNetworkAvailable()) { if (!isNetworkAvailable()) {
_syncError.value = "No internet connection." _syncError.value = "No internet connection."
Log.d(TAG, "Sync skipped: No internet connection.") AppLogger.d(TAG) { "Sync skipped: No internet connection." }
return return
} }
if (!_isConfigured.value) { if (!_isConfigured.value) {
@@ -188,43 +188,46 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val serverBackup = downloadData() val serverBackup = downloadData()
val hasLocalData = val hasLocalData =
localBackup.gyms.isNotEmpty() || localBackup.gyms.isNotEmpty() ||
localBackup.problems.isNotEmpty() || localBackup.problems.isNotEmpty() ||
localBackup.sessions.isNotEmpty() || localBackup.sessions.isNotEmpty() ||
localBackup.attempts.isNotEmpty() localBackup.attempts.isNotEmpty()
val hasServerData = val hasServerData =
serverBackup.gyms.isNotEmpty() || serverBackup.gyms.isNotEmpty() ||
serverBackup.problems.isNotEmpty() || serverBackup.problems.isNotEmpty() ||
serverBackup.sessions.isNotEmpty() || serverBackup.sessions.isNotEmpty() ||
serverBackup.attempts.isNotEmpty() serverBackup.attempts.isNotEmpty()
// If both client and server have been synced before, use delta sync // If both client and server have been synced before, use delta sync
val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
if (hasLocalData && hasServerData && lastSyncTimeStr != null) { if (hasLocalData && hasServerData && lastSyncTimeStr != null) {
Log.d(TAG, "Using delta sync for incremental updates") AppLogger.d(TAG) { "Using delta sync for incremental updates" }
performDeltaSync(lastSyncTimeStr) performDeltaSync(lastSyncTimeStr)
} else { } else {
when { when {
!hasLocalData && hasServerData -> { !hasLocalData && hasServerData -> {
Log.d(TAG, "No local data found, performing full restore from server") AppLogger.d(TAG) { "No local data found, performing full restore from server" }
val imagePathMapping = syncImagesFromServer(serverBackup) val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping) importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Full restore completed") AppLogger.d(TAG) { "Full restore completed" }
} }
hasLocalData && !hasServerData -> { hasLocalData && !hasServerData -> {
Log.d(TAG, "No server data found, uploading local data to server") AppLogger.d(TAG) { "No server data found, uploading local data to server" }
uploadData(localBackup) uploadData(localBackup)
syncImagesForBackup(localBackup) syncImagesForBackup(localBackup)
Log.d(TAG, "Initial upload completed") AppLogger.d(TAG) { "Initial upload completed" }
} }
hasLocalData && hasServerData -> { hasLocalData && hasServerData -> {
Log.d(TAG, "Both local and server data exist, merging (server wins)") AppLogger.d(TAG) { "Both local and server data exist, merging (server wins)" }
mergeDataSafely(serverBackup) mergeDataSafely(serverBackup)
Log.d(TAG, "Merge completed") AppLogger.d(TAG) { "Merge completed" }
} }
else -> { else -> {
Log.d(TAG, "No data to sync") AppLogger.d(TAG) { "No data to sync" }
} }
} }
} }
@@ -242,7 +245,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
private suspend fun performDeltaSync(lastSyncTimeStr: String) { private suspend fun performDeltaSync(lastSyncTimeStr: String) {
Log.d(TAG, "Starting delta sync with lastSyncTime=$lastSyncTimeStr") AppLogger.d(TAG) { "Starting delta sync with lastSyncTime=$lastSyncTimeStr" }
// Parse last sync time to filter modified items // Parse last sync time to filter modified items
val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0) val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0)
@@ -250,102 +253,100 @@ class SyncService(private val context: Context, private val repository: ClimbRep
// Collect items modified since last sync // Collect items modified since last sync
val allGyms = repository.getAllGyms().first() val allGyms = repository.getAllGyms().first()
val modifiedGyms = val modifiedGyms =
allGyms allGyms
.filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true } .filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true }
.map { BackupGym.fromGym(it) } .map { BackupGym.fromGym(it) }
val allProblems = repository.getAllProblems().first() val allProblems = repository.getAllProblems().first()
val modifiedProblems = val modifiedProblems =
allProblems allProblems
.filter { problem -> .filter { problem ->
parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true 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
} }
.map { problem ->
Log.d( val backupProblem = BackupProblem.fromProblem(problem)
TAG, val normalizedImagePaths =
"Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}" problem.imagePaths.mapIndexed { index, _ ->
) ImageNamingUtils.generateImageFilename(problem.id, index)
// 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) { if (normalizedImagePaths.isNotEmpty()) {
throw SyncException.NetworkError(e.message ?: "Network error") backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
} }
} }
Log.d( val allSessions = repository.getAllSessions().first()
TAG, val modifiedSessions =
"Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}" 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 // Apply server changes to local data
applyDeltaResponse(deltaResponse) applyDeltaResponse(deltaResponse)
@@ -372,7 +373,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val allDeletions = repository.getDeletedItems() + response.deletedItems val allDeletions = repository.getDeletedItems() + response.deletedItems
val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" } val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" }
Log.d(TAG, "Applying ${uniqueDeletions.size} deletion records before merging data") AppLogger.d(TAG) { "Applying ${uniqueDeletions.size} deletion records before merging data" }
applyDeletions(uniqueDeletions) applyDeletions(uniqueDeletions)
// Build deleted item lookup set // Build deleted item lookup set
@@ -392,7 +393,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
imagePathMapping[imagePath] = localImagePath imagePathMapping[imagePath] = localImagePath
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}") AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
} }
} }
} }
@@ -421,9 +422,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
continue continue
} }
val updatedImagePaths = val updatedImagePaths =
backupProblem.imagePaths?.map { oldPath -> backupProblem.imagePaths?.map { oldPath ->
imagePathMapping[oldPath] ?: oldPath imagePathMapping[oldPath] ?: oldPath
} }
val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths) val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths)
val problem = problemToMerge.toProblem() val problem = problemToMerge.toProblem()
@@ -484,7 +485,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
private suspend fun applyDeletions( private suspend fun applyDeletions(
deletions: List<com.atridad.ascently.data.format.DeletedItem> deletions: List<com.atridad.ascently.data.format.DeletedItem>
) { ) {
val existingGyms = repository.getAllGyms().first() val existingGyms = repository.getAllGyms().first()
val existingProblems = repository.getAllProblems().first() val existingProblems = repository.getAllProblems().first()
@@ -496,12 +497,15 @@ class SyncService(private val context: Context, private val repository: ClimbRep
"gym" -> { "gym" -> {
existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) } existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) }
} }
"problem" -> { "problem" -> {
existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) } existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) }
} }
"session" -> { "session" -> {
existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) } existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) }
} }
"attempt" -> { "attempt" -> {
existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) } existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) }
} }
@@ -512,7 +516,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun syncModifiedImages(modifiedProblems: List<BackupProblem>) { private suspend fun syncModifiedImages(modifiedProblems: List<BackupProblem>) {
if (modifiedProblems.isEmpty()) return if (modifiedProblems.isEmpty()) return
Log.d(TAG, "Syncing images for ${modifiedProblems.size} modified problems") AppLogger.d(TAG) { "Syncing images for ${modifiedProblems.size} modified problems" }
for (backupProblem in modifiedProblems) { for (backupProblem in modifiedProblems) {
backupProblem.imagePaths?.forEach { imagePath -> backupProblem.imagePaths?.forEach { imagePath ->
@@ -524,11 +528,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun downloadData(): ClimbDataBackup { private suspend fun downloadData(): ClimbDataBackup {
val request = val request =
Request.Builder() Request.Builder()
.url("$serverUrl/sync") .url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken") .header("Authorization", "Bearer $authToken")
.get() .get()
.build() .build()
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
@@ -539,11 +543,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
json.decodeFromString(body) json.decodeFromString(body)
} else { } else {
ClimbDataBackup( ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(), exportedAt = DateFormatUtils.nowISO8601(),
gyms = emptyList(), gyms = emptyList(),
problems = emptyList(), problems = emptyList(),
sessions = emptyList(), sessions = emptyList(),
attempts = emptyList() attempts = emptyList()
) )
} }
} else { } else {
@@ -558,14 +562,14 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun uploadData(backup: ClimbDataBackup) { private suspend fun uploadData(backup: ClimbDataBackup) {
val requestBody = val requestBody =
json.encodeToString(backup).toRequestBody("application/json".toMediaType()) json.encodeToString(backup).toRequestBody("application/json".toMediaType())
val request = val request =
Request.Builder() Request.Builder()
.url("$serverUrl/sync") .url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken") .header("Authorization", "Bearer $authToken")
.put(requestBody) .put(requestBody)
.build() .build()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
@@ -583,7 +587,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> { private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> {
val imagePathMapping = mutableMapOf<String, String>() val imagePathMapping = mutableMapOf<String, String>()
val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 } val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 }
Log.d(TAG, "Starting image download from server for $totalImages images") AppLogger.d(TAG) { "Starting image download from server for $totalImages images" }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
backup.problems.forEach { problem -> backup.problems.forEach { problem ->
@@ -595,9 +599,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
imagePathMapping[imagePath] = localImagePath imagePathMapping[imagePath] = localImagePath
} }
} catch (_: SyncException.ImageNotFound) { } catch (_: SyncException.ImageNotFound) {
Log.w(TAG, "Image not found on server: $imagePath") AppLogger.w(TAG) { "Image not found on server: $imagePath" }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}") AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
} }
} }
} }
@@ -607,10 +611,10 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun downloadImage(serverFilename: String): String? { private suspend fun downloadImage(serverFilename: String): String? {
val request = val request =
Request.Builder() Request.Builder()
.url("$serverUrl/images/download?filename=$serverFilename") .url("$serverUrl/images/download?filename=$serverFilename")
.header("Authorization", "Bearer $authToken") .header("Authorization", "Bearer $authToken")
.build() .build()
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
@@ -625,14 +629,14 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Network error downloading image $serverFilename", e) AppLogger.e(TAG, e) { "Network error downloading image $serverFilename" }
null null
} }
} }
} }
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) { private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems") AppLogger.d(TAG) { "Starting image sync for backup with ${backup.problems.size} problems" }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
backup.problems.forEach { problem -> backup.problems.forEach { problem ->
problem.imagePaths?.forEach { localPath -> problem.imagePaths?.forEach { localPath ->
@@ -646,33 +650,32 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun uploadImage(localPath: String, filename: String) { private suspend fun uploadImage(localPath: String, filename: String) {
val file = ImageUtils.getImageFile(context, localPath) val file = ImageUtils.getImageFile(context, localPath)
if (!file.exists()) { if (!file.exists()) {
Log.w(TAG, "Local image file not found, cannot upload: $localPath") AppLogger.w(TAG) { "Local image file not found, cannot upload: $localPath" }
return return
} }
val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType()) val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType())
val request = val request =
Request.Builder() Request.Builder()
.url("$serverUrl/images/upload?filename=$filename") .url("$serverUrl/images/upload?filename=$filename")
.header("Authorization", "Bearer $authToken") .header("Authorization", "Bearer $authToken")
.post(requestBody) .post(requestBody)
.build() .build()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
httpClient.newCall(request).execute().use { response -> httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) { if (response.isSuccessful) {
Log.d(TAG, "Successfully uploaded image: $filename") AppLogger.d(TAG) { "Successfully uploaded image: $filename" }
} else { } else {
Log.w( AppLogger.w(TAG) {
TAG, "Failed to upload image $filename. Server responded with ${response.code}"
"Failed to upload image $filename. Server responded with ${response.code}" }
)
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Network error uploading image $filename", e) AppLogger.e(TAG, e) { "Network error uploading image $filename" }
} }
} }
} }
@@ -680,49 +683,49 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun createBackupFromRepository(): ClimbDataBackup { private suspend fun createBackupFromRepository(): ClimbDataBackup {
return withContext(Dispatchers.Default) { return withContext(Dispatchers.Default) {
ClimbDataBackup( ClimbDataBackup(
exportedAt = dataStateManager.getLastModified(), exportedAt = dataStateManager.getLastModified(),
gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) }, gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) },
problems = problems =
repository.getAllProblems().first().map { problem -> repository.getAllProblems().first().map { problem ->
val backupProblem = BackupProblem.fromProblem(problem) val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths = val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ -> problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename( ImageNamingUtils.generateImageFilename(
problem.id, problem.id,
index index
) )
} }
if (normalizedImagePaths.isNotEmpty()) { if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths) backupProblem.copy(imagePaths = normalizedImagePaths)
} else { } else {
backupProblem backupProblem
} }
}, },
sessions = sessions =
repository.getAllSessions().first().map { repository.getAllSessions().first().map {
BackupClimbSession.fromClimbSession(it) BackupClimbSession.fromClimbSession(it)
}, },
attempts = attempts =
repository.getAllAttempts().first().map { repository.getAllAttempts().first().map {
BackupAttempt.fromAttempt(it) BackupAttempt.fromAttempt(it)
}, },
deletedItems = repository.getDeletedItems() deletedItems = repository.getDeletedItems()
) )
} }
} }
private suspend fun importBackupToRepository( private suspend fun importBackupToRepository(
backup: ClimbDataBackup, backup: ClimbDataBackup,
imagePathMapping: Map<String, String> imagePathMapping: Map<String, String>
) { ) {
val gyms = backup.gyms.map { it.toGym() } val gyms = backup.gyms.map { it.toGym() }
val problems = val problems =
backup.problems.map { backupProblem -> backup.problems.map { backupProblem ->
val imagePaths = backupProblem.imagePaths val imagePaths = backupProblem.imagePaths
val updatedImagePaths = val updatedImagePaths =
imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath } imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath }
backupProblem.copy(imagePaths = updatedImagePaths).toProblem() backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
} }
val sessions = backup.sessions.map { it.toClimbSession() } val sessions = backup.sessions.map { it.toClimbSession() }
val attempts = backup.attempts.map { it.toAttempt() } val attempts = backup.attempts.map { it.toAttempt() }
@@ -737,7 +740,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) { private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) {
Log.d(TAG, "Server data will overwrite local data. Performing full restore.") AppLogger.d(TAG) { "Server data will overwrite local data. Performing full restore." }
val imagePathMapping = syncImagesFromServer(serverBackup) val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping) importBackupToRepository(serverBackup, imagePathMapping)
} }
@@ -760,11 +763,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
_syncError.value = null _syncError.value = null
val request = val request =
Request.Builder() Request.Builder()
.url("$serverUrl/sync") .url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken") .header("Authorization", "Bearer $authToken")
.head() .head()
.build() .build()
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
httpClient.newCall(request).execute().use { response -> httpClient.newCall(request).execute().use { response ->
@@ -793,18 +796,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
syncJob?.cancel() syncJob?.cancel()
syncJob = syncJob =
serviceScope.launch { serviceScope.launch {
delay(syncDebounceDelay) delay(syncDebounceDelay)
try { try {
syncWithServer() syncWithServer()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Auto-sync failed", e) AppLogger.e(TAG, e) { "Auto-sync failed" }
}
if (pendingChanges) {
pendingChanges = false
triggerAutoSync()
}
} }
if (pendingChanges) {
pendingChanges = false
triggerAutoSync()
}
}
} }
fun clearConfiguration() { fun clearConfiguration() {
@@ -822,7 +825,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
sealed class SyncException(message: String) : IOException(message), Serializable { sealed class SyncException(message: String) : IOException(message), Serializable {
object NotConfigured : object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.") SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.") object NotConnected : SyncException("Not connected to server. Please test connection first.")
@@ -832,6 +835,7 @@ sealed class SyncException(message: String) : IOException(message), Serializable
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code") data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) : data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details") SyncException("Invalid server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details") data class NetworkError(val details: String) : SyncException("Network error: $details")
} }

View File

@@ -12,6 +12,7 @@ import com.atridad.ascently.MainActivity
import com.atridad.ascently.R import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.widget.ClimbStatsWidgetProvider import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
@@ -29,6 +30,7 @@ class SessionTrackingService : Service() {
private lateinit var notificationManager: NotificationManager private lateinit var notificationManager: NotificationManager
companion object { companion object {
private const val LOG_TAG = "SessionTrackingService"
const val NOTIFICATION_ID = 1001 const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "session_tracking_channel" const val CHANNEL_ID = "session_tracking_channel"
const val ACTION_START_SESSION = "start_session" const val ACTION_START_SESSION = "start_session"
@@ -68,23 +70,24 @@ class SessionTrackingService : Service() {
startSessionTracking(sessionId) startSessionTracking(sessionId)
} }
} }
ACTION_STOP_SESSION -> { ACTION_STOP_SESSION -> {
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID) val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
serviceScope.launch { serviceScope.launch {
try { try {
val targetSession = val targetSession =
when { when {
sessionId != null -> repository.getSessionById(sessionId) sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession() else -> repository.getActiveSession()
} }
if (targetSession != null && if (targetSession != null &&
targetSession.status == targetSession.status ==
com.atridad.ascently.data.model.SessionStatus.ACTIVE com.atridad.ascently.data.model.SessionStatus.ACTIVE
) { ) {
val completed = val completed =
with(com.atridad.ascently.data.model.ClimbSession) { with(com.atridad.ascently.data.model.ClimbSession) {
targetSession.complete() targetSession.complete()
} }
repository.updateSession(completed) repository.updateSession(completed)
} }
} finally { } finally {
@@ -108,50 +111,50 @@ class SessionTrackingService : Service() {
// Update widget when session tracking starts // Update widget when session tracking starts
ClimbStatsWidgetProvider.updateAllWidgets(this) ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e(LOG_TAG, e) { "Failed to initialize session tracking notification" }
} }
notificationJob = notificationJob =
serviceScope.launch { serviceScope.launch {
try { try {
if (!isNotificationActive()) { if (!isNotificationActive()) {
delay(1000L) delay(1000L)
createAndShowNotification(sessionId) createAndShowNotification(sessionId)
}
while (isActive) {
delay(5000L)
updateNotification(sessionId)
}
} catch (e: Exception) {
e.printStackTrace()
} }
while (isActive) {
delay(5000L)
updateNotification(sessionId)
}
} catch (e: Exception) {
AppLogger.e(LOG_TAG, e) { "Notification updater loop crashed" }
} }
}
monitoringJob = monitoringJob =
serviceScope.launch { serviceScope.launch {
try { try {
while (isActive) { while (isActive) {
delay(10000L) delay(10000L)
if (!isNotificationActive()) { if (!isNotificationActive()) {
updateNotification(sessionId) updateNotification(sessionId)
} }
val session = repository.getSessionById(sessionId) val session = repository.getSessionById(sessionId)
if (session == null || if (session == null ||
session.status != session.status !=
com.atridad.ascently.data.model.SessionStatus com.atridad.ascently.data.model.SessionStatus
.ACTIVE .ACTIVE
) { ) {
stopSessionTracking() stopSessionTracking()
break break
}
} }
} catch (e: Exception) {
e.printStackTrace()
} }
} catch (e: Exception) {
AppLogger.e(LOG_TAG, e) { "Session monitoring loop crashed" }
} }
}
} }
private fun stopSessionTracking() { private fun stopSessionTracking() {
@@ -178,13 +181,13 @@ class SessionTrackingService : Service() {
// Update widget when notification updates // Update widget when notification updates
ClimbStatsWidgetProvider.updateAllWidgets(this) ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e(LOG_TAG, e) { "Failed to update notification; retrying in 10s" }
try { try {
delay(10000L) delay(10000L)
createAndShowNotification(sessionId) createAndShowNotification(sessionId)
} catch (retryException: Exception) { } catch (retryException: Exception) {
retryException.printStackTrace() AppLogger.e(LOG_TAG, retryException) { "Retrying notification update failed" }
stopSessionTracking() stopSessionTracking()
} }
} }
@@ -194,7 +197,7 @@ class SessionTrackingService : Service() {
try { try {
val session = runBlocking { repository.getSessionById(sessionId) } val session = runBlocking { repository.getSessionById(sessionId) }
if (session == null || if (session == null ||
session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE
) { ) {
stopSessionTracking() stopSessionTracking()
return return
@@ -207,99 +210,99 @@ class SessionTrackingService : Service() {
} }
val duration = val duration =
session.startTime?.let { startTime -> session.startTime?.let { startTime ->
try { try {
val start = LocalDateTime.parse(startTime) val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now() val now = LocalDateTime.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now) val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val hours = totalSeconds / 3600 val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60 val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60 val seconds = totalSeconds % 60
when { when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s" hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s" minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s" else -> "${totalSeconds}s"
}
} catch (_: Exception) {
"Active"
} }
} catch (_: Exception) {
"Active"
} }
?: "Active" }
?: "Active"
val notification = val notification =
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Climbing Session Active") .setContentTitle("Climbing Session Active")
.setContentText( .setContentText(
"${gym?.name ?: "Gym"}$duration${attempts.size} attempts" "${gym?.name ?: "Gym"}$duration${attempts.size} attempts"
) )
.setSmallIcon(R.drawable.ic_mountains) .setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false) .setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent()) .setContentIntent(createOpenAppIntent())
.addAction( .addAction(
R.drawable.ic_mountains, R.drawable.ic_mountains,
"Open Session", "Open Session",
createOpenAppIntent() createOpenAppIntent()
) )
.addAction( .addAction(
android.R.drawable.ic_menu_close_clear_cancel, android.R.drawable.ic_menu_close_clear_cancel,
"End Session", "End Session",
createStopPendingIntent(sessionId) createStopPendingIntent(sessionId)
) )
.build() .build()
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e(LOG_TAG, e) { "Failed to build session tracking notification" }
throw e throw e
} }
} }
private fun createOpenAppIntent(): PendingIntent { private fun createOpenAppIntent(): PendingIntent {
val intent = val intent =
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
action = "OPEN_SESSION" action = "OPEN_SESSION"
} }
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, this,
0, 0,
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
} }
private fun createStopPendingIntent(sessionId: String): PendingIntent { private fun createStopPendingIntent(sessionId: String): PendingIntent {
val intent = createStopIntent(this, sessionId) val intent = createStopIntent(this, sessionId)
return PendingIntent.getService( return PendingIntent.getService(
this, this,
1, 1,
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
} }
private fun createNotificationChannel() { private fun createNotificationChannel() {
val channel = val channel =
NotificationChannel( NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Session Tracking", "Session Tracking",
NotificationManager.IMPORTANCE_DEFAULT NotificationManager.IMPORTANCE_DEFAULT
) )
.apply { .apply {
description = "Shows active climbing session information" description = "Shows active climbing session information"
setShowBadge(false) setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
enableLights(false) enableLights(false)
enableVibration(false) enableVibration(false)
setSound(null, null) setSound(null, null)
} }
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }

View File

@@ -26,15 +26,16 @@ import com.atridad.ascently.ui.components.NotificationPermissionDialog
import com.atridad.ascently.ui.screens.* import com.atridad.ascently.ui.screens.*
import com.atridad.ascently.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.AppShortcutManager import com.atridad.ascently.utils.AppShortcutManager
import com.atridad.ascently.utils.NotificationPermissionUtils import com.atridad.ascently.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AscentlyApp( fun AscentlyApp(
shortcutAction: String? = null, shortcutAction: String? = null,
lastUsedGymId: String? = null, lastUsedGymId: String? = null,
onShortcutActionProcessed: () -> Unit = {} onShortcutActionProcessed: () -> Unit = {}
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
@@ -45,26 +46,26 @@ fun AscentlyApp(
val repository = remember { ClimbRepository(database, context) } val repository = remember { ClimbRepository(database, context) }
val syncService = remember { SyncService(context, repository) } val syncService = remember { SyncService(context, repository) }
val viewModel: ClimbViewModel = val viewModel: ClimbViewModel =
viewModel(factory = ClimbViewModelFactory(repository, syncService, context)) viewModel(factory = ClimbViewModelFactory(repository, syncService, context))
var showNotificationPermissionDialog by remember { mutableStateOf(false) } var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) } var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
val permissionLauncher = val permissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean -> ) { isGranted: Boolean ->
if (!isGranted) { if (!isGranted) {
showNotificationPermissionDialog = false showNotificationPermissionDialog = false
}
} }
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) { if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true hasCheckedNotificationPermission = true
if (NotificationPermissionUtils.shouldRequestNotificationPermission() && if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context) !NotificationPermissionUtils.isNotificationPermissionGranted(context)
) { ) {
showNotificationPermissionDialog = true showNotificationPermissionDialog = true
} }
@@ -86,10 +87,10 @@ fun AscentlyApp(
LaunchedEffect(activeSession, gyms, lastUsedGym) { LaunchedEffect(activeSession, gyms, lastUsedGym) {
AppShortcutManager.updateShortcuts( AppShortcutManager.updateShortcuts(
context = context, context = context,
hasActiveSession = activeSession != null, hasActiveSession = activeSession != null,
hasGyms = gyms.isNotEmpty(), hasGyms = gyms.isNotEmpty(),
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
) )
} }
@@ -101,6 +102,7 @@ fun AscentlyApp(
launchSingleTop = true launchSingleTop = true
} }
} }
AppShortcutManager.ACTION_END_SESSION -> { AppShortcutManager.ACTION_END_SESSION -> {
navController.navigate(Screen.Sessions) { navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
@@ -114,51 +116,36 @@ fun AscentlyApp(
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) { LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) { if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}" }
"AscentlyApp",
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
)
if (activeSession == null) { if (activeSession == null) {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() && if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted( !NotificationPermissionUtils.isNotificationPermissionGranted(
context context
) )
) { ) {
android.util.Log.d("AscentlyApp", "Showing notification permission dialog") AppLogger.d("AscentlyApp") { "Showing notification permission dialog" }
showNotificationPermissionDialog = true showNotificationPermissionDialog = true
} else { } else {
if (gyms.size == 1) { if (gyms.size == 1) {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Starting session with single gym: ${gyms.first().name}" }
"AscentlyApp",
"Starting session with single gym: ${gyms.first().name}"
)
viewModel.startSession(context, gyms.first().id) viewModel.startSession(context, gyms.first().id)
} else { } else {
val targetGym = val targetGym =
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } } lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
?: lastUsedGym ?: lastUsedGym
if (targetGym != null) { if (targetGym != null) {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Starting session with target gym: ${targetGym.name}" }
"AscentlyApp",
"Starting session with target gym: ${targetGym.name}"
)
viewModel.startSession(context, targetGym.id) viewModel.startSession(context, targetGym.id)
} else { } else {
android.util.Log.d( AppLogger.d("AscentlyApp") { "No target gym found, navigating to selection" }
"AscentlyApp",
"No target gym found, navigating to selection"
)
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
} }
} }
} }
} else { } else {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Active session already exists: ${activeSession?.id}" }
"AscentlyApp",
"Active session already exists: ${activeSession?.id}"
)
} }
onShortcutActionProcessed() onShortcutActionProcessed()
@@ -168,79 +155,79 @@ fun AscentlyApp(
var fabConfig by remember { mutableStateOf<FabConfig?>(null) } var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
Scaffold( Scaffold(
bottomBar = { AscentlyBottomNavigation(navController = navController) }, bottomBar = { AscentlyBottomNavigation(navController = navController) },
floatingActionButton = { floatingActionButton = {
fabConfig?.let { config -> fabConfig?.let { config ->
FloatingActionButton( FloatingActionButton(
onClick = config.onClick, onClick = config.onClick,
containerColor = MaterialTheme.colorScheme.primary containerColor = MaterialTheme.colorScheme.primary
) { ) {
Icon( Icon(
imageVector = config.icon, imageVector = config.icon,
contentDescription = config.contentDescription contentDescription = config.contentDescription
) )
}
} }
} }
}
) { innerPadding -> ) { innerPadding ->
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Screen.Sessions, startDestination = Screen.Sessions,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) { ) {
composable<Screen.Sessions> { composable<Screen.Sessions> {
LaunchedEffect(gyms, activeSession) { LaunchedEffect(gyms, activeSession) {
fabConfig = fabConfig =
if (gyms.isNotEmpty() && activeSession == null) { if (gyms.isNotEmpty() && activeSession == null) {
FabConfig( FabConfig(
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
contentDescription = "Start Session", contentDescription = "Start Session",
onClick = { onClick = {
if (NotificationPermissionUtils if (NotificationPermissionUtils
.shouldRequestNotificationPermission() && .shouldRequestNotificationPermission() &&
!NotificationPermissionUtils !NotificationPermissionUtils
.isNotificationPermissionGranted( .isNotificationPermissionGranted(
context context
) )
) { ) {
showNotificationPermissionDialog = true showNotificationPermissionDialog = true
} else { } else {
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
} }
} }
) )
} else { } else {
null null
} }
} }
SessionsScreen( SessionsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToSessionDetail = { sessionId -> onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId)) navController.navigate(Screen.SessionDetail(sessionId))
} }
) )
} }
composable<Screen.Problems> { composable<Screen.Problems> {
LaunchedEffect(gyms) { LaunchedEffect(gyms) {
fabConfig = fabConfig =
if (gyms.isNotEmpty()) { if (gyms.isNotEmpty()) {
FabConfig( FabConfig(
icon = Icons.Default.Add, icon = Icons.Default.Add,
contentDescription = "Add Problem", contentDescription = "Add Problem",
onClick = { onClick = {
navController.navigate(Screen.AddEditProblem()) navController.navigate(Screen.AddEditProblem())
} }
) )
} else { } else {
null null
} }
} }
ProblemsScreen( ProblemsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToProblemDetail = { problemId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
@@ -252,17 +239,17 @@ fun AscentlyApp(
composable<Screen.Gyms> { composable<Screen.Gyms> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
fabConfig = fabConfig =
FabConfig( FabConfig(
icon = Icons.Default.Add, icon = Icons.Default.Add,
contentDescription = "Add Gym", contentDescription = "Add Gym",
onClick = { navController.navigate(Screen.AddEditGym()) } onClick = { navController.navigate(Screen.AddEditGym()) }
) )
} }
GymsScreen( GymsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToGymDetail = { gymId -> onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId)) navController.navigate(Screen.GymDetail(gymId))
} }
) )
} }
@@ -275,12 +262,12 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.SessionDetail>() val args = backStackEntry.toRoute<Screen.SessionDetail>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
SessionDetailScreen( SessionDetailScreen(
sessionId = args.sessionId, sessionId = args.sessionId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToProblemDetail = { problemId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
@@ -288,12 +275,12 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.ProblemDetail>() val args = backStackEntry.toRoute<Screen.ProblemDetail>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
ProblemDetailScreen( ProblemDetailScreen(
problemId = args.problemId, problemId = args.problemId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { problemId -> onNavigateToEdit = { problemId ->
navController.navigate(Screen.AddEditProblem(problemId = problemId)) navController.navigate(Screen.AddEditProblem(problemId = problemId))
} }
) )
} }
@@ -301,18 +288,18 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.GymDetail>() val args = backStackEntry.toRoute<Screen.GymDetail>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
GymDetailScreen( GymDetailScreen(
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId -> onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = gymId)) navController.navigate(Screen.AddEditGym(gymId = gymId))
}, },
onNavigateToSessionDetail = { sessionId -> onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId)) navController.navigate(Screen.SessionDetail(sessionId))
}, },
onNavigateToProblemDetail = { problemId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
@@ -320,9 +307,9 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditGym>() val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen( AddEditGymScreen(
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
@@ -330,10 +317,10 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditProblem>() val args = backStackEntry.toRoute<Screen.AddEditProblem>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
AddEditProblemScreen( AddEditProblemScreen(
problemId = args.problemId, problemId = args.problemId,
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
@@ -341,22 +328,22 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditSession>() val args = backStackEntry.toRoute<Screen.AddEditSession>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
AddEditSessionScreen( AddEditSessionScreen(
sessionId = args.sessionId, sessionId = args.sessionId,
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
} }
if (showNotificationPermissionDialog) { if (showNotificationPermissionDialog) {
NotificationPermissionDialog( NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false }, onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = { onRequestPermission = {
permissionLauncher.launch( permissionLauncher.launch(
NotificationPermissionUtils.getNotificationPermissionString() NotificationPermissionUtils.getNotificationPermissionString()
) )
} }
) )
} }
} }
@@ -370,34 +357,34 @@ fun AscentlyBottomNavigation(navController: NavHostController) {
NavigationBar { NavigationBar {
bottomNavigationItems.forEach { item -> bottomNavigationItems.forEach { item ->
val isSelected = val isSelected =
when (item.screen) { when (item.screen) {
is Screen.Sessions -> currentRoute?.contains("Session") == true is Screen.Sessions -> currentRoute?.contains("Session") == true
is Screen.Problems -> currentRoute?.contains("Problem") == true is Screen.Problems -> currentRoute?.contains("Problem") == true
is Screen.Gyms -> currentRoute?.contains("Gym") == true is Screen.Gyms -> currentRoute?.contains("Gym") == true
is Screen.Analytics -> currentRoute?.contains("Analytics") == true is Screen.Analytics -> currentRoute?.contains("Analytics") == true
is Screen.Settings -> currentRoute?.contains("Settings") == true is Screen.Settings -> currentRoute?.contains("Settings") == true
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
} }
NavigationBarItem( NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) }, icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) }, label = { Text(item.label) },
selected = isSelected, selected = isSelected,
onClick = { onClick = {
navController.navigate(item.screen) { navController.navigate(item.screen) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
launchSingleTop = true launchSingleTop = true
// Don't restore state - always start fresh when switching tabs // Don't restore state - always start fresh when switching tabs
restoreState = false restoreState = false
}
} }
}
) )
} }
} }
} }
data class FabConfig( data class FabConfig(
val icon: androidx.compose.ui.graphics.vector.ImageVector, val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String, val contentDescription: String,
val onClick: () -> Unit val onClick: () -> Unit
) )

View File

@@ -8,6 +8,7 @@ import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.sync.SyncService import com.atridad.ascently.data.sync.SyncService
import com.atridad.ascently.service.SessionTrackingService import com.atridad.ascently.service.SessionTrackingService
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ImageUtils import com.atridad.ascently.utils.ImageUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.io.File import java.io.File
@@ -15,9 +16,9 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ClimbViewModel( class ClimbViewModel(
private val repository: ClimbRepository, private val repository: ClimbRepository,
val syncService: SyncService, val syncService: SyncService,
private val context: Context private val context: Context
) : ViewModel() { ) : ViewModel() {
// Health Connect manager // Health Connect manager
@@ -29,49 +30,49 @@ class ClimbViewModel(
// Data flows // Data flows
val gyms = val gyms =
repository repository
.getAllGyms() .getAllGyms()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
val problems = val problems =
repository repository
.getAllProblems() .getAllProblems()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
val sessions = val sessions =
repository repository
.getAllSessions() .getAllSessions()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
val activeSession = val activeSession =
repository repository
.getActiveSessionFlow() .getActiveSessionFlow()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = null initialValue = null
) )
val attempts = val attempts =
repository repository
.getAllAttempts() .getAllAttempts()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
// Gym operations // Gym operations
fun addGym(gym: Gym, updateWidgets: Boolean = true) { fun addGym(gym: Gym, updateWidgets: Boolean = true) {
@@ -124,7 +125,7 @@ class ClimbViewModel(
problem.imagePaths.forEachIndexed { index, tempPath -> problem.imagePaths.forEachIndexed { index, tempPath ->
if (tempPath.startsWith("temp_")) { if (tempPath.startsWith("temp_")) {
val finalPath = val finalPath =
ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index) ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index)
finalImagePaths.add(finalPath ?: tempPath) finalImagePaths.add(finalPath ?: tempPath)
} else { } else {
finalImagePaths.add(tempPath) finalImagePaths.add(tempPath)
@@ -176,23 +177,23 @@ class ClimbViewModel(
val allProblems = repository.getAllProblems().first() val allProblems = repository.getAllProblems().first()
val updatedProblems = val updatedProblems =
allProblems.map { problem -> allProblems.map { problem ->
if (problem.imagePaths.isNotEmpty()) { if (problem.imagePaths.isNotEmpty()) {
problem.copy(imagePaths = emptyList()) problem.copy(imagePaths = emptyList())
} else { } else {
problem problem
}
} }
}
for (updatedProblem in updatedProblems) { for (updatedProblem in updatedProblems) {
if (updatedProblem.imagePaths != if (updatedProblem.imagePaths !=
allProblems.find { it.id == updatedProblem.id }?.imagePaths allProblems.find { it.id == updatedProblem.id }?.imagePaths
) { ) {
repository.insertProblemWithoutSync(updatedProblem) repository.insertProblemWithoutSync(updatedProblem)
} }
} }
println("Deleted $deletedCount image files and cleared image references") AppLogger.i("ClimbViewModel") { "Deleted $deletedCount image files and cleared image references" }
} }
} }
@@ -225,7 +226,7 @@ class ClimbViewModel(
} }
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
repository.getSessionsByGym(gymId) repository.getSessionsByGym(gymId)
// Get last used gym for shortcut functionality // Get last used gym for shortcut functionality
suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym() suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym()
@@ -233,41 +234,35 @@ class ClimbViewModel(
// Active session management // Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) { fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch { viewModelScope.launch {
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId") AppLogger.d("ClimbViewModel") { "startSession called with gymId: $gymId" }
if (!com.atridad.ascently.utils.NotificationPermissionUtils if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context) .isNotificationPermissionGranted(context)
) { ) {
android.util.Log.d("ClimbViewModel", "Notification permission not granted") AppLogger.d("ClimbViewModel") { "Notification permission not granted" }
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
error = error =
"Notification permission is required to track your climbing session. Please enable notifications in settings." "Notification permission is required to track your climbing session. Please enable notifications in settings."
) )
return@launch return@launch
} }
val existingActive = repository.getActiveSession() val existingActive = repository.getActiveSession()
if (existingActive != null) { if (existingActive != null) {
android.util.Log.d( AppLogger.d("ClimbViewModel") { "Active session already exists: ${existingActive.id}" }
"ClimbViewModel",
"Active session already exists: ${existingActive.id}"
)
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
error = "There's already an active session. Please end it first." error = "There's already an active session. Please end it first."
) )
return@launch return@launch
} }
android.util.Log.d("ClimbViewModel", "Creating new session") AppLogger.d("ClimbViewModel") { "Creating new session" }
val newSession = ClimbSession.create(gymId = gymId, notes = notes) val newSession = ClimbSession.create(gymId = gymId, notes = notes)
repository.insertSession(newSession) repository.insertSession(newSession)
android.util.Log.d( AppLogger.d("ClimbViewModel") { "Starting tracking service for session: ${newSession.id}" }
"ClimbViewModel",
"Starting tracking service for session: ${newSession.id}"
)
// Start the tracking service // Start the tracking service
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id) val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
context.startForegroundService(serviceIntent) context.startForegroundService(serviceIntent)
@@ -281,13 +276,13 @@ class ClimbViewModel(
fun endSession(context: Context, sessionId: String) { fun endSession(context: Context, sessionId: String) {
viewModelScope.launch { viewModelScope.launch {
if (!com.atridad.ascently.utils.NotificationPermissionUtils if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context) .isNotificationPermissionGranted(context)
) { ) {
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
error = error =
"Notification permission is required to manage your climbing session. Please enable notifications in settings." "Notification permission is required to manage your climbing session. Please enable notifications in settings."
) )
return@launch return@launch
} }
@@ -313,7 +308,7 @@ class ClimbViewModel(
val activeSession = repository.getActiveSession() val activeSession = repository.getActiveSession()
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) { if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
val serviceIntent = val serviceIntent =
SessionTrackingService.createStartIntent(context, activeSession.id) SessionTrackingService.createStartIntent(context, activeSession.id)
context.startForegroundService(serviceIntent) context.startForegroundService(serviceIntent)
} }
} }
@@ -348,32 +343,32 @@ class ClimbViewModel(
} }
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId) repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
repository.getAttemptsByProblem(problemId) repository.getAttemptsByProblem(problemId)
fun exportDataToZipUri(context: Context, uri: android.net.Uri) { fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch { viewModelScope.launch {
try { try {
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = true, isLoading = true,
message = "Creating ZIP file with images..." message = "Creating ZIP file with images..."
) )
repository.exportAllDataToZipUri(context, uri) repository.exportAllDataToZipUri(context, uri)
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
message = message =
"Export complete! Your climbing data and images have been saved." "Export complete! Your climbing data and images have been saved."
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
error = "Export failed: ${e.message}" error = "Export failed: ${e.message}"
) )
} }
} }
} }
@@ -385,23 +380,23 @@ class ClimbViewModel(
if (!file.name.lowercase().endsWith(".zip")) { if (!file.name.lowercase().endsWith(".zip")) {
throw Exception( throw Exception(
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently." "Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
) )
} }
repository.importDataFromZip(file) repository.importDataFromZip(file)
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
message = "Data imported successfully from ${file.name}" message = "Data imported successfully from ${file.name}"
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
error = "Import failed: ${e.message}" error = "Import failed: ${e.message}"
) )
} }
} }
} }
@@ -448,13 +443,13 @@ class ClimbViewModel(
repository.resetAllData() repository.resetAllData()
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
message = "All data has been reset successfully" message = "All data has been reset successfully"
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value =
_uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}") _uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
} }
} }
} }
@@ -469,23 +464,20 @@ class ClimbViewModel(
val attemptCount = attempts.size val attemptCount = attempts.size
val result = val result =
healthConnectManager.autoSyncCompletedSession( healthConnectManager.autoSyncCompletedSession(
session, session,
gymName, gymName,
attemptCount attemptCount
) )
result.onFailure { error -> result.onFailure { error ->
if (healthConnectManager.isReadySync()) { if (healthConnectManager.isReadySync()) {
android.util.Log.w( AppLogger.w("ClimbViewModel") { "Health Connect sync failed: ${error.message}" }
"ClimbViewModel",
"Health Connect sync failed: ${error.message}"
)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
if (healthConnectManager.isReadySync()) { if (healthConnectManager.isReadySync()) {
android.util.Log.w("ClimbViewModel", "Health Connect sync error: ${e.message}") AppLogger.w("ClimbViewModel") { "Health Connect sync error: ${e.message}" }
} }
} }
} }
@@ -493,7 +485,7 @@ class ClimbViewModel(
} }
data class ClimbUiState( data class ClimbUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val message: String? = null, val message: String? = null,
val error: String? = null val error: String? = null
) )

View File

@@ -0,0 +1,48 @@
package com.atridad.ascently.utils
import android.util.Log
import com.atridad.ascently.BuildConfig
object AppLogger {
private const val DEFAULT_TAG = "Ascently"
enum class Level(val androidLevel: Int) {
DEBUG(Log.DEBUG),
INFO(Log.INFO),
WARN(Log.WARN),
ERROR(Log.ERROR)
}
fun d(tag: String = DEFAULT_TAG, messageProvider: () -> String) {
log(Level.DEBUG, tag, messageProvider)
}
fun i(tag: String = DEFAULT_TAG, messageProvider: () -> String) {
log(Level.INFO, tag, messageProvider)
}
fun w(tag: String = DEFAULT_TAG, throwable: Throwable? = null, messageProvider: () -> String) {
log(Level.WARN, tag, messageProvider, throwable)
}
fun e(tag: String = DEFAULT_TAG, throwable: Throwable? = null, messageProvider: () -> String) {
log(Level.ERROR, tag, messageProvider, throwable)
}
private fun log(
level: Level,
tag: String,
messageProvider: () -> String,
throwable: Throwable? = null
) {
if (!BuildConfig.DEBUG) return
val message = messageProvider()
if (throwable != null) {
Log.println(level.androidLevel, tag, "$message\n${Log.getStackTraceString(throwable)}")
} else {
Log.println(level.androidLevel, tag, message)
}
}
}

View File

@@ -6,7 +6,6 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.ImageDecoder import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.core.graphics.scale import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import java.io.File import java.io.File
@@ -30,17 +29,17 @@ object ImageUtils {
/** Saves an image from a URI while preserving EXIF orientation data */ /** Saves an image from a URI while preserving EXIF orientation data */
private fun saveImageWithExif( private fun saveImageWithExif(
context: Context, context: Context,
imageUri: Uri, imageUri: Uri,
originalBitmap: Bitmap, originalBitmap: Bitmap,
outputFile: File outputFile: File
): Boolean { ): Boolean {
return try { return try {
// Get EXIF data from original image // Get EXIF data from original image
val originalExif = val originalExif =
context.contentResolver.openInputStream(imageUri)?.use { input -> context.contentResolver.openInputStream(imageUri)?.use { input ->
ExifInterface(input) ExifInterface(input)
} }
// Compress and save the bitmap // Compress and save the bitmap
val compressedBitmap = compressImage(originalBitmap) val compressedBitmap = compressImage(originalBitmap)
@@ -73,7 +72,7 @@ object ImageUtils {
compressedBitmap.recycle() compressedBitmap.recycle()
true true
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Error saving image with EXIF data" }
false false
} }
} }
@@ -86,11 +85,11 @@ object ImageUtils {
// Calculate the scaling factor // Calculate the scaling factor
val scaleFactor = val scaleFactor =
if (width > height) { if (width > height) {
if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f
} else { } else {
if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f
} }
return if (scaleFactor < 1f) { return if (scaleFactor < 1f) {
val newWidth = (width * scaleFactor).toInt() val newWidth = (width * scaleFactor).toInt()
@@ -119,7 +118,7 @@ object ImageUtils {
val file = getImageFile(context, relativePath) val file = getImageFile(context, relativePath)
file.delete() file.delete()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to delete image: $relativePath" }
false false
} }
} }
@@ -137,7 +136,7 @@ object ImageUtils {
sourceFile.copyTo(destFile, overwrite = true) sourceFile.copyTo(destFile, overwrite = true)
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to import image from source: ${sourceFile.name}" }
null null
} }
} }
@@ -148,16 +147,16 @@ object ImageUtils {
val imagesDir = getImagesDirectory(context) val imagesDir = getImagesDirectory(context)
imagesDir.listFiles()?.mapNotNull { file -> imagesDir.listFiles()?.mapNotNull { file ->
if (file.isFile && if (file.isFile &&
(file.extension == "jpg" || (file.extension == "jpg" ||
file.extension == "jpeg" || file.extension == "jpeg" ||
file.extension == "png") file.extension == "png")
) { ) {
"$IMAGES_DIR/${file.name}" "$IMAGES_DIR/${file.name}"
} else null } else null
} }
?: emptyList() ?: emptyList()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to enumerate images directory" }
emptyList() emptyList()
} }
} }
@@ -178,50 +177,47 @@ object ImageUtils {
tempFilename tempFilename
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ImageUtils", "Error saving temporary image from URI", e) AppLogger.e("ImageUtils", e) { "Error saving temporary image from URI" }
null null
} }
} }
/** Renames a temporary image */ /** Renames a temporary image */
fun renameTemporaryImage( fun renameTemporaryImage(
context: Context, context: Context,
tempFilename: String, tempFilename: String,
problemId: String, problemId: String,
imageIndex: Int imageIndex: Int
): String? { ): String? {
return try { return try {
val tempFile = File(getImagesDirectory(context), tempFilename) val tempFile = File(getImagesDirectory(context), tempFilename)
if (!tempFile.exists()) { if (!tempFile.exists()) {
Log.e("ImageUtils", "Temporary file does not exist: $tempFilename") AppLogger.e("ImageUtils") { "Temporary file does not exist: $tempFilename" }
return null return null
} }
val deterministicFilename = val deterministicFilename =
ImageNamingUtils.generateImageFilename(problemId, imageIndex) ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val finalFile = File(getImagesDirectory(context), deterministicFilename) val finalFile = File(getImagesDirectory(context), deterministicFilename)
if (tempFile.renameTo(finalFile)) { if (tempFile.renameTo(finalFile)) {
Log.d( AppLogger.d("ImageUtils") { "Renamed temporary image: $tempFilename -> $deterministicFilename" }
"ImageUtils",
"Renamed temporary image: $tempFilename -> $deterministicFilename"
)
deterministicFilename deterministicFilename
} else { } else {
Log.e("ImageUtils", "Failed to rename temporary image: $tempFilename") AppLogger.e("ImageUtils") { "Failed to rename temporary image: $tempFilename" }
null null
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ImageUtils", "Error renaming temporary image", e) AppLogger.e("ImageUtils", e) { "Error renaming temporary image" }
null null
} }
} }
/** Saves image data with a specific filename */ /** Saves image data with a specific filename */
fun saveImageFromBytesWithFilename( fun saveImageFromBytesWithFilename(
context: Context, context: Context,
imageData: ByteArray, imageData: ByteArray,
filename: String filename: String
): String? { ): String? {
return try { return try {
val imageFile = File(getImagesDirectory(context), filename) val imageFile = File(getImagesDirectory(context), filename)
@@ -230,7 +226,7 @@ object ImageUtils {
if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold
// For large images, decode, compress, and try to preserve EXIF // For large images, decode, compress, and try to preserve EXIF
val bitmap = val bitmap =
BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap) val compressedBitmap = compressImage(bitmap)
// Save compressed image // Save compressed image
@@ -249,7 +245,7 @@ object ImageUtils {
destExif.saveAttributes() destExif.saveAttributes()
} catch (e: Exception) { } catch (e: Exception) {
// If EXIF preservation fails, continue without it // If EXIF preservation fails, continue without it
Log.w("ImageUtils", "Failed to preserve EXIF data: ${e.message}") AppLogger.w("ImageUtils") { "Failed to preserve EXIF data: ${e.message}" }
} }
bitmap.recycle() bitmap.recycle()
@@ -262,7 +258,7 @@ object ImageUtils {
// Return relative path // Return relative path
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to save image from bytes: $filename" }
null null
} }
} }
@@ -275,7 +271,7 @@ object ImageUtils {
orphanedImages.forEach { path -> deleteImage(context, path) } orphanedImages.forEach { path -> deleteImage(context, path) }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to clean up orphaned images" }
} }
} }
} }

View File

@@ -2,7 +2,6 @@ package com.atridad.ascently.utils
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
class MigrationManager(private val context: Context) { class MigrationManager(private val context: Context) {
@@ -14,7 +13,7 @@ class MigrationManager(private val context: Context) {
} }
private val migrationPrefs: SharedPreferences = private val migrationPrefs: SharedPreferences =
context.getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE)
/** /**
* Perform migration from OpenClimb to Ascently if needed This should be called early in app * Perform migration from OpenClimb to Ascently if needed This should be called early in app
@@ -22,11 +21,11 @@ class MigrationManager(private val context: Context) {
*/ */
fun migrateIfNeeded() { fun migrateIfNeeded() {
if (migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) { if (migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) {
Log.d(TAG, "Migration already completed, skipping") AppLogger.d(TAG) { "Migration already completed, skipping" }
return return
} }
Log.i(TAG, "🔄 Starting migration from OpenClimb to Ascently...") AppLogger.i(TAG) { "🔄 Starting migration from OpenClimb to Ascently..." }
var migrationCount = 0 var migrationCount = 0
// Migrate SharedPreferences // Migrate SharedPreferences
@@ -36,12 +35,9 @@ class MigrationManager(private val context: Context) {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, true) } migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, true) }
if (migrationCount > 0) { if (migrationCount > 0) {
Log.i( AppLogger.i(TAG) { "🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently" }
TAG,
"🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently"
)
} else { } else {
Log.i(TAG, " No OpenClimb data found to migrate") AppLogger.i(TAG) { " No OpenClimb data found to migrate" }
} }
} }
@@ -51,12 +47,12 @@ class MigrationManager(private val context: Context) {
// Define preference file migrations // Define preference file migrations
val preferenceFileMigrations = val preferenceFileMigrations =
listOf( listOf(
"openclimb_data_state" to "ascently_data_state", "openclimb_data_state" to "ascently_data_state",
"health_connect_prefs" to "health_connect_prefs", // Keep same name "health_connect_prefs" to "health_connect_prefs", // Keep same name
"deleted_items" to "deleted_items", // Keep same name "deleted_items" to "deleted_items", // Keep same name
"sync_preferences" to "sync_preferences" // Keep same name "sync_preferences" to "sync_preferences" // Keep same name
) )
for ((oldFileName, newFileName) in preferenceFileMigrations) { for ((oldFileName, newFileName) in preferenceFileMigrations) {
if (oldFileName != newFileName) { if (oldFileName != newFileName) {
@@ -95,10 +91,7 @@ class MigrationManager(private val context: Context) {
// Clear old preferences // Clear old preferences
oldPrefs.edit { clear() } oldPrefs.edit { clear() }
Log.d( AppLogger.d(TAG) { "Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)" }
TAG,
"✅ Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)"
)
return oldPrefs.all.size return oldPrefs.all.size
} }
@@ -111,12 +104,12 @@ class MigrationManager(private val context: Context) {
// Check for any openclimb-prefixed keys across all preference files // Check for any openclimb-prefixed keys across all preference files
val preferencesToCheck = val preferencesToCheck =
listOf( listOf(
"ascently_data_state", "ascently_data_state",
"health_connect_prefs", "health_connect_prefs",
"deleted_items", "deleted_items",
"sync_preferences" "sync_preferences"
) )
for (prefFileName in preferencesToCheck) { for (prefFileName in preferencesToCheck) {
val prefs = context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
@@ -150,7 +143,7 @@ class MigrationManager(private val context: Context) {
} }
} }
Log.d(TAG, "Migrated ${keysToMigrate.size} keys in $prefFileName") AppLogger.d(TAG) { "Migrated ${keysToMigrate.size} keys in $prefFileName" }
count += keysToMigrate.size count += keysToMigrate.size
} }
} }
@@ -166,6 +159,6 @@ class MigrationManager(private val context: Context) {
/** Reset migration state (for testing purposes) */ /** Reset migration state (for testing purposes) */
fun resetMigrationState() { fun resetMigrationState() {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, false) } migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, false) }
Log.d(TAG, "Migration state reset") AppLogger.d(TAG) { "Migration state reset" }
} }
} }

View File

@@ -22,19 +22,19 @@ object ZipExportImportUtils {
/** Creates a ZIP file containing the JSON data and all referenced images */ /** Creates a ZIP file containing the JSON data and all referenced images */
fun createExportZip( fun createExportZip(
context: Context, context: Context,
exportData: ClimbDataBackup, exportData: ClimbDataBackup,
referencedImagePaths: Set<String>, referencedImagePaths: Set<String>,
directory: File? = null directory: File? = null
): File { ): File {
val exportDir = val exportDir =
directory directory
?: File( ?: File(
context.getExternalFilesDir( context.getExternalFilesDir(
android.os.Environment.DIRECTORY_DOCUMENTS android.os.Environment.DIRECTORY_DOCUMENTS
), ),
"Ascently" "Ascently"
) )
if (!exportDir.exists()) { if (!exportDir.exists()) {
exportDir.mkdirs() exportDir.mkdirs()
} }
@@ -52,10 +52,11 @@ object ZipExportImportUtils {
zipOut.closeEntry() zipOut.closeEntry()
// Add JSON data file // Add JSON data file
val json = Json { val json =
prettyPrint = true Json {
ignoreUnknownKeys = true prettyPrint = true
} ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(exportData) val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME) val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
@@ -78,24 +79,21 @@ object ZipExportImportUtils {
zipOut.closeEntry() zipOut.closeEntry()
successfulImages++ successfulImages++
} else { } else {
android.util.Log.w( AppLogger.w("ZipExportImportUtils") {
"ZipExportImportUtils", "Image file not found or empty: $imagePath"
"Image file not found or empty: $imagePath" }
)
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e( AppLogger.e("ZipExportImportUtils", e) {
"ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}"
"Failed to add image $imagePath: ${e.message}" }
)
} }
} }
// Log export summary // Log export summary
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils", "Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included" }
)
} }
// Validate the created ZIP file // Validate the created ZIP file
@@ -115,10 +113,10 @@ object ZipExportImportUtils {
/** Creates a ZIP file and writes it to a provided URI */ /** Creates a ZIP file and writes it to a provided URI */
fun createExportZipToUri( fun createExportZipToUri(
context: Context, context: Context,
uri: android.net.Uri, uri: android.net.Uri,
exportData: ClimbDataBackup, exportData: ClimbDataBackup,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>
) { ) {
try { try {
context.contentResolver.openOutputStream(uri)?.use { outputStream -> context.contentResolver.openOutputStream(uri)?.use { outputStream ->
@@ -131,10 +129,11 @@ object ZipExportImportUtils {
zipOut.closeEntry() zipOut.closeEntry()
// Add JSON data file // Add JSON data file
val json = Json { val json =
prettyPrint = true Json {
ignoreUnknownKeys = true prettyPrint = true
} ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(exportData) val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME) val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
@@ -158,28 +157,26 @@ object ZipExportImportUtils {
successfulImages++ successfulImages++
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e( AppLogger.e("ZipExportImportUtils", e) {
"ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}"
"Failed to add image $imagePath: ${e.message}" }
)
} }
} }
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils", "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included"
"Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included" }
)
} }
} }
?: throw IOException("Could not open output stream") ?: throw IOException("Could not open output stream")
} catch (e: Exception) { } catch (e: Exception) {
throw IOException("Failed to create export ZIP to URI: ${e.message}") throw IOException("Failed to create export ZIP to URI: ${e.message}")
} }
} }
private fun createMetadata( private fun createMetadata(
exportData: ClimbDataBackup, exportData: ClimbDataBackup,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>
): String { ): String {
return buildString { return buildString {
appendLine("Ascently Export Metadata") appendLine("Ascently Export Metadata")
@@ -197,8 +194,8 @@ object ZipExportImportUtils {
/** Data class to hold extraction results */ /** Data class to hold extraction results */
data class ImportResult( data class ImportResult(
val jsonContent: String, val jsonContent: String,
val importedImagePaths: Map<String, String> // original filename -> new relative path val importedImagePaths: Map<String, String> // original filename -> new relative path
) )
/** Extracts a ZIP file and returns the JSON content and imported image paths */ /** Extracts a ZIP file and returns the JSON content and imported image paths */
@@ -217,16 +214,17 @@ object ZipExportImportUtils {
// Read metadata for validation // Read metadata for validation
val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8) val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("metadata") foundRequiredFiles.add("metadata")
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils", "Found metadata: ${metadataContent.lines().take(3).joinToString()}"
"Found metadata: ${metadataContent.lines().take(3).joinToString()}" }
)
} }
entry.name == DATA_JSON_FILENAME -> { entry.name == DATA_JSON_FILENAME -> {
// Read JSON data // Read JSON data
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8) jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("data") foundRequiredFiles.add("data")
} }
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> { entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
// Extract image file // Extract image file
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/") val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
@@ -234,11 +232,11 @@ object ZipExportImportUtils {
try { try {
// Create temporary file to hold the extracted image // Create temporary file to hold the extracted image
val tempFile = val tempFile =
File.createTempFile( File.createTempFile(
"import_image_", "import_image_",
"_$originalFilename", "_$originalFilename",
context.cacheDir context.cacheDir
) )
FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) } FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) }
@@ -248,37 +246,33 @@ object ZipExportImportUtils {
val newPath = ImageUtils.importImageFile(context, tempFile) val newPath = ImageUtils.importImageFile(context, tempFile)
if (newPath != null) { if (newPath != null) {
importedImagePaths[originalFilename] = newPath importedImagePaths[originalFilename] = newPath
android.util.Log.d( AppLogger.d("ZipExportImportUtils") {
"ZipExportImportUtils", "Successfully imported image: $originalFilename -> $newPath"
"Successfully imported image: $originalFilename -> $newPath" }
)
} else { } else {
android.util.Log.w( AppLogger.w("ZipExportImportUtils") {
"ZipExportImportUtils", "Failed to import image: $originalFilename"
"Failed to import image: $originalFilename" }
)
} }
} else { } else {
android.util.Log.w( AppLogger.w("ZipExportImportUtils") {
"ZipExportImportUtils", "Extracted image is empty: $originalFilename"
"Extracted image is empty: $originalFilename" }
)
} }
// Clean up temp file // Clean up temp file
tempFile.delete() tempFile.delete()
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e( AppLogger.e("ZipExportImportUtils", e) {
"ZipExportImportUtils", "Failed to process image $originalFilename: ${e.message}"
"Failed to process image $originalFilename: ${e.message}" }
)
} }
} }
else -> { else -> {
android.util.Log.d( AppLogger.d("ZipExportImportUtils") {
"ZipExportImportUtils", "Skipping ZIP entry: ${entry.name}"
"Skipping ZIP entry: ${entry.name}" }
)
} }
} }
@@ -296,10 +290,9 @@ object ZipExportImportUtils {
throw IOException("Invalid ZIP file: data.json is empty") throw IOException("Invalid ZIP file: data.json is empty")
} }
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils", "Import extraction completed: ${importedImagePaths.size} images processed"
"Import extraction completed: ${importedImagePaths.size} images processed" }
)
return ImportResult(jsonContent, importedImagePaths) return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) { } catch (e: Exception) {
@@ -312,16 +305,16 @@ object ZipExportImportUtils {
* the new ones after import * the new ones after import
*/ */
fun updateProblemImagePaths( fun updateProblemImagePaths(
problems: List<BackupProblem>, problems: List<BackupProblem>,
imagePathMapping: Map<String, String> imagePathMapping: Map<String, String>
): List<BackupProblem> { ): List<BackupProblem> {
return problems.map { problem -> return problems.map { problem ->
val updatedImagePaths = val updatedImagePaths =
(problem.imagePaths ?: emptyList()).mapNotNull { oldPath -> (problem.imagePaths ?: emptyList()).mapNotNull { oldPath ->
// Extract filename from the old path // Extract filename from the old path
val filename = oldPath.substringAfterLast("/") val filename = oldPath.substringAfterLast("/")
imagePathMapping[filename] imagePathMapping[filename]
} }
problem.withUpdatedImagePaths(updatedImagePaths) problem.withUpdatedImagePaths(updatedImagePaths)
} }
} }

View File

@@ -25,13 +25,13 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^9.5.0", "@astrojs/node": "^9.5.1",
"@astrojs/starlight": "^0.36.1", "@astrojs/starlight": "^0.36.2",
"astro": "^5.14.6", "astro": "^5.16.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.34.4" "sharp": "^0.34.5"
}, },
"devDependencies": { "devDependencies": {
"@types/qrcode": "^1.5.5" "@types/qrcode": "^1.5.6"
} }
} }

1145
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@ final class LiveActivityManager {
pushType: nil pushType: nil
) )
} catch { } catch {
print("Failed to start live activity: \(error)") AppLogger.error("Failed to start live activity: \(error)", tag: "LegacyLiveActivityManager")
} }
} }

View File

@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -487,7 +487,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -513,7 +513,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -535,7 +535,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -602,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -613,7 +613,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -643,7 +643,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@@ -0,0 +1,33 @@
import AppIntents
/// Provides a curated list of the most useful Ascently shortcuts for Siri and the Shortcuts app.
/// Surfaces intents that users can trigger hands-free to manage their climbing sessions.
struct AscentlyShortcuts: AppShortcutsProvider {
static var shortcutTileColor: ShortcutTileColor {
.teal
}
static var appShortcuts: [AppShortcut] {
return [
AppShortcut(
intent: StartLastGymSessionIntent(),
phrases: [
"Start my climb in \(.applicationName)",
"Begin my last gym session in \(.applicationName)",
],
shortTitle: "Start Climb",
systemImageName: "figure.climbing"
),
AppShortcut(
intent: EndActiveSessionIntent(),
phrases: [
"Finish my climb in \(.applicationName)",
"End my session in \(.applicationName)",
],
shortTitle: "End Climb",
systemImageName: "flag.checkered"
),
]
}
}

View File

@@ -0,0 +1,40 @@
import AppIntents
import Foundation
/// Ends the currently active climbing session so logging stays in sync across devices.
/// Exposed to Shortcuts so users can wrap up a session without opening the app.
struct EndActiveSessionIntent: AppIntent {
static var title: LocalizedStringResource {
"End Active Session"
}
static var description: IntentDescription {
IntentDescription(
"Stop the active climbing session and save its progress in Ascently."
)
}
static var openAppWhenRun: Bool {
false
}
func perform() async throws -> some IntentResult & ProvidesDialog {
do {
let summary = try await SessionIntentController().endActiveSession()
let dialog = IntentDialog("Session at \(summary.gymName) ended. Nice work!")
return .result(dialog: dialog)
} catch SessionIntentError.noActiveSession {
// No active session is fine - just return a friendly message
let dialog = IntentDialog("No active session to end.")
return .result(dialog: dialog)
} catch {
// Re-throw other errors
throw error
}
}
static var parameterSummary: some ParameterSummary {
Summary("End my current climbing session")
}
}

View File

@@ -0,0 +1,95 @@
import Foundation
/// User-visible errors that can arise while handling session-related intents.
enum SessionIntentError: LocalizedError {
case noRecentGym
case noActiveSession
case failedToStartSession
case failedToEndSession
var errorDescription: String? {
switch self {
case .noRecentGym:
return "There's no recent gym to start a session with."
case .noActiveSession:
return "There isn't an active session to end right now."
case .failedToStartSession:
return "Ascently couldn't start a new session."
case .failedToEndSession:
return "Ascently couldn't finish the active session."
}
}
}
struct SessionIntentSummary: Sendable {
let sessionId: UUID
let gymName: String
let status: SessionStatus
}
/// Central controller that exposes the minimal climbing session operations used by App Intents and shortcuts.
@MainActor
final class SessionIntentController {
private let dataManager: ClimbingDataManager
init(dataManager: ClimbingDataManager = .shared) {
self.dataManager = dataManager
}
/// Starts a new session using the most recently visited gym.
func startSessionWithLastUsedGym() async throws -> SessionIntentSummary {
// Give a moment for data to be ready if app just launched
if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
}
guard let lastGym = dataManager.getLastUsedGym() else {
logFailure(.noRecentGym, context: "No recorded sessions available")
throw SessionIntentError.noRecentGym
}
guard let startedSession = await dataManager.startSessionAsync(gymId: lastGym.id) else {
logFailure(.failedToStartSession, context: "Data manager failed to create new session")
throw SessionIntentError.failedToStartSession
}
return SessionIntentSummary(
sessionId: startedSession.id,
gymName: lastGym.name,
status: startedSession.status
)
}
/// Ends the currently active climbing session, if one exists.
func endActiveSession() async throws -> SessionIntentSummary {
guard let activeSession = dataManager.activeSession else {
logFailure(.noActiveSession, context: "No active session stored in data manager")
throw SessionIntentError.noActiveSession
}
guard let completedSession = await dataManager.endSessionAsync(activeSession.id) else {
logFailure(
.failedToEndSession, context: "Data manager failed to complete active session")
throw SessionIntentError.failedToEndSession
}
guard let gym = dataManager.gym(withId: completedSession.gymId) else {
logFailure(
.failedToEndSession,
context: "Gym missing for completed session \(completedSession.id)")
throw SessionIntentError.failedToEndSession
}
return SessionIntentSummary(
sessionId: completedSession.id,
gymName: gym.name,
status: completedSession.status
)
}
private func logFailure(_ error: SessionIntentError, context: String) {
// Logging from intent context - errors are visible to user via dialog
print("SessionIntentError: \(error). Context: \(context)")
}
}

View File

@@ -0,0 +1,43 @@
import AppIntents
import Foundation
/// Starts a climbing session at the most recently visited gym.
/// Exposed to Shortcuts so users can begin logging without opening the app.
struct StartLastGymSessionIntent: AppIntent {
static var title: LocalizedStringResource {
"Start Last Gym Session"
}
static var description: IntentDescription {
IntentDescription(
"Begin a new climbing session using the most recent gym you visited in Ascently."
)
}
static var openAppWhenRun: Bool {
true
}
func perform() async throws -> some IntentResult & ProvidesDialog {
// Delay to ensure app has time to fully initialize if just launched
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
let summary = try await SessionIntentController().startSessionWithLastUsedGym()
// Give Live Activity extra time to start
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
return .result(
dialog: Self.successDialog(for: summary.gymName)
)
}
private static func successDialog(for gymName: String) -> IntentDialog {
IntentDialog("Session started at \(gymName). Have an awesome climb!")
}
static var parameterSummary: some ParameterSummary {
Summary("Start a session at my last gym")
}
}

View File

@@ -1,7 +1,19 @@
import SwiftUI import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
return true
}
}
@main @main
struct AscentlyApp: App { struct AscentlyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) private var scenePhase
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()

View File

@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@StateObject private var dataManager = ClimbingDataManager() @StateObject private var dataManager = ClimbingDataManager.shared
@State private var selectedTab = 0 @State private var selectedTab = 0
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var notificationObservers: [NSObjectProtocol] = [] @State private var notificationObservers: [NSObjectProtocol] = []
@@ -91,11 +91,12 @@ struct ContentView: View {
object: nil, object: nil,
queue: .main queue: .main
) { _ in ) { _ in
print("App will enter foreground - preparing Live Activity check") Task { @MainActor in
Task { AppLogger.info(
"App will enter foreground - preparing Live Activity check", tag: "Lifecycle")
// Small delay to ensure app is fully active // Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
await dataManager.onAppBecomeActive() dataManager.onAppBecomeActive()
// Re-verify health integration when returning from background // Re-verify health integration when returning from background
await dataManager.healthKitService.verifyAndRestoreIntegration() await dataManager.healthKitService.verifyAndRestoreIntegration()
} }
@@ -107,10 +108,11 @@ struct ContentView: View {
object: nil, object: nil,
queue: .main queue: .main
) { _ in ) { _ in
print("App did become active - checking Live Activity status") Task { @MainActor in
Task { AppLogger.info(
"App did become active - checking Live Activity status", tag: "Lifecycle")
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive() dataManager.onAppBecomeActive()
await dataManager.healthKitService.verifyAndRestoreIntegration() await dataManager.healthKitService.verifyAndRestoreIntegration()
} }
} }

View File

@@ -6,6 +6,7 @@
<true/> <true/>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library to add photos to climbing problems.</string> <string>This app needs access to your photo library to add photos to climbing problems.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>

View File

@@ -38,7 +38,7 @@ class HealthKitService: ObservableObject {
{ {
currentWorkoutStartDate = startDate currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId currentWorkoutSessionId = sessionId
print("HealthKit: Restored active workout from \(startDate)") AppLogger.info("HealthKit: Restored active workout from \(startDate)", tag: "HealthKit")
} }
} }
@@ -56,31 +56,34 @@ class HealthKitService: ObservableObject {
guard isEnabled else { return } guard isEnabled else { return }
guard HKHealthStore.isHealthDataAvailable() else { guard HKHealthStore.isHealthDataAvailable() else {
print("HealthKit: Device does not support HealthKit") AppLogger.warning("HealthKit: Device does not support HealthKit", tag: "HealthKit")
return return
} }
checkAuthorization() checkAuthorization()
if !isAuthorized { if !isAuthorized {
print( AppLogger.warning(
"HealthKit: Integration was enabled but authorization lost, attempting to restore..." "HealthKit: Integration was enabled but authorization lost, attempting to restore...",
) tag: "HealthKit")
do { do {
try await requestAuthorization() try await requestAuthorization()
print("HealthKit: Authorization restored successfully") AppLogger.info("HealthKit: Authorization restored successfully", tag: "HealthKit")
} catch { } catch {
print("HealthKit: Failed to restore authorization: \(error.localizedDescription)") AppLogger.error(
"HealthKit: Failed to restore authorization: \(error.localizedDescription)",
tag: "HealthKit")
} }
} else { } else {
print("HealthKit: Integration verified - authorization is valid") AppLogger.info(
"HealthKit: Integration verified - authorization is valid", tag: "HealthKit")
} }
if hasActiveWorkout() { if hasActiveWorkout() {
print( AppLogger.info(
"HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)" "HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)",
) tag: "HealthKit")
} }
} }
@@ -130,7 +133,7 @@ class HealthKitService: ObservableObject {
currentWorkoutStartDate = startDate currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId currentWorkoutSessionId = sessionId
persistActiveWorkout() persistActiveWorkout()
print("HealthKit: Started workout for session \(sessionId)") AppLogger.info("HealthKit: Started workout for session \(sessionId)", tag: "HealthKit")
} }
func endWorkout(endDate: Date) async throws { func endWorkout(endDate: Date) async throws {
@@ -178,15 +181,17 @@ class HealthKitService: ObservableObject {
try await builder.endCollection(at: endDate) try await builder.endCollection(at: endDate)
let workout = try await builder.finishWorkout() let workout = try await builder.finishWorkout()
print( AppLogger.info(
"HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")" "HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")",
) tag: "HealthKit")
currentWorkoutStartDate = nil currentWorkoutStartDate = nil
currentWorkoutSessionId = nil currentWorkoutSessionId = nil
persistActiveWorkout() persistActiveWorkout()
} catch { } catch {
print("HealthKit: Failed to save workout: \(error.localizedDescription)") AppLogger.error(
"HealthKit: Failed to save workout: \(error.localizedDescription)", tag: "HealthKit"
)
currentWorkoutStartDate = nil currentWorkoutStartDate = nil
currentWorkoutSessionId = nil currentWorkoutSessionId = nil
persistActiveWorkout() persistActiveWorkout()
@@ -199,7 +204,7 @@ class HealthKitService: ObservableObject {
currentWorkoutStartDate = nil currentWorkoutStartDate = nil
currentWorkoutSessionId = nil currentWorkoutSessionId = nil
persistActiveWorkout() persistActiveWorkout()
print("HealthKit: Workout cancelled") AppLogger.info("HealthKit: Workout cancelled", tag: "HealthKit")
} }
func hasActiveWorkout() -> Bool { func hasActiveWorkout() -> Bool {

View File

@@ -12,10 +12,27 @@ class SyncService: ObservableObject {
@Published var isOfflineMode = false @Published var isOfflineMode = false
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let logTag = "SyncService"
private var syncTask: Task<Void, Never>? private var syncTask: Task<Void, Never>?
private var pendingChanges = false private var pendingChanges = false
private let syncDebounceDelay: TimeInterval = 2.0 private let syncDebounceDelay: TimeInterval = 2.0
private func logDebug(_ message: @autoclosure () -> String) {
AppLogger.debug(message(), tag: logTag)
}
private func logInfo(_ message: @autoclosure () -> String) {
AppLogger.info(message(), tag: logTag)
}
private func logWarning(_ message: @autoclosure () -> String) {
AppLogger.warning(message(), tag: logTag)
}
private func logError(_ message: @autoclosure () -> String) {
AppLogger.error(message(), tag: logTag)
}
private enum Keys { private enum Keys {
static let serverURL = "sync_server_url" static let serverURL = "sync_server_url"
static let authToken = "sync_auth_token" static let authToken = "sync_auth_token"
@@ -201,7 +218,7 @@ class SyncService: ObservableObject {
return false return false
} }
print( logInfo(
"iOS DELTA SYNC: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)" "iOS DELTA SYNC: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)"
) )
@@ -244,7 +261,7 @@ class SyncService: ObservableObject {
let decoder = JSONDecoder() let decoder = JSONDecoder()
let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data) let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data)
print( logInfo(
"iOS DELTA SYNC: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count), deletions=\(deltaResponse.deletedItems.count)" "iOS DELTA SYNC: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count), deletions=\(deltaResponse.deletedItems.count)"
) )
@@ -270,7 +287,7 @@ class SyncService: ObservableObject {
let allDeletions = dataManager.getDeletedItems() + response.deletedItems let allDeletions = dataManager.getDeletedItems() + response.deletedItems
let uniqueDeletions = Array(Set(allDeletions)) let uniqueDeletions = Array(Set(allDeletions))
print( logInfo(
"iOS DELTA SYNC: Applying \(uniqueDeletions.count) deletion records before merging data" "iOS DELTA SYNC: Applying \(uniqueDeletions.count) deletion records before merging data"
) )
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager) applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
@@ -298,10 +315,10 @@ class SyncService: ObservableObject {
_ = try imageManager.saveImportedImage(imageData, filename: consistentFilename) _ = try imageManager.saveImportedImage(imageData, filename: consistentFilename)
imagePathMapping[serverFilename] = consistentFilename imagePathMapping[serverFilename] = consistentFilename
} catch SyncError.imageNotFound { } catch SyncError.imageNotFound {
print("Image not found on server: \(serverFilename)") logInfo("Image not found on server: \(serverFilename)")
continue continue
} catch { } catch {
print("Failed to download image \(serverFilename): \(error)") logInfo("Failed to download image \(serverFilename): \(error)")
continue continue
} }
} }
@@ -436,7 +453,7 @@ class SyncService: ObservableObject {
) async throws { ) async throws {
guard !modifiedProblems.isEmpty else { return } guard !modifiedProblems.isEmpty else { return }
print("iOS DELTA SYNC: Syncing images for \(modifiedProblems.count) modified problems") logInfo("iOS DELTA SYNC: Syncing images for \(modifiedProblems.count) modified problems")
for backupProblem in modifiedProblems { for backupProblem in modifiedProblems {
guard guard
@@ -465,9 +482,9 @@ class SyncService: ObservableObject {
} }
try await uploadImage(filename: consistentFilename, imageData: imageData) try await uploadImage(filename: consistentFilename, imageData: imageData)
print("Uploaded modified problem image: \(consistentFilename)") logInfo("Uploaded modified problem image: \(consistentFilename)")
} catch { } catch {
print("Failed to upload image \(consistentFilename): \(error)") logInfo("Failed to upload image \(consistentFilename): \(error)")
} }
} }
} }
@@ -549,7 +566,7 @@ class SyncService: ObservableObject {
func syncWithServer(dataManager: ClimbingDataManager) async throws { func syncWithServer(dataManager: ClimbingDataManager) async throws {
if isOfflineMode { if isOfflineMode {
print("Sync skipped: Offline mode is enabled.") logInfo("Sync skipped: Offline mode is enabled.")
return return
} }
@@ -586,7 +603,7 @@ class SyncService: ObservableObject {
// If both client and server have been synced before, use delta sync // If both client and server have been synced before, use delta sync
if hasLocalData && hasServerData && lastSyncTime != nil { if hasLocalData && hasServerData && lastSyncTime != nil {
print("iOS SYNC: Using delta sync for incremental updates") logInfo("iOS SYNC: Using delta sync for incremental updates")
try await performDeltaSync(dataManager: dataManager) try await performDeltaSync(dataManager: dataManager)
// Update last sync time // Update last sync time
@@ -597,32 +614,32 @@ class SyncService: ObservableObject {
if !hasLocalData && hasServerData { if !hasLocalData && hasServerData {
// Case 1: No local data - do full restore from server // Case 1: No local data - do full restore from server
print("iOS SYNC: Case 1 - No local data, performing full restore from server") logInfo("iOS SYNC: Case 1 - No local data, performing full restore from server")
print("Syncing images from server first...") logInfo("Syncing images from server first...")
let imagePathMapping = try await syncImagesFromServer( let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager) backup: serverBackup, dataManager: dataManager)
print("Importing data after images...") logInfo("Importing data after images...")
try importBackupToDataManager( try importBackupToDataManager(
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping) serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
print("Full restore completed") logInfo("Full restore completed")
} else if hasLocalData && !hasServerData { } else if hasLocalData && !hasServerData {
// Case 2: No server data - upload local data to server // Case 2: No server data - upload local data to server
print("iOS SYNC: Case 2 - No server data, uploading local data to server") logInfo("iOS SYNC: Case 2 - No server data, uploading local data to server")
let currentBackup = createBackupFromDataManager(dataManager) let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup) _ = try await uploadData(currentBackup)
print("Uploading local images to server...") logInfo("Uploading local images to server...")
try await syncImagesToServer(dataManager: dataManager) try await syncImagesToServer(dataManager: dataManager)
print("Initial upload completed") logInfo("Initial upload completed")
} else if hasLocalData && hasServerData { } else if hasLocalData && hasServerData {
// Case 3: Both have data - use safe merge strategy // Case 3: Both have data - use safe merge strategy
print("iOS SYNC: Case 3 - Merging local and server data safely") logInfo("iOS SYNC: Case 3 - Merging local and server data safely")
try await mergeDataSafely( try await mergeDataSafely(
localBackup: localBackup, localBackup: localBackup,
serverBackup: serverBackup, serverBackup: serverBackup,
dataManager: dataManager) dataManager: dataManager)
print("Safe merge completed") logInfo("Safe merge completed")
} else { } else {
print("No data to sync") logInfo("No data to sync")
} }
// Update last sync time // Update last sync time
@@ -640,7 +657,7 @@ class SyncService: ObservableObject {
if let date = formatter.date(from: timestamp) { if let date = formatter.date(from: timestamp) {
return Int64(date.timeIntervalSince1970 * 1000) return Int64(date.timeIntervalSince1970 * 1000)
} }
print("Failed to parse timestamp: \(timestamp), using 0") logInfo("Failed to parse timestamp: \(timestamp), using 0")
return 0 return 0
} }
@@ -666,12 +683,12 @@ class SyncService: ObservableObject {
imageData, filename: consistentFilename) imageData, filename: consistentFilename)
imagePathMapping[serverFilename] = consistentFilename imagePathMapping[serverFilename] = consistentFilename
print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)") logInfo("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)")
} catch SyncError.imageNotFound { } catch SyncError.imageNotFound {
print("Image not found on server: \(serverFilename)") logInfo("Image not found on server: \(serverFilename)")
continue continue
} catch { } catch {
print("Failed to download image \(serverFilename): \(error)") logInfo("Failed to download image \(serverFilename): \(error)")
continue continue
} }
} }
@@ -704,18 +721,18 @@ class SyncService: ObservableObject {
).path ).path
do { do {
try FileManager.default.moveItem(atPath: fullPath, toPath: newPath) try FileManager.default.moveItem(atPath: fullPath, toPath: newPath)
print("Renamed local image: \(filename) -> \(consistentFilename)") logInfo("Renamed local image: \(filename) -> \(consistentFilename)")
// Update problem's image path in memory for consistency // Update problem's image path in memory for consistency
} catch { } catch {
print("Failed to rename local image, using original: \(error)") logInfo("Failed to rename local image, using original: \(error)")
} }
} }
try await uploadImage(filename: consistentFilename, imageData: imageData) try await uploadImage(filename: consistentFilename, imageData: imageData)
print("Successfully uploaded image: \(consistentFilename)") logInfo("Successfully uploaded image: \(consistentFilename)")
} catch { } catch {
print("Failed to upload image \(consistentFilename): \(error)") logInfo("Failed to upload image \(consistentFilename): \(error)")
// Continue with other images even if one fails // Continue with other images even if one fails
} }
} }
@@ -733,7 +750,7 @@ class SyncService: ObservableObject {
!activeSessionIds.contains($0.sessionId) !activeSessionIds.contains($0.sessionId)
} }
print( logInfo(
"iOS SYNC: Excluding \(dataManager.sessions.count - completedSessions.count) active sessions and \(dataManager.attempts.count - completedAttempts.count) active session attempts from sync" "iOS SYNC: Excluding \(dataManager.sessions.count - completedSessions.count) active sessions and \(dataManager.attempts.count - completedAttempts.count) active session attempts from sync"
) )
@@ -808,26 +825,26 @@ class SyncService: ObservableObject {
let allDeletions = localDeletions + serverBackup.deletedItems let allDeletions = localDeletions + serverBackup.deletedItems
let uniqueDeletions = Array(Set(allDeletions)) let uniqueDeletions = Array(Set(allDeletions))
print("Merging gyms...") logInfo("Merging gyms...")
let mergedGyms = mergeGyms( let mergedGyms = mergeGyms(
local: dataManager.gyms, local: dataManager.gyms,
server: serverBackup.gyms, server: serverBackup.gyms,
deletedItems: uniqueDeletions) deletedItems: uniqueDeletions)
print("Merging problems...") logInfo("Merging problems...")
let mergedProblems = try mergeProblems( let mergedProblems = try mergeProblems(
local: dataManager.problems, local: dataManager.problems,
server: serverBackup.problems, server: serverBackup.problems,
imagePathMapping: imagePathMapping, imagePathMapping: imagePathMapping,
deletedItems: uniqueDeletions) deletedItems: uniqueDeletions)
print("Merging sessions...") logInfo("Merging sessions...")
let mergedSessions = try mergeSessions( let mergedSessions = try mergeSessions(
local: dataManager.sessions, local: dataManager.sessions,
server: serverBackup.sessions, server: serverBackup.sessions,
deletedItems: uniqueDeletions) deletedItems: uniqueDeletions)
print("Merging attempts...") logInfo("Merging attempts...")
let mergedAttempts = try mergeAttempts( let mergedAttempts = try mergeAttempts(
local: dataManager.attempts, local: dataManager.attempts,
server: serverBackup.attempts, server: serverBackup.attempts,
@@ -887,7 +904,7 @@ class SyncService: ObservableObject {
&& !allDeletedAttemptIds.contains($0.id.uuidString) && !allDeletedAttemptIds.contains($0.id.uuidString)
} }
print( logInfo(
"iOS IMPORT: Preserving \(activeSessions.count) active sessions and \(activeAttempts.count) active attempts during import" "iOS IMPORT: Preserving \(activeSessions.count) active sessions and \(activeAttempts.count) active attempts during import"
) )
@@ -977,7 +994,7 @@ class SyncService: ObservableObject {
// Restore active sessions and their attempts after import // Restore active sessions and their attempts after import
for session in activeSessions { for session in activeSessions {
print("iOS IMPORT: Restoring active session: \(session.id)") logInfo("iOS IMPORT: Restoring active session: \(session.id)")
dataManager.sessions.append(session) dataManager.sessions.append(session)
if session.id == dataManager.activeSession?.id { if session.id == dataManager.activeSession?.id {
dataManager.activeSession = session dataManager.activeSession = session
@@ -997,12 +1014,12 @@ class SyncService: ObservableObject {
dataManager.clearDeletedItems() dataManager.clearDeletedItems()
if let data = try? JSONEncoder().encode(backup.deletedItems) { if let data = try? JSONEncoder().encode(backup.deletedItems) {
UserDefaults.standard.set(data, forKey: "ascently_deleted_items") UserDefaults.standard.set(data, forKey: "ascently_deleted_items")
print("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records") logInfo("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records")
} }
// Update local data state to match imported data timestamp // Update local data state to match imported data timestamp
DataStateManager.shared.setLastModified(backup.exportedAt) DataStateManager.shared.setLastModified(backup.exportedAt)
print("Data state synchronized to imported timestamp: \(backup.exportedAt)") logInfo("Data state synchronized to imported timestamp: \(backup.exportedAt)")
} catch { } catch {
throw SyncError.importFailed(error) throw SyncError.importFailed(error)

View File

@@ -0,0 +1,46 @@
import Foundation
/// Centralized logging utility for the iOS app.
///
/// All log output is automatically compiled out in non-debug builds to avoid leaking
/// sensitive information. Use this instead of calling `print` directly.
enum AppLogger {
enum LogLevel: String {
case debug = "DEBUG"
case info = "INFO"
case warning = "WARN"
case error = "ERROR"
}
static func debug(_ message: @autoclosure () -> String, tag: String = #fileID) {
log(level: .debug, tag: tag, message: message())
}
static func info(_ message: @autoclosure () -> String, tag: String = #fileID) {
log(level: .info, tag: tag, message: message())
}
static func warning(_ message: @autoclosure () -> String, tag: String = #fileID) {
log(level: .warning, tag: tag, message: message())
}
static func error(_ message: @autoclosure () -> String, tag: String = #fileID) {
log(level: .error, tag: tag, message: message())
}
static func log(level: LogLevel, tag: String, message: @autoclosure () -> String) {
#if DEBUG
let lastPath = (tag as NSString).lastPathComponent
let resolvedTag = lastPath.isEmpty ? tag : lastPath
Swift.print("[\(level.rawValue)][\(resolvedTag)] \(message())")
#endif
}
}
enum LogTag {
static let climbingData = "ClimbingData"
static let dataManagement = "DataManagementSection"
static let exportData = "ExportDataView"
static let syncSection = "SyncSection"
}

View File

@@ -18,14 +18,17 @@ class DataStateManager {
private init() { private init() {
// Initialize with current timestamp if this is the first time // Initialize with current timestamp if this is the first time
if !isInitialized() { if !isInitialized() {
print("DataStateManager: First time initialization") AppLogger.info("DataStateManager: First time initialization", tag: "DataState")
// Set initial timestamp to a very old date so server data will be considered newer // Set initial timestamp to a very old date so server data will be considered newer
let epochTime = "1970-01-01T00:00:00.000Z" let epochTime = "1970-01-01T00:00:00.000Z"
userDefaults.set(epochTime, forKey: Keys.lastModified) userDefaults.set(epochTime, forKey: Keys.lastModified)
markAsInitialized() markAsInitialized()
print("DataStateManager initialized with epoch timestamp: \(epochTime)") AppLogger.info(
"DataStateManager initialized with epoch timestamp: \(epochTime)", tag: "DataState")
} else { } else {
print("DataStateManager: Already initialized, current timestamp: \(getLastModified())") AppLogger.info(
"DataStateManager: Already initialized, current timestamp: \(getLastModified())",
tag: "DataState")
} }
} }
@@ -34,29 +37,32 @@ class DataStateManager {
func updateDataState() { func updateDataState() {
let now = ISO8601DateFormatter().string(from: Date()) let now = ISO8601DateFormatter().string(from: Date())
userDefaults.set(now, forKey: Keys.lastModified) userDefaults.set(now, forKey: Keys.lastModified)
print("iOS Data state updated to: \(now)") AppLogger.info("iOS Data state updated to: \(now)", tag: "DataState")
} }
func getLastModified() -> String { func getLastModified() -> String {
if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) { if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) {
print("iOS DataStateManager returning stored timestamp: \(storedTimestamp)") AppLogger.debug(
"iOS DataStateManager returning stored timestamp: \(storedTimestamp)",
tag: "DataState")
return storedTimestamp return storedTimestamp
} }
let epochTime = "1970-01-01T00:00:00.000Z" let epochTime = "1970-01-01T00:00:00.000Z"
print("No data state timestamp found - returning epoch time: \(epochTime)") AppLogger.warning(
"No data state timestamp found - returning epoch time: \(epochTime)", tag: "DataState")
return epochTime return epochTime
} }
func setLastModified(_ timestamp: String) { func setLastModified(_ timestamp: String) {
userDefaults.set(timestamp, forKey: Keys.lastModified) userDefaults.set(timestamp, forKey: Keys.lastModified)
print("Data state set to: \(timestamp)") AppLogger.info("Data state set to: \(timestamp)", tag: "DataState")
} }
func reset() { func reset() {
userDefaults.removeObject(forKey: Keys.lastModified) userDefaults.removeObject(forKey: Keys.lastModified)
userDefaults.removeObject(forKey: Keys.initialized) userDefaults.removeObject(forKey: Keys.initialized)
print("Data state reset") AppLogger.info("Data state reset", tag: "DataState")
} }
private func isInitialized() -> Bool { private func isInitialized() -> Bool {

View File

@@ -5,6 +5,7 @@ import UIKit
class ImageManager { class ImageManager {
static let shared = ImageManager() static let shared = ImageManager()
private let logTag = "ImageManager"
private let thumbnailCache = NSCache<NSString, UIImage>() private let thumbnailCache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default private let fileManager = FileManager.default
@@ -30,7 +31,7 @@ class ImageManager {
// Final integrity check // Final integrity check
if !validateStorageIntegrity() { if !validateStorageIntegrity() {
print("CRITICAL: Storage integrity compromised - attempting emergency recovery") logError("CRITICAL: Storage integrity compromised - attempting emergency recovery")
emergencyImageRestore() emergencyImageRestore()
} }
@@ -83,7 +84,7 @@ class ImageManager {
return return
} }
print("🔄 Migrating images from OpenClimb to Ascently directory...") logInfo("🔄 Migrating images from OpenClimb to Ascently directory...")
do { do {
// Create parent directory if needed // Create parent directory if needed
@@ -94,16 +95,16 @@ class ImageManager {
// Move the entire directory // Move the entire directory
try fileManager.moveItem(at: legacyDir, to: appSupportDirectory) try fileManager.moveItem(at: legacyDir, to: appSupportDirectory)
print("Successfully migrated image directory from OpenClimb to Ascently") logInfo("Successfully migrated image directory from OpenClimb to Ascently")
} catch { } catch {
print("Failed to migrate image directory: \(error)") logError("Failed to migrate image directory: \(error)")
// If move fails, try to copy instead // If move fails, try to copy instead
do { do {
try fileManager.copyItem(at: legacyDir, to: appSupportDirectory) try fileManager.copyItem(at: legacyDir, to: appSupportDirectory)
print("Successfully copied image directory from OpenClimb to Ascently") logInfo("Successfully copied image directory from OpenClimb to Ascently")
// Don't remove the old directory in case of issues // Don't remove the old directory in case of issues
} catch { } catch {
print("Failed to copy image directory: \(error)") logError("Failed to copy image directory: \(error)")
} }
} }
} }
@@ -122,9 +123,9 @@ class ImageManager {
attributes: [ attributes: [
.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication
]) ])
print("Created directory: \(directory.path)") logInfo("Created directory: \(directory.path)")
} catch { } catch {
print("ERROR: Failed to create directory \(directory.path): \(error)") logError("ERROR: Failed to create directory \(directory.path): \(error)")
} }
} }
} }
@@ -141,9 +142,9 @@ class ImageManager {
var backupURL = backupDirectory var backupURL = backupDirectory
try imagesURL.setResourceValues(resourceValues) try imagesURL.setResourceValues(resourceValues)
try backupURL.setResourceValues(resourceValues) try backupURL.setResourceValues(resourceValues)
print("Excluded image directories from iCloud backup") logInfo("Excluded image directories from iCloud backup")
} catch { } catch {
print("WARNING: Failed to exclude from iCloud backup: \(error)") logWarning("WARNING: Failed to exclude from iCloud backup: \(error)")
} }
} }
@@ -167,11 +168,11 @@ class ImageManager {
} }
private func performRobustMigration() { private func performRobustMigration() {
print("Starting robust image migration system...") logInfo("Starting robust image migration system...")
// Check for interrupted migration // Check for interrupted migration
if let incompleteState = loadMigrationState() { if let incompleteState = loadMigrationState() {
print("Detected interrupted migration, resuming...") logInfo("Detected interrupted migration, resuming...")
resumeMigration(from: incompleteState) resumeMigration(from: incompleteState)
} else { } else {
// Start fresh migration // Start fresh migration
@@ -188,7 +189,7 @@ class ImageManager {
private func startNewMigration() { private func startNewMigration() {
// First check for images in previous Application Support directories // First check for images in previous Application Support directories
if let previousAppSupportImages = findPreviousAppSupportImages() { if let previousAppSupportImages = findPreviousAppSupportImages() {
print("Found images in previous Application Support directory") logInfo("Found images in previous Application Support directory")
migratePreviousAppSupportImages(from: previousAppSupportImages) migratePreviousAppSupportImages(from: previousAppSupportImages)
return return
} }
@@ -198,7 +199,7 @@ class ImageManager {
let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path) let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
guard hasLegacyImages || hasLegacyImportImages else { guard hasLegacyImages || hasLegacyImportImages else {
print("No legacy images to migrate") logInfo("No legacy images to migrate")
return return
} }
@@ -213,7 +214,7 @@ class ImageManager {
let legacyFiles = try fileManager.contentsOfDirectory( let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path) atPath: legacyImagesDirectory.path)
allLegacyFiles.append(contentsOf: legacyFiles) allLegacyFiles.append(contentsOf: legacyFiles)
print("Found \(legacyFiles.count) images in OpenClimbImages") logInfo("Found \(legacyFiles.count) images in OpenClimbImages")
} }
// Collect files from Documents/images directory // Collect files from Documents/images directory
@@ -221,10 +222,10 @@ class ImageManager {
let importFiles = try fileManager.contentsOfDirectory( let importFiles = try fileManager.contentsOfDirectory(
atPath: legacyImportImagesDirectory.path) atPath: legacyImportImagesDirectory.path)
allLegacyFiles.append(contentsOf: importFiles) allLegacyFiles.append(contentsOf: importFiles)
print("Found \(importFiles.count) images in Documents/images") logInfo("Found \(importFiles.count) images in Documents/images")
} }
print("Total legacy images to migrate: \(allLegacyFiles.count)") logInfo("Total legacy images to migrate: \(allLegacyFiles.count)")
let initialState = MigrationState( let initialState = MigrationState(
version: MigrationState.currentVersion, version: MigrationState.currentVersion,
@@ -239,24 +240,24 @@ class ImageManager {
performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState) performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState)
} catch { } catch {
print("ERROR: Failed to start migration: \(error)") logError("ERROR: Failed to start migration: \(error)")
} }
} }
private func resumeMigration(from state: MigrationState) { private func resumeMigration(from state: MigrationState) {
print("Resuming migration from checkpoint...") logInfo("Resuming migration from checkpoint...")
print("Progress: \(state.completedFiles.count)/\(state.totalFiles)") logInfo("Progress: \(state.completedFiles.count)/\(state.totalFiles)")
do { do {
let legacyFiles = try fileManager.contentsOfDirectory( let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path) atPath: legacyImagesDirectory.path)
let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) } let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) }
print("Resuming with \(remainingFiles.count) remaining files") logInfo("Resuming with \(remainingFiles.count) remaining files")
performMigrationWithCheckpoints(files: remainingFiles, currentState: state) performMigrationWithCheckpoints(files: remainingFiles, currentState: state)
} catch { } catch {
print("ERROR: Failed to resume migration: \(error)") logError("ERROR: Failed to resume migration: \(error)")
// Fallback: start fresh // Fallback: start fresh
removeMigrationState() removeMigrationState()
startNewMigration() startNewMigration()
@@ -323,11 +324,11 @@ class ImageManager {
completedFiles.append(fileName) completedFiles.append(fileName)
migratedCount += 1 migratedCount += 1
print("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))") logInfo("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))")
} catch { } catch {
failedCount += 1 failedCount += 1
print("ERROR: Failed to migrate \(fileName): \(error)") logError("ERROR: Failed to migrate \(fileName): \(error)")
} }
// Save checkpoint every 5 files or if interrupted // Save checkpoint every 5 files or if interrupted
@@ -341,7 +342,7 @@ class ImageManager {
lastCheckpoint: Date() lastCheckpoint: Date()
) )
saveMigrationState(checkpointState) saveMigrationState(checkpointState)
print("Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)") logInfo("Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)")
} }
} }
} }
@@ -357,7 +358,7 @@ class ImageManager {
) )
saveMigrationState(finalState) saveMigrationState(finalState)
print("Migration complete: \(migratedCount) migrated, \(failedCount) failed") logInfo("Migration complete: \(migratedCount) migrated, \(failedCount) failed")
// Clean up legacy directory if no failures // Clean up legacy directory if no failures
if failedCount == 0 { if failedCount == 0 {
@@ -366,7 +367,7 @@ class ImageManager {
} }
private func verifyMigrationIntegrity() { private func verifyMigrationIntegrity() {
print("Verifying migration integrity...") logInfo("Verifying migration integrity...")
var allLegacyFiles = Set<String>() var allLegacyFiles = Set<String>()
@@ -384,12 +385,12 @@ class ImageManager {
allLegacyFiles.formUnion(importFiles) allLegacyFiles.formUnion(importFiles)
} }
} catch { } catch {
print("ERROR: Failed to read legacy directories: \(error)") logError("ERROR: Failed to read legacy directories: \(error)")
return return
} }
guard !allLegacyFiles.isEmpty else { guard !allLegacyFiles.isEmpty else {
print("No legacy directories to verify against") logInfo("No legacy directories to verify against")
return return
} }
@@ -400,10 +401,10 @@ class ImageManager {
let missingFiles = allLegacyFiles.subtracting(migratedFiles) let missingFiles = allLegacyFiles.subtracting(migratedFiles)
if missingFiles.isEmpty { if missingFiles.isEmpty {
print("Migration integrity verified - all files present") logInfo("Migration integrity verified - all files present")
cleanupLegacyDirectory() cleanupLegacyDirectory()
} else { } else {
print("WARNING: Missing \(missingFiles.count) files, re-triggering migration") logWarning("WARNING: Missing \(missingFiles.count) files, re-triggering migration")
// Re-trigger migration for missing files // Re-trigger migration for missing files
performMigrationWithCheckpoints( performMigrationWithCheckpoints(
files: Array(missingFiles), files: Array(missingFiles),
@@ -417,16 +418,16 @@ class ImageManager {
)) ))
} }
} catch { } catch {
print("ERROR: Failed to verify migration integrity: \(error)") logError("ERROR: Failed to verify migration integrity: \(error)")
} }
} }
private func cleanupLegacyDirectory() { private func cleanupLegacyDirectory() {
do { do {
try fileManager.removeItem(at: legacyImagesDirectory) try fileManager.removeItem(at: legacyImagesDirectory)
print("Cleaned up legacy directory") logInfo("Cleaned up legacy directory")
} catch { } catch {
print("WARNING: Failed to clean up legacy directory: \(error)") logWarning("WARNING: Failed to clean up legacy directory: \(error)")
} }
} }
@@ -446,16 +447,16 @@ class ImageManager {
let data = try Data(contentsOf: migrationStateURL) let data = try Data(contentsOf: migrationStateURL)
let state = try JSONDecoder().decode(MigrationState.self, from: data) let state = try JSONDecoder().decode(MigrationState.self, from: data)
// Check if state is too old (more than 1 hour) // Check if state is too old
if Date().timeIntervalSince(state.lastCheckpoint) > 3600 { if Date().timeIntervalSince(state.lastCheckpoint) > 3600 {
print("WARNING: Migration state is stale, starting fresh") logWarning("WARNING: Migration state is stale, starting fresh")
removeMigrationState() removeMigrationState()
return nil return nil
} }
return state.isComplete ? nil : state return state.isComplete ? nil : state
} catch { } catch {
print("ERROR: Failed to load migration state: \(error)") logError("ERROR: Failed to load migration state: \(error)")
removeMigrationState() removeMigrationState()
return nil return nil
} }
@@ -466,7 +467,7 @@ class ImageManager {
let data = try JSONEncoder().encode(state) let data = try JSONEncoder().encode(state)
try data.write(to: migrationStateURL) try data.write(to: migrationStateURL)
} catch { } catch {
print("ERROR: Failed to save migration state: \(error)") logError("ERROR: Failed to save migration state: \(error)")
} }
} }
@@ -482,7 +483,7 @@ class ImageManager {
private func cleanupMigrationState() { private func cleanupMigrationState() {
try? fileManager.removeItem(at: migrationStateURL) try? fileManager.removeItem(at: migrationStateURL)
try? fileManager.removeItem(at: migrationLockURL) try? fileManager.removeItem(at: migrationLockURL)
print("Cleaned up migration state files") logInfo("Cleaned up migration state files")
} }
func saveImageData(_ data: Data, withName name: String? = nil) -> String? { func saveImageData(_ data: Data, withName name: String? = nil) -> String? {
@@ -497,10 +498,10 @@ class ImageManager {
// Create backup copy // Create backup copy
try data.write(to: backupPath) try data.write(to: backupPath)
print("Saved image with backup: \(fileName)") logInfo("Saved image with backup: \(fileName)")
return fileName return fileName
} catch { } catch {
print("ERROR: Failed to save image \(fileName): \(error)") logError("ERROR: Failed to save image \(fileName): \(error)")
return nil return nil
} }
} }
@@ -520,7 +521,7 @@ class ImageManager {
if fileManager.fileExists(atPath: backupPath.path), if fileManager.fileExists(atPath: backupPath.path),
let data = try? Data(contentsOf: backupPath) let data = try? Data(contentsOf: backupPath)
{ {
print("Restored image from backup: \(path)") logInfo("Restored image from backup: \(path)")
// Restore to primary location // Restore to primary location
try? data.write(to: URL(fileURLWithPath: primaryPath)) try? data.write(to: URL(fileURLWithPath: primaryPath))
@@ -595,7 +596,7 @@ class ImageManager {
do { do {
try fileManager.removeItem(atPath: primaryPath) try fileManager.removeItem(atPath: primaryPath)
} catch { } catch {
print("ERROR: Failed to delete primary image at \(primaryPath): \(error)") logError("ERROR: Failed to delete primary image at \(primaryPath): \(error)")
success = false success = false
} }
} }
@@ -605,7 +606,7 @@ class ImageManager {
do { do {
try fileManager.removeItem(at: backupPath) try fileManager.removeItem(at: backupPath)
} catch { } catch {
print("ERROR: Failed to delete backup image at \(backupPath.path): \(error)") logError("ERROR: Failed to delete backup image at \(backupPath.path): \(error)")
success = false success = false
} }
} }
@@ -642,7 +643,7 @@ class ImageManager {
} }
func performMaintenance() { func performMaintenance() {
print("Starting image maintenance...") logInfo("Starting image maintenance...")
syncBackups() syncBackups()
validateImageIntegrity() validateImageIntegrity()
@@ -660,11 +661,11 @@ class ImageManager {
let backupPath = backupDirectory.appendingPathComponent(fileName) let backupPath = backupDirectory.appendingPathComponent(fileName)
try? fileManager.copyItem(at: primaryPath, to: backupPath) try? fileManager.copyItem(at: primaryPath, to: backupPath)
print("Created missing backup for: \(fileName)") logInfo("Created missing backup for: \(fileName)")
} }
} }
} catch { } catch {
print("ERROR: Failed to sync backups: \(error)") logError("ERROR: Failed to sync backups: \(error)")
} }
} }
@@ -683,14 +684,14 @@ class ImageManager {
} }
} }
print("Validated \(validFiles) of \(files.count) image files") logInfo("Validated \(validFiles) of \(files.count) image files")
} catch { } catch {
print("ERROR: Failed to validate images: \(error)") logError("ERROR: Failed to validate images: \(error)")
} }
} }
private func cleanupOrphanedFiles() { private func cleanupOrphanedFiles() {
print("Cleanup would require coordination with data manager") logInfo("Cleanup would require coordination with data manager")
} }
func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) { func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) {
@@ -718,7 +719,7 @@ class ImageManager {
private func logDirectoryInfo() { private func logDirectoryInfo() {
let info = getStorageInfo() let info = getStorageInfo()
let previousDir = findPreviousAppSupportImages() let previousDir = findPreviousAppSupportImages()
print( logInfo(
""" """
Ascently Image Storage: Ascently Image Storage:
- App Support: \(appSupportDirectory.path) - App Support: \(appSupportDirectory.path)
@@ -732,7 +733,7 @@ class ImageManager {
} }
func forceRecoveryMigration() { func forceRecoveryMigration() {
print("FORCE RECOVERY: Starting manual migration recovery...") logInfo("FORCE RECOVERY: Starting manual migration recovery...")
// Remove any stale state // Remove any stale state
removeMigrationState() removeMigrationState()
@@ -741,7 +742,7 @@ class ImageManager {
// Force fresh migration // Force fresh migration
startNewMigration() startNewMigration()
print("FORCE RECOVERY: Migration recovery completed") logInfo("FORCE RECOVERY: Migration recovery completed")
} }
func saveImportedImage(_ imageData: Data, filename: String) throws -> String { func saveImportedImage(_ imageData: Data, filename: String) throws -> String {
@@ -754,12 +755,12 @@ class ImageManager {
// Create backup // Create backup
try? imageData.write(to: backupPath) try? imageData.write(to: backupPath)
print("Imported image: \(filename)") logInfo("Imported image: \(filename)")
return filename return filename
} }
func emergencyImageRestore() { func emergencyImageRestore() {
print("EMERGENCY: Attempting image restoration...") logError("EMERGENCY: Attempting image restoration...")
// Try to restore from backup directory // Try to restore from backup directory
do { do {
@@ -777,14 +778,14 @@ class ImageManager {
} }
} }
print("EMERGENCY: Restored \(restoredCount) images from backup") logError("EMERGENCY: Restored \(restoredCount) images from backup")
} catch { } catch {
print("EMERGENCY: Failed to restore from backup: \(error)") logError("EMERGENCY: Failed to restore from backup: \(error)")
} }
// Try previous Application Support directories first // Try previous Application Support directories first
if let previousAppSupportImages = findPreviousAppSupportImages() { if let previousAppSupportImages = findPreviousAppSupportImages() {
print("EMERGENCY: Found previous Application Support images, migrating...") logError("EMERGENCY: Found previous Application Support images, migrating...")
migratePreviousAppSupportImages(from: previousAppSupportImages) migratePreviousAppSupportImages(from: previousAppSupportImages)
return return
} }
@@ -793,23 +794,21 @@ class ImageManager {
if fileManager.fileExists(atPath: legacyImagesDirectory.path) if fileManager.fileExists(atPath: legacyImagesDirectory.path)
|| fileManager.fileExists(atPath: legacyImportImagesDirectory.path) || fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
{ {
print("EMERGENCY: Attempting legacy migration as fallback...") logError("EMERGENCY: Attempting legacy migration as fallback...")
forceRecoveryMigration() forceRecoveryMigration()
} }
} }
func debugSafeInitialization() -> Bool { func debugSafeInitialization() -> Bool {
print("DEBUG SAFE: Performing debug-safe initialization check...") logDebug("DEBUG SAFE: Performing debug-safe initialization check...")
// Check if we're in a debug environment // Check if we're in a debug environment
#if DEBUG #if DEBUG
print("DEBUG SAFE: Debug environment detected") logDebug("DEBUG SAFE: Debug environment detected")
// Check for interrupted migration more aggressively
if fileManager.fileExists(atPath: migrationLockURL.path) { if fileManager.fileExists(atPath: migrationLockURL.path) {
print("DEBUG SAFE: Found migration lock - likely debug interruption") logDebug("DEBUG SAFE: Found migration lock - likely debug interruption")
// Give extra time for file system to stabilize
Thread.sleep(forTimeInterval: 1.0) Thread.sleep(forTimeInterval: 1.0)
// Try emergency recovery // Try emergency recovery
@@ -829,14 +828,14 @@ class ImageManager {
((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0 ((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0
if primaryEmpty && backupHasFiles { if primaryEmpty && backupHasFiles {
print("DEBUG SAFE: Primary empty but backup exists - restoring") logDebug("DEBUG SAFE: Primary empty but backup exists - restoring")
emergencyImageRestore() emergencyImageRestore()
return true return true
} }
// Check if primary storage is empty but previous Application Support images exist // Check if primary storage is empty but previous Application Support images exist
if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() { if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() {
print("DEBUG SAFE: Primary empty but found previous Application Support images") logDebug("DEBUG SAFE: Primary empty but found previous Application Support images")
migratePreviousAppSupportImages(from: previousAppSupportImages) migratePreviousAppSupportImages(from: previousAppSupportImages)
return true return true
} }
@@ -852,7 +851,7 @@ class ImageManager {
// Check if we have more backups than primary files (sign of corruption) // Check if we have more backups than primary files (sign of corruption)
if backupFiles.count > primaryFiles.count + 5 { if backupFiles.count > primaryFiles.count + 5 {
print( logInfo(
"WARNING INTEGRITY: Backup count significantly exceeds primary - potential corruption" "WARNING INTEGRITY: Backup count significantly exceeds primary - potential corruption"
) )
return false return false
@@ -860,7 +859,7 @@ class ImageManager {
// Check if primary is completely empty but we have data elsewhere // Check if primary is completely empty but we have data elsewhere
if primaryFiles.isEmpty && !backupFiles.isEmpty { if primaryFiles.isEmpty && !backupFiles.isEmpty {
print("WARNING INTEGRITY: Primary storage empty but backups exist") logWarning("WARNING INTEGRITY: Primary storage empty but backups exist")
return false return false
} }
@@ -874,7 +873,7 @@ class ImageManager {
for: .applicationSupportDirectory, in: .userDomainMask for: .applicationSupportDirectory, in: .userDomainMask
).first ).first
else { else {
print("ERROR: Could not access Application Support directory") logError("ERROR: Could not access Application Support directory")
return nil return nil
} }
@@ -908,13 +907,13 @@ class ImageManager {
} }
} }
} catch { } catch {
print("ERROR: Error scanning for previous Application Support directories: \(error)") logError("ERROR: Error scanning for previous Application Support directories: \(error)")
} }
return nil return nil
} }
private func migratePreviousAppSupportImages(from sourceDirectory: URL) { private func migratePreviousAppSupportImages(from sourceDirectory: URL) {
print("Migrating images from previous Application Support directory") logInfo("Migrating images from previous Application Support directory")
do { do {
let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path) let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path)
@@ -937,18 +936,33 @@ class ImageManager {
// Create backup // Create backup
try? fileManager.copyItem(at: sourcePath, to: backupPath) try? fileManager.copyItem(at: sourcePath, to: backupPath)
print("Migrated: \(fileName)") logInfo("Migrated: \(fileName)")
} catch { } catch {
print("ERROR: Failed to migrate \(fileName): \(error)") logError("ERROR: Failed to migrate \(fileName): \(error)")
} }
} }
} }
print("Completed migration from previous Application Support directory") logInfo("Completed migration from previous Application Support directory")
} catch { } catch {
print("ERROR: Failed to migrate from previous Application Support: \(error)") logError("ERROR: Failed to migrate from previous Application Support: \(error)")
} }
} }
private func logInfo(_ message: String) {
AppLogger.info(message, tag: logTag)
}
private func logWarning(_ message: String) {
AppLogger.warning(message, tag: logTag)
}
private func logError(_ message: String) {
AppLogger.error(message, tag: logTag)
}
private func logDebug(_ message: String) {
AppLogger.debug(message, tag: logTag)
}
} }

View File

@@ -4,6 +4,8 @@ import zlib
struct ZipUtils { struct ZipUtils {
private static let logTag = "ZipUtils"
private static let DATA_JSON_FILENAME = "data.json" private static let DATA_JSON_FILENAME = "data.json"
private static let IMAGES_DIR_NAME = "images" private static let IMAGES_DIR_NAME = "images"
private static let METADATA_FILENAME = "metadata.txt" private static let METADATA_FILENAME = "metadata.txt"
@@ -49,7 +51,7 @@ struct ZipUtils {
) )
// Process images in batches for better performance // Process images in batches for better performance
print("Processing \(referencedImagePaths.count) images for export") logInfo("Processing \(referencedImagePaths.count) images for export")
var successfulImages = 0 var successfulImages = 0
let batchSize = 10 let batchSize = 10
let sortedPaths = Array(referencedImagePaths).sorted() let sortedPaths = Array(referencedImagePaths).sorted()
@@ -59,7 +61,7 @@ struct ZipUtils {
for (index, imagePath) in sortedPaths.enumerated() { for (index, imagePath) in sortedPaths.enumerated() {
if index % batchSize == 0 { if index % batchSize == 0 {
print("Processing images \(index)/\(sortedPaths.count)") logInfo("Processing images \(index)/\(sortedPaths.count)")
} }
let imageURL = URL(fileURLWithPath: imagePath) let imageURL = URL(fileURLWithPath: imagePath)
@@ -83,11 +85,11 @@ struct ZipUtils {
successfulImages += 1 successfulImages += 1
} }
} catch { } catch {
print("Failed to read image: \(imageName)") logWarning("Failed to read image: \(imageName)")
} }
} }
print("Export: included \(successfulImages)/\(referencedImagePaths.count) images") logInfo("Export: included \(successfulImages)/\(referencedImagePaths.count) images")
// Build central directory // Build central directory
centralDirectory.reserveCapacity(fileEntries.count * 100) // Estimate 100 bytes per entry centralDirectory.reserveCapacity(fileEntries.count * 100) // Estimate 100 bytes per entry
@@ -114,7 +116,7 @@ struct ZipUtils {
} }
static func extractImportZip(data: Data) throws -> ImportResult { static func extractImportZip(data: Data) throws -> ImportResult {
print("Starting ZIP extraction - data size: \(data.count) bytes") logInfo("Starting ZIP extraction - data size: \(data.count) bytes")
return try extractUsingCustomParser(data: data) return try extractUsingCustomParser(data: data)
} }
@@ -127,10 +129,10 @@ struct ZipUtils {
let zipEntries: [ZipEntry] let zipEntries: [ZipEntry]
do { do {
zipEntries = try parseZipFile(data: data) zipEntries = try parseZipFile(data: data)
print("Successfully parsed ZIP file with \(zipEntries.count) entries") logInfo("Successfully parsed ZIP file with \(zipEntries.count) entries")
} catch { } catch {
print("Failed to parse ZIP file: \(error)") logError("Failed to parse ZIP file: \(error)")
print( logError(
"ZIP data header: \(data.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " "))" "ZIP data header: \(data.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " "))"
) )
throw NSError( throw NSError(
@@ -142,24 +144,24 @@ struct ZipUtils {
) )
} }
print("Found \(zipEntries.count) entries in ZIP file:") logInfo("Found \(zipEntries.count) entries in ZIP file:")
for entry in zipEntries { for entry in zipEntries {
print(" - \(entry.filename) (size: \(entry.data.count) bytes)") logInfo(" - \(entry.filename) (size: \(entry.data.count) bytes)")
} }
for entry in zipEntries { for entry in zipEntries {
switch entry.filename { switch entry.filename {
case METADATA_FILENAME: case METADATA_FILENAME:
metadataContent = String(data: entry.data, encoding: .utf8) ?? "" metadataContent = String(data: entry.data, encoding: .utf8) ?? ""
print("Found metadata: \(metadataContent.prefix(100))...") logInfo("Found metadata: \(metadataContent.prefix(100))...")
case DATA_JSON_FILENAME: case DATA_JSON_FILENAME:
jsonContent = String(data: entry.data, encoding: .utf8) ?? "" jsonContent = String(data: entry.data, encoding: .utf8) ?? ""
print("Found data.json with \(jsonContent.count) characters") logInfo("Found data.json with \(jsonContent.count) characters")
if jsonContent.isEmpty { if jsonContent.isEmpty {
print("WARNING: data.json is empty!") logWarning("WARNING: data.json is empty!")
} else { } else {
print("data.json preview: \(jsonContent.prefix(200))...") logInfo("data.json preview: \(jsonContent.prefix(200))...")
} }
default: default:
@@ -173,17 +175,17 @@ struct ZipUtils {
entry.data, filename: originalFilename) entry.data, filename: originalFilename)
importedImagePaths[originalFilename] = filename importedImagePaths[originalFilename] = filename
} catch { } catch {
print("Failed to import image \(originalFilename): \(error)") logError("Failed to import image \(originalFilename): \(error)")
} }
} }
} }
} }
guard !jsonContent.isEmpty else { guard !jsonContent.isEmpty else {
print("ERROR: data.json not found or empty") logError("ERROR: data.json not found or empty")
print("Available files in ZIP:") logInfo("Available files in ZIP:")
for entry in zipEntries { for entry in zipEntries {
print(" - \(entry.filename)") logInfo(" - \(entry.filename)")
} }
throw NSError( throw NSError(
domain: "ImportError", code: 1, domain: "ImportError", code: 1,
@@ -194,13 +196,25 @@ struct ZipUtils {
) )
} }
print("Import extraction completed: \(importedImagePaths.count) images processed") logInfo("Import extraction completed: \(importedImagePaths.count) images processed")
return ImportResult( return ImportResult(
jsonData: jsonContent.data(using: .utf8) ?? Data(), imagePathMapping: importedImagePaths jsonData: jsonContent.data(using: .utf8) ?? Data(), imagePathMapping: importedImagePaths
) )
} }
private static func logInfo(_ message: String) {
AppLogger.info(message, tag: logTag)
}
private static func logWarning(_ message: String) {
AppLogger.warning(message, tag: logTag)
}
private static func logError(_ message: String) {
AppLogger.error(message, tag: logTag)
}
private static func createMetadata( private static func createMetadata(
exportData: ClimbDataBackup, exportData: ClimbDataBackup,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>

View File

@@ -15,6 +15,8 @@ import UniformTypeIdentifiers
@MainActor @MainActor
class ClimbingDataManager: ObservableObject { class ClimbingDataManager: ObservableObject {
static let shared = ClimbingDataManager()
@Published var gyms: [Gym] = [] @Published var gyms: [Gym] = []
@Published var problems: [Problem] = [] @Published var problems: [Problem] = []
@Published var sessions: [ClimbSession] = [] @Published var sessions: [ClimbSession] = []
@@ -38,7 +40,6 @@ class ClimbingDataManager: ObservableObject {
let healthKitService = HealthKitService.shared let healthKitService = HealthKitService.shared
@Published var isSyncing = false @Published var isSyncing = false
private enum Keys { private enum Keys {
static let gyms = "ascently_gyms" static let gyms = "ascently_gyms"
static let problems = "ascently_problems" static let problems = "ascently_problems"
@@ -79,7 +80,7 @@ class ClimbingDataManager: ObservableObject {
let name: String let name: String
} }
init() { fileprivate init() {
_ = ImageManager.shared _ = ImageManager.shared
migrateFromOpenClimbIfNeeded() migrateFromOpenClimbIfNeeded()
loadAllData() loadAllData()
@@ -115,7 +116,8 @@ class ClimbingDataManager: ObservableObject {
return return
} }
print("Starting migration from OpenClimb to Ascently keys...") AppLogger.info(
"Starting migration from OpenClimb to Ascently keys...", tag: LogTag.climbingData)
var migrationCount = 0 var migrationCount = 0
// Migrate each data type if it exists in old format but not in new format // Migrate each data type if it exists in old format but not in new format
@@ -135,7 +137,7 @@ class ClimbingDataManager: ObservableObject {
userDefaults.set(oldData, forKey: newKey) userDefaults.set(oldData, forKey: newKey)
userDefaults.removeObject(forKey: oldKey) userDefaults.removeObject(forKey: oldKey)
migrationCount += 1 migrationCount += 1
print("Migrated: \(oldKey)\(newKey)") AppLogger.info("Migrated: \(oldKey)\(newKey)", tag: LogTag.climbingData)
} }
} }
@@ -147,7 +149,8 @@ class ClimbingDataManager: ObservableObject {
{ {
sharedDefaults.set(oldData, forKey: newKey) sharedDefaults.set(oldData, forKey: newKey)
sharedDefaults.removeObject(forKey: oldKey) sharedDefaults.removeObject(forKey: oldKey)
print("✅ Migrated shared: \(oldKey)\(newKey)") AppLogger.info(
"Migrated shared: \(oldKey)\(newKey)", tag: LogTag.climbingData)
} }
} }
} }
@@ -161,18 +164,19 @@ class ClimbingDataManager: ObservableObject {
userDefaults.set(lastModified, forKey: newDataStateKey) userDefaults.set(lastModified, forKey: newDataStateKey)
userDefaults.removeObject(forKey: legacyDataStateKey) userDefaults.removeObject(forKey: legacyDataStateKey)
migrationCount += 1 migrationCount += 1
print("Migrated data state timestamp") AppLogger.info("Migrated data state timestamp", tag: LogTag.climbingData)
} }
// Mark migration as completed // Mark migration as completed
userDefaults.set(true, forKey: migrationKey) userDefaults.set(true, forKey: migrationKey)
if migrationCount > 0 { if migrationCount > 0 {
print( AppLogger.info(
"Migration completed! Migrated \(migrationCount) data items from OpenClimb to Ascently" "Migration completed! Migrated \(migrationCount) data items from OpenClimb to Ascently",
tag: LogTag.climbingData
) )
} else { } else {
print("No OpenClimb data found to migrate") AppLogger.info("No OpenClimb data found to migrate", tag: LogTag.climbingData)
} }
} }
@@ -413,9 +417,16 @@ class ClimbingDataManager: ObservableObject {
} }
func startSession(gymId: UUID, notes: String? = nil) { func startSession(gymId: UUID, notes: String? = nil) {
// End any currently active session Task { @MainActor in
await startSessionAsync(gymId: gymId, notes: notes)
}
}
@discardableResult
func startSessionAsync(gymId: UUID, notes: String? = nil) async -> ClimbSession? {
// End any currently active session before starting a new one
if let currentActive = activeSession { if let currentActive = activeSession {
endSession(currentActive.id) await endSessionAsync(currentActive.id)
} }
let newSession = ClimbSession(gymId: gymId, notes: notes) let newSession = ClimbSession(gymId: gymId, notes: notes)
@@ -428,60 +439,70 @@ class ClimbingDataManager: ObservableObject {
// MARK: - Start Live Activity for new session // MARK: - Start Live Activity for new session
if let gym = gym(withId: gymId) { if let gym = gym(withId: gymId) {
Task { await LiveActivityManager.shared.startLiveActivity(
await LiveActivityManager.shared.startLiveActivity( for: newSession,
for: newSession, gymName: gym.name) gymName: gym.name)
}
} }
if healthKitService.isEnabled { if healthKitService.isEnabled {
Task { do {
do { try await healthKitService.startWorkout(
try await healthKitService.startWorkout( startDate: newSession.startTime ?? Date(),
startDate: newSession.startTime ?? Date(), sessionId: newSession.id)
sessionId: newSession.id) } catch {
} catch { AppLogger.error(
print("Failed to start HealthKit workout: \(error.localizedDescription)") "Failed to start HealthKit workout: \(error.localizedDescription)",
} tag: LogTag.climbingData)
} }
} }
return newSession
} }
func endSession(_ sessionId: UUID) { func endSession(_ sessionId: UUID) {
if let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }), Task { @MainActor in
await endSessionAsync(sessionId)
}
}
@discardableResult
func endSessionAsync(_ sessionId: UUID) async -> ClimbSession? {
guard
let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }),
let index = sessions.firstIndex(where: { $0.id == sessionId }) let index = sessions.firstIndex(where: { $0.id == sessionId })
{ else {
return nil
}
let completedSession = session.completed() let completedSession = session.completed()
sessions[index] = completedSession sessions[index] = completedSession
if activeSession?.id == sessionId { if activeSession?.id == sessionId {
activeSession = nil activeSession = nil
} }
saveActiveSession() saveActiveSession()
saveSessions() saveSessions()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
// Trigger auto-sync if enabled // Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self) syncService.triggerAutoSync(dataManager: self)
// MARK: - End Live Activity after session ends // MARK: - End Live Activity after session ends
Task { await LiveActivityManager.shared.endLiveActivity()
await LiveActivityManager.shared.endLiveActivity()
}
if healthKitService.isEnabled { if healthKitService.isEnabled {
Task { do {
do { try await healthKitService.endWorkout(
try await healthKitService.endWorkout( endDate: completedSession.endTime ?? Date())
endDate: completedSession.endTime ?? Date()) } catch {
} catch { AppLogger.error(
print("Failed to end HealthKit workout: \(error.localizedDescription)") "Failed to end HealthKit workout: \(error.localizedDescription)",
} tag: LogTag.climbingData)
}
} }
} }
return completedSession
} }
func updateSession(_ session: ClimbSession) { func updateSession(_ session: ClimbSession) {
@@ -667,7 +688,9 @@ class ClimbingDataManager: ObservableObject {
} }
if !orphanedAttempts.isEmpty { if !orphanedAttempts.isEmpty {
print("🧹 Cleaning up \(orphanedAttempts.count) orphaned attempts") AppLogger.info(
"🧹 Cleaning up \(orphanedAttempts.count) orphaned attempts",
tag: LogTag.climbingData)
// Track these as deleted to prevent sync from re-introducing them // Track these as deleted to prevent sync from re-introducing them
for attempt in orphanedAttempts { for attempt in orphanedAttempts {
@@ -693,14 +716,15 @@ class ClimbingDataManager: ObservableObject {
if seenAttempts.contains(key) { if seenAttempts.contains(key) {
duplicateIds.append(attempt.id) duplicateIds.append(attempt.id)
print("🧹 Found duplicate attempt: \(attempt.id)") AppLogger.info("🧹 Found duplicate attempt: \(attempt.id)", tag: LogTag.climbingData)
} else { } else {
seenAttempts.insert(key) seenAttempts.insert(key)
} }
} }
if !duplicateIds.isEmpty { if !duplicateIds.isEmpty {
print("🧹 Removing \(duplicateIds.count) duplicate attempts") AppLogger.info(
"🧹 Removing \(duplicateIds.count) duplicate attempts", tag: LogTag.climbingData)
// Track duplicates as deleted // Track duplicates as deleted
for attemptId in duplicateIds { for attemptId in duplicateIds {
@@ -714,8 +738,9 @@ class ClimbingDataManager: ObservableObject {
if initialAttemptCount != attempts.count { if initialAttemptCount != attempts.count {
saveAttempts() saveAttempts()
let removedCount = initialAttemptCount - attempts.count let removedCount = initialAttemptCount - attempts.count
print( AppLogger.info(
"Cleanup complete. Removed \(removedCount) attempts. Remaining: \(attempts.count)" "Cleanup complete. Removed \(removedCount) attempts. Remaining: \(attempts.count)",
tag: LogTag.climbingData
) )
} }
@@ -725,7 +750,9 @@ class ClimbingDataManager: ObservableObject {
} }
if !orphanedProblems.isEmpty { if !orphanedProblems.isEmpty {
print("🧹 Cleaning up \(orphanedProblems.count) orphaned problems") AppLogger.info(
"🧹 Cleaning up \(orphanedProblems.count) orphaned problems",
tag: LogTag.climbingData)
for problem in orphanedProblems { for problem in orphanedProblems {
trackDeletion(itemId: problem.id.uuidString, itemType: "problem") trackDeletion(itemId: problem.id.uuidString, itemType: "problem")
@@ -744,7 +771,9 @@ class ClimbingDataManager: ObservableObject {
} }
if !orphanedSessions.isEmpty { if !orphanedSessions.isEmpty {
print("🧹 Cleaning up \(orphanedSessions.count) orphaned sessions") AppLogger.info(
"🧹 Cleaning up \(orphanedSessions.count) orphaned sessions",
tag: LogTag.climbingData)
for session in orphanedSessions { for session in orphanedSessions {
trackDeletion(itemId: session.id.uuidString, itemType: "session") trackDeletion(itemId: session.id.uuidString, itemType: "session")
@@ -844,19 +873,29 @@ class ClimbingDataManager: ObservableObject {
let problemsForImages = problems let problemsForImages = problems
// Move heavy I/O operations to background thread // Move heavy I/O operations to background thread
let logTag = LogTag.climbingData
let zipData = try await Task.detached(priority: .userInitiated) { let zipData = try await Task.detached(priority: .userInitiated) {
// Collect actual image paths from disk for the ZIP // Collect actual image paths from disk for the ZIP
let referencedImagePaths = await Self.collectReferencedImagePathsStatic( let imageSummary = Self.collectReferencedImagePathsStatic(
problems: problemsForImages, problems: problemsForImages,
imagesDirectory: imagesDirectory) imagesDirectory: imagesDirectory)
print("Starting export with \(referencedImagePaths.count) images") let referencedImagePaths = imageSummary.paths
await MainActor.run {
AppLogger.info(
"Starting export with \(referencedImagePaths.count) images (\(imageSummary.missingCount) missing)",
tag: logTag)
}
let zipData = try await ZipUtils.createExportZip( let zipData = try await ZipUtils.createExportZip(
exportData: exportData, exportData: exportData,
referencedImagePaths: referencedImagePaths referencedImagePaths: referencedImagePaths
) )
print("Export completed successfully") await MainActor.run {
AppLogger.info("Export completed successfully", tag: logTag)
}
return (zipData, referencedImagePaths.count) return (zipData, referencedImagePaths.count)
}.value }.value
@@ -865,7 +904,7 @@ class ClimbingDataManager: ObservableObject {
return zipData.0 return zipData.0
} catch { } catch {
let errorMessage = "Export failed: \(error.localizedDescription)" let errorMessage = "Export failed: \(error.localizedDescription)"
print("ERROR: \(errorMessage)") AppLogger.error("ERROR: \(errorMessage)", tag: LogTag.climbingData)
setError(errorMessage) setError(errorMessage)
return nil return nil
} }
@@ -894,16 +933,24 @@ class ClimbingDataManager: ObservableObject {
return Date() return Date()
} }
print("Raw JSON content preview:") AppLogger.debug("Raw JSON content preview:", tag: LogTag.climbingData)
print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...") AppLogger.debug(
String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...",
tag: LogTag.climbingData
)
let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData) let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData)
print("Successfully decoded import data:") AppLogger.info(
print("- Gyms: \(importData.gyms.count)") """
print("- Problems: \(importData.problems.count)") Successfully decoded import data:
print("- Sessions: \(importData.sessions.count)") - Gyms: \(importData.gyms.count)
print("- Attempts: \(importData.attempts.count)") - Problems: \(importData.problems.count)
- Sessions: \(importData.sessions.count)
- Attempts: \(importData.attempts.count)
""",
tag: LogTag.climbingData
)
try validateImportData(importData) try validateImportData(importData)
@@ -960,14 +1007,20 @@ class ClimbingDataManager: ObservableObject {
extension ClimbingDataManager { extension ClimbingDataManager {
private func collectReferencedImagePaths() -> Set<String> { private func collectReferencedImagePaths() -> Set<String> {
let imagesDirectory = ImageManager.shared.imagesDirectory.path let imagesDirectory = ImageManager.shared.imagesDirectory.path
return Self.collectReferencedImagePathsStatic( let result = Self.collectReferencedImagePathsStatic(
problems: problems, problems: problems,
imagesDirectory: imagesDirectory) imagesDirectory: imagesDirectory)
AppLogger.info(
"Export: Collected \(result.paths.count) images (\(result.missingCount) missing)",
tag: LogTag.climbingData)
return result.paths
} }
private static func collectReferencedImagePathsStatic( nonisolated private static func collectReferencedImagePathsStatic(
problems: [Problem], imagesDirectory: String problems: [Problem], imagesDirectory: String
) -> Set<String> { ) -> (paths: Set<String>, missingCount: Int) {
var imagePaths = Set<String>() var imagePaths = Set<String>()
var missingCount = 0 var missingCount = 0
@@ -988,8 +1041,7 @@ extension ClimbingDataManager {
} }
} }
print("Export: Collected \(imagePaths.count) images (\(missingCount) missing)") return (imagePaths, missingCount)
return imagePaths
} }
private func updateProblemImagePaths( private func updateProblemImagePaths(
@@ -1030,11 +1082,14 @@ extension ClimbingDataManager {
} }
deterministicImagePaths.append(deterministicName) deterministicImagePaths.append(deterministicName)
print("Renamed imported image: \(tempFileName)\(deterministicName)") AppLogger.debug(
"Renamed imported image: \(tempFileName)\(deterministicName)",
tag: LogTag.climbingData)
} }
} catch { } catch {
print( AppLogger.error(
"Failed to rename imported image \(tempFileName) to \(deterministicName): \(error)" "Failed to rename imported image \(tempFileName) to \(deterministicName): \(error)",
tag: LogTag.climbingData
) )
deterministicImagePaths.append(tempFileName) deterministicImagePaths.append(tempFileName)
} }
@@ -1078,7 +1133,8 @@ extension ClimbingDataManager {
if needsUpdate { if needsUpdate {
problems = updatedProblems problems = updatedProblems
saveProblems() saveProblems()
print("Migrated image paths for \(problems.count) problems") AppLogger.info(
"Migrated image paths for \(problems.count) problems", tag: LogTag.climbingData)
} }
} }
@@ -1089,8 +1145,9 @@ extension ClimbingDataManager {
// Log storage information for debugging // Log storage information for debugging
let info = await ImageManager.shared.getStorageInfo() let info = await ImageManager.shared.getStorageInfo()
print( await AppLogger.debug(
"Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total" "Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total",
tag: LogTag.climbingData
) )
}.value }.value
} }
@@ -1128,7 +1185,9 @@ extension ClimbingDataManager {
} }
if !orphanedFiles.isEmpty { if !orphanedFiles.isEmpty {
print("Cleaned up \(orphanedFiles.count) orphaned image files") AppLogger.info(
"Cleaned up \(orphanedFiles.count) orphaned image files",
tag: LogTag.climbingData)
} }
} }
} }
@@ -1145,7 +1204,7 @@ extension ClimbingDataManager {
} }
func forceImageRecovery() { func forceImageRecovery() {
print("User initiated force image recovery") AppLogger.info("User initiated force image recovery", tag: LogTag.climbingData)
ImageManager.shared.forceRecoveryMigration() ImageManager.shared.forceRecoveryMigration()
// Refresh the UI after recovery // Refresh the UI after recovery
@@ -1153,7 +1212,7 @@ extension ClimbingDataManager {
} }
func emergencyImageRestore() { func emergencyImageRestore() {
print("User initiated emergency image restore") AppLogger.info("User initiated emergency image restore", tag: LogTag.climbingData)
ImageManager.shared.emergencyImageRestore() ImageManager.shared.emergencyImageRestore()
// Refresh the UI after restore // Refresh the UI after restore
@@ -1179,15 +1238,15 @@ extension ClimbingDataManager {
} }
func testLiveActivity() { func testLiveActivity() {
print("🧪 Testing Live Activity functionality...") AppLogger.info("Testing Live Activity functionality...", tag: LogTag.climbingData)
// Check Live Activity availability // Check Live Activity availability
let status = LiveActivityManager.shared.checkLiveActivityAvailability() let status = LiveActivityManager.shared.checkLiveActivityAvailability()
print(status) AppLogger.info(status, tag: LogTag.climbingData)
// Test with dummy data if we have a gym // Test with dummy data if we have a gym
guard let testGym = gyms.first else { guard let testGym = gyms.first else {
print("ERROR: No gyms available for testing") AppLogger.error("No gyms available for testing", tag: LogTag.climbingData)
return return
} }
@@ -1218,15 +1277,18 @@ extension ClimbingDataManager {
// Only restart if session is actually active // Only restart if session is actually active
guard activeSession.status == .active else { guard activeSession.status == .active else {
print( AppLogger.warning(
"WARNING: Session exists but is not active (status: \(activeSession.status)), ending Live Activity" "Session exists but is not active (status: \(activeSession.status)), ending Live Activity",
tag: LogTag.climbingData
) )
await LiveActivityManager.shared.endLiveActivity() await LiveActivityManager.shared.endLiveActivity()
return return
} }
if let gym = gym(withId: activeSession.gymId) { if let gym = gym(withId: activeSession.gymId) {
print("Checking Live Activity for active session at \(gym.name)") AppLogger.info(
"Checking Live Activity for active session at \(gym.name)", tag: LogTag.climbingData
)
// First cleanup any dismissed activities // First cleanup any dismissed activities
await LiveActivityManager.shared.cleanupDismissedActivities() await LiveActivityManager.shared.cleanupDismissedActivities()
@@ -1241,7 +1303,9 @@ extension ClimbingDataManager {
/// Call this when app becomes active to check for Live Activity restart /// Call this when app becomes active to check for Live Activity restart
func onAppBecomeActive() { func onAppBecomeActive() {
print("App became active - checking Live Activity status") let logTag = "ClimbingData"
AppLogger.info(
"App became active - checking Live Activity status", tag: logTag)
Task { Task {
await checkAndRestartLiveActivity() await checkAndRestartLiveActivity()
} }
@@ -1249,35 +1313,46 @@ extension ClimbingDataManager {
/// Call this when app enters background to update Live Activity /// Call this when app enters background to update Live Activity
func onAppEnterBackground() { func onAppEnterBackground() {
print("App entering background - updating Live Activity if needed") let logTag = "ClimbingData"
AppLogger.info(
"App entering background - updating Live Activity if needed", tag: logTag)
Task { Task {
await updateLiveActivityData() await updateLiveActivityData()
} }
} }
/// Setup notifications for Live Activity events /// Setup notifications for Live Activity events
private func setupLiveActivityNotifications() { nonisolated private func setupLiveActivityNotifications() {
let notificationName = Notification.Name("liveActivityDismissed")
let logTag = "ClimbingData"
liveActivityObserver = NotificationCenter.default.addObserver( liveActivityObserver = NotificationCenter.default.addObserver(
forName: .liveActivityDismissed, forName: notificationName,
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
print("🔔 Received Live Activity dismissed notification - attempting restart")
Task { @MainActor in Task { @MainActor in
AppLogger.info(
"Received Live Activity dismissed notification - attempting restart",
tag: logTag)
await self?.handleLiveActivityDismissed() await self?.handleLiveActivityDismissed()
} }
} }
} }
private func setupMigrationNotifications() { nonisolated private func setupMigrationNotifications() {
let logTag = "ClimbingData"
migrationObserver = NotificationCenter.default.addObserver( migrationObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("ImageMigrationCompleted"), forName: NSNotification.Name("ImageMigrationCompleted"),
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] notification in ) { [weak self] notification in
if let updateCount = notification.userInfo?["updateCount"] as? Int { if let updateCount = notification.userInfo?["updateCount"] as? Int {
print("🔔 Image migration completed with \(updateCount) updates - reloading data")
Task { @MainActor in Task { @MainActor in
AppLogger.info(
"Image migration completed with \(updateCount) updates - reloading data",
tag: logTag)
self?.loadProblems() self?.loadProblems()
} }
} }
@@ -1293,7 +1368,9 @@ extension ClimbingDataManager {
return return
} }
print("Attempting to restart dismissed Live Activity for \(gym.name)") AppLogger.info(
"Attempting to restart dismissed Live Activity for \(gym.name)",
tag: LogTag.climbingData)
// Wait a bit before restarting to avoid frequency limits // Wait a bit before restarting to avoid frequency limits
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
@@ -1333,11 +1410,20 @@ extension ClimbingDataManager {
activeSession.status == .active, activeSession.status == .active,
let gym = gym(withId: activeSession.gymId) let gym = gym(withId: activeSession.gymId)
else { else {
print("WARNING: Live Activity update skipped - no active session or gym") AppLogger.warning(
"Live Activity update skipped - no active session or gym",
tag: LogTag.climbingData
)
if let session = activeSession { if let session = activeSession {
print(" Session ID: \(session.id)") AppLogger.debug(
print(" Session Status: \(session.status)") """
print(" Gym ID: \(session.gymId)") Skipped session details:
Session ID: \(session.id)
Session Status: \(session.status)
Gym ID: \(session.gymId)
""",
tag: LogTag.climbingData
)
} }
return return
} }
@@ -1357,14 +1443,17 @@ extension ClimbingDataManager {
elapsedInterval = 0 elapsedInterval = 0
} }
print("Live Activity Update Debug:") AppLogger.debug(
print(" Session ID: \(activeSession.id)") """
print(" Gym: \(gym.name)") Live Activity Update Debug:
print(" Total attempts in session: \(totalAttempts)") Session ID: \(activeSession.id)
print(" Completed problems: \(completedProblems)") Gym: \(gym.name)
print(" Elapsed time: \(elapsedInterval) seconds") Total attempts in session: \(totalAttempts)
print( Completed problems: \(completedProblems)
" All attempts for session: \(attemptsForSession.map { "\($0.result) - Problem: \($0.problemId)" })" Elapsed time: \(elapsedInterval) seconds
All attempts for session: \(attemptsForSession.map { "\($0.result) - Problem: \($0.problemId)" })
""",
tag: LogTag.climbingData
) )
Task { Task {

View File

@@ -8,6 +8,7 @@ extension Notification.Name {
@MainActor @MainActor
final class LiveActivityManager { final class LiveActivityManager {
static let shared = LiveActivityManager() static let shared = LiveActivityManager()
private static let logTag = "LiveActivity"
private init() {} private init() {}
nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>? nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>?
@@ -30,11 +31,12 @@ final class LiveActivityManager {
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
if isStillActive { if isStillActive {
print("Live Activity still running: \(currentActivity.id)") AppLogger.debug("Live Activity still running: \(currentActivity.id)", tag: Self.logTag)
return return
} else { } else {
print( AppLogger.warning(
"WARNING: Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference" "Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference",
tag: Self.logTag
) )
self.currentActivity = nil self.currentActivity = nil
} }
@@ -43,18 +45,18 @@ final class LiveActivityManager {
// Check if there are ANY active Live Activities for this session // Check if there are ANY active Live Activities for this session
let existingActivities = Activity<SessionActivityAttributes>.activities let existingActivities = Activity<SessionActivityAttributes>.activities
if let existingActivity = existingActivities.first { if let existingActivity = existingActivities.first {
print("Found existing Live Activity: \(existingActivity.id), using it") AppLogger.info("Found existing Live Activity: \(existingActivity.id), using it", tag: Self.logTag)
self.currentActivity = existingActivity self.currentActivity = existingActivity
return return
} }
print("No Live Activity found, restarting for existing session") AppLogger.info("No Live Activity found, restarting for existing session", tag: Self.logTag)
await startLiveActivity(for: activeSession, gymName: gymName) await startLiveActivity(for: activeSession, gymName: gymName)
} }
/// Call this when a ClimbSession starts to begin a Live Activity /// Call this when a ClimbSession starts to begin a Live Activity
func startLiveActivity(for session: ClimbSession, gymName: String) async { func startLiveActivity(for session: ClimbSession, gymName: String) async {
print("Starting Live Activity for gym: \(gymName)") AppLogger.info("Starting Live Activity for gym: \(gymName)", tag: Self.logTag)
await endLiveActivity() await endLiveActivity()
@@ -80,18 +82,26 @@ final class LiveActivityManager {
pushType: nil pushType: nil
) )
self.currentActivity = activity self.currentActivity = activity
print("Live Activity started successfully: \(activity.id)") AppLogger.info("Live Activity started successfully: \(activity.id)", tag: Self.logTag)
} catch { } catch {
print("ERROR: Failed to start live activity: \(error)") AppLogger.error(
print("Error details: \(error.localizedDescription)") """
Failed to start live activity: \(error)
Details: \(error.localizedDescription)
""",
tag: Self.logTag
)
// Check specific error types // Check specific error types
if error.localizedDescription.contains("authorization") { if error.localizedDescription.contains("authorization") {
print("Authorization error - check Live Activity permissions in Settings") AppLogger.warning(
"Authorization error - check Live Activity permissions in Settings",
tag: Self.logTag
)
} else if error.localizedDescription.contains("content") { } else if error.localizedDescription.contains("content") {
print("Content error - check ActivityAttributes structure") AppLogger.warning("Content error - check ActivityAttributes structure", tag: Self.logTag)
} else if error.localizedDescription.contains("frequencyLimited") { } else if error.localizedDescription.contains("frequencyLimited") {
print("Frequency limited - too many Live Activities started recently") AppLogger.warning("Frequency limited - too many Live Activities started recently", tag: Self.logTag)
} }
} }
} }
@@ -100,7 +110,7 @@ final class LiveActivityManager {
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{ {
guard let currentActivity = currentActivity else { guard let currentActivity = currentActivity else {
print("WARNING: No current activity to update") AppLogger.warning("No current activity to update", tag: Self.logTag)
return return
} }
@@ -109,15 +119,17 @@ final class LiveActivityManager {
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive { if !isStillActive {
print( AppLogger.warning(
"WARNING: Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference" "Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference",
tag: Self.logTag
) )
self.currentActivity = nil self.currentActivity = nil
return return
} }
print( AppLogger.debug(
"Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)" "Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)",
tag: Self.logTag
) )
let updatedContentState = SessionActivityAttributes.ContentState( let updatedContentState = SessionActivityAttributes.ContentState(
@@ -137,26 +149,26 @@ final class LiveActivityManager {
// First end the tracked activity if it exists // First end the tracked activity if it exists
if let currentActivity { if let currentActivity {
print("Ending tracked Live Activity: \(currentActivity.id)") AppLogger.info("Ending tracked Live Activity: \(currentActivity.id)", tag: Self.logTag)
nonisolated(unsafe) let activity = currentActivity nonisolated(unsafe) let activity = currentActivity
await activity.end(nil, dismissalPolicy: .immediate) await activity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil self.currentActivity = nil
print("Tracked Live Activity ended successfully") AppLogger.info("Tracked Live Activity ended successfully", tag: Self.logTag)
} }
// Force end ALL active activities of our type to ensure cleanup // Force end ALL active activities of our type to ensure cleanup
print("Checking for any remaining active activities...") AppLogger.debug("Checking for any remaining active activities...", tag: Self.logTag)
let activities = Activity<SessionActivityAttributes>.activities let activities = Activity<SessionActivityAttributes>.activities
if activities.isEmpty { if activities.isEmpty {
print("No additional activities found") AppLogger.debug("No additional activities found", tag: Self.logTag)
} else { } else {
print("Found \(activities.count) additional active activities, ending them...") AppLogger.info("Found \(activities.count) additional active activities, ending them...", tag: Self.logTag)
for activity in activities { for activity in activities {
print("Force ending activity: \(activity.id)") AppLogger.debug("Force ending activity: \(activity.id)", tag: Self.logTag)
await activity.end(nil, dismissalPolicy: .immediate) await activity.end(nil, dismissalPolicy: .immediate)
} }
print("All Live Activities ended successfully") AppLogger.info("All Live Activities ended successfully", tag: Self.logTag)
} }
} }
@@ -174,7 +186,7 @@ final class LiveActivityManager {
• All Active Activities: \(allActivities.count) • All Active Activities: \(allActivities.count)
""" """
print(message) AppLogger.info(message, tag: Self.logTag)
return message return message
} }
@@ -185,7 +197,7 @@ final class LiveActivityManager {
if let currentActivity = currentActivity { if let currentActivity = currentActivity {
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive { if !isStillActive {
print("Cleaning up dismissed Live Activity: \(currentActivity.id)") AppLogger.info("Cleaning up dismissed Live Activity: \(currentActivity.id)", tag: Self.logTag)
self.currentActivity = nil self.currentActivity = nil
} }
} }
@@ -195,7 +207,7 @@ final class LiveActivityManager {
func startHealthChecks() { func startHealthChecks() {
stopHealthChecks() // Stop any existing timer stopHealthChecks() // Stop any existing timer
print("🩺 Starting Live Activity health checks") AppLogger.debug("🩺 Starting Live Activity health checks", tag: Self.logTag)
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
[weak self] _ in [weak self] _ in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
@@ -208,7 +220,7 @@ final class LiveActivityManager {
func stopHealthChecks() { func stopHealthChecks() {
healthCheckTimer?.invalidate() healthCheckTimer?.invalidate()
healthCheckTimer = nil healthCheckTimer = nil
print("Stopped Live Activity health checks") AppLogger.debug("Stopped Live Activity health checks", tag: Self.logTag)
} }
/// Perform a health check on the current Live Activity /// Perform a health check on the current Live Activity
@@ -221,14 +233,14 @@ final class LiveActivityManager {
// Only perform health check if it's been at least 25 seconds // Only perform health check if it's been at least 25 seconds
guard timeSinceLastCheck >= 25 else { return } guard timeSinceLastCheck >= 25 else { return }
print("🩺 Performing Live Activity health check") AppLogger.debug("🩺 Performing Live Activity health check", tag: Self.logTag)
lastHealthCheck = now lastHealthCheck = now
let activities = Activity<SessionActivityAttributes>.activities let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive { if !isStillActive {
print("Health check failed - Live Activity was dismissed") AppLogger.warning("Health check failed - Live Activity was dismissed", tag: Self.logTag)
self.currentActivity = nil self.currentActivity = nil
// Notify that we need to restart // Notify that we need to restart
@@ -237,7 +249,7 @@ final class LiveActivityManager {
object: nil object: nil
) )
} else { } else {
print("Live Activity health check passed") AppLogger.debug("Live Activity health check passed", tag: Self.logTag)
} }
} }

View File

@@ -196,7 +196,7 @@ struct LiveActivityDebugView: View {
} }
isTestRunning = true isTestRunning = true
appendDebugOutput("🧪 Starting Live Activity test...") appendDebugOutput("Starting Live Activity test...")
Task { Task {
defer { defer {

View File

@@ -317,7 +317,6 @@ struct ProblemsList: View {
} }
Button { Button {
// Use a spring animation for more natural movement
withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1)) withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1))
{ {
let updatedProblem = problem.updated(isActive: !problem.isActive) let updatedProblem = problem.updated(isActive: !problem.isActive)

View File

@@ -84,6 +84,8 @@ struct DataManagementSection: View {
@State private var isDeletingImages = false @State private var isDeletingImages = false
@State private var showingDeleteImagesAlert = false @State private var showingDeleteImagesAlert = false
private static let logTag = "DataManagementSection"
var body: some View { var body: some View {
Section("Data Management") { Section("Data Management") {
// Export Data // Export Data
@@ -217,13 +219,14 @@ struct DataManagementSection: View {
try fileManager.removeItem(at: imageFile) try fileManager.removeItem(at: imageFile)
deletedCount += 1 deletedCount += 1
} catch { } catch {
print("Failed to delete image: \(imageFile.lastPathComponent)") AppLogger.error(
"Failed to delete image: \(imageFile.lastPathComponent)", tag: Self.logTag)
} }
} }
print("Deleted \(deletedCount) image files") AppLogger.info("Deleted \(deletedCount) image files", tag: Self.logTag)
} catch { } catch {
print("Failed to access images directory: \(error)") AppLogger.error("Failed to access images directory: \(error)", tag: Self.logTag)
} }
// Delete all images from backup directory // Delete all images from backup directory
@@ -235,7 +238,7 @@ struct DataManagementSection: View {
try? fileManager.removeItem(at: backupFile) try? fileManager.removeItem(at: backupFile)
} }
} catch { } catch {
print("Failed to access backup directory: \(error)") AppLogger.error("Failed to access backup directory: \(error)", tag: Self.logTag)
} }
// Clear image paths from all problems // Clear image paths from all problems
@@ -260,20 +263,6 @@ struct AppInfoSection: View {
var body: some View { var body: some View {
Section("App Information") { Section("App Information") {
HStack {
Image("AppLogo")
.resizable()
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Ascently")
.font(.headline)
Text("Track your climbing progress")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
HStack { HStack {
Image(systemName: "info.circle") Image(systemName: "info.circle")
.foregroundColor(.blue) .foregroundColor(.blue)
@@ -292,11 +281,13 @@ struct ExportDataView: View {
@State private var tempFileURL: URL? @State private var tempFileURL: URL?
@State private var isCreatingFile = true @State private var isCreatingFile = true
private static let logTag = "ExportDataView"
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 30) { VStack(spacing: 30) {
if isCreatingFile { if isCreatingFile {
// Loading state - more prominent // Loading state
VStack(spacing: 20) { VStack(spacing: 20) {
ProgressView() ProgressView()
.scaleEffect(1.5) .scaleEffect(1.5)
@@ -380,6 +371,7 @@ struct ExportDataView: View {
} }
private func createTempFile() { private func createTempFile() {
let logTag = Self.logTag // Capture before entering background queue
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
do { do {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
@@ -394,7 +386,9 @@ struct ExportDataView: View {
for: .documentDirectory, in: .userDomainMask for: .documentDirectory, in: .userDomainMask
).first ).first
else { else {
print("Could not access Documents directory") Task { @MainActor in
AppLogger.error("Could not access Documents directory", tag: logTag)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.isCreatingFile = false self.isCreatingFile = false
} }
@@ -410,7 +404,9 @@ struct ExportDataView: View {
self.isCreatingFile = false self.isCreatingFile = false
} }
} catch { } catch {
print("Failed to create export file: \(error)") Task { @MainActor in
AppLogger.error("Failed to create export file: \(error)", tag: logTag)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.isCreatingFile = false self.isCreatingFile = false
} }
@@ -420,10 +416,12 @@ struct ExportDataView: View {
private func cleanupTempFile() { private func cleanupTempFile() {
if let fileURL = tempFileURL { if let fileURL = tempFileURL {
let logTag = Self.logTag // Capture before entering async closure
// Clean up after a delay to ensure sharing is complete // Clean up after a delay to ensure sharing is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
try? FileManager.default.removeItem(at: fileURL) try? FileManager.default.removeItem(at: fileURL)
print("Cleaned up export file: \(fileURL.lastPathComponent)") AppLogger.debug(
"Cleaned up export file: \(fileURL.lastPathComponent)", tag: logTag)
} }
} }
} }
@@ -435,6 +433,8 @@ struct SyncSection: View {
@State private var showingSyncSettings = false @State private var showingSyncSettings = false
@State private var showingDisconnectAlert = false @State private var showingDisconnectAlert = false
private static let logTag = "SyncSection"
var body: some View { var body: some View {
Section("Sync") { Section("Sync") {
// Sync Status // Sync Status
@@ -579,11 +579,14 @@ struct SyncSection: View {
} }
private func performSync() { private func performSync() {
let logTag = Self.logTag // Capture before entering async context
Task { Task {
do { do {
try await syncService.syncWithServer(dataManager: dataManager) try await syncService.syncWithServer(dataManager: dataManager)
} catch { } catch {
print("Sync failed: \(error)") await MainActor.run {
AppLogger.error("Sync failed: \(error)", tag: logTag)
}
} }
} }
} }

View File

@@ -8,7 +8,6 @@ import WidgetKit
struct SessionStatusLiveBundle: WidgetBundle { struct SessionStatusLiveBundle: WidgetBundle {
var body: some Widget { var body: some Widget {
SessionStatusLive() SessionStatusLive()
SessionStatusLiveControl()
SessionStatusLiveLiveActivity() SessionStatusLiveLiveActivity()
} }
} }

View File

@@ -1,74 +0,0 @@
//
// SessionStatusLiveControl.swift
import AppIntents
import SwiftUI
import WidgetKit
struct SessionStatusLiveControl: ControlWidget {
static let kind: String = "com.atridad.Ascently.SessionStatusLive"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: Provider()
) { value in
ControlWidgetToggle(
"Start Timer",
isOn: value.isRunning,
action: StartTimerIntent(value.name)
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Timer")
.description("A an example control that runs a timer.")
}
}
extension SessionStatusLiveControl {
struct Value {
var isRunning: Bool
var name: String
}
struct Provider: AppIntentControlValueProvider {
func previewValue(configuration: TimerConfiguration) -> Value {
SessionStatusLiveControl.Value(isRunning: false, name: configuration.timerName)
}
func currentValue(configuration: TimerConfiguration) async throws -> Value {
let isRunning = true // Check if the timer is running
return SessionStatusLiveControl.Value(
isRunning: isRunning, name: configuration.timerName)
}
}
}
struct TimerConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Timer Name Configuration"
@Parameter(title: "Timer Name", default: "Timer")
var timerName: String
}
struct StartTimerIntent: SetValueIntent {
static let title: LocalizedStringResource = "Start a timer"
@Parameter(title: "Timer Name")
var name: String
@Parameter(title: "Timer is running")
var value: Bool
init() {}
init(_ name: String) {
self.name = name
}
func perform() async throws -> some IntentResult {
// Start the timer
return .result()
}
}

View File

@@ -13,7 +13,7 @@ import (
"time" "time"
) )
const VERSION = "2.2.0" const VERSION = "2.3.0"
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {