Compare commits

...

3 Commits
1.1.0 ... 1.1.2

6 changed files with 68 additions and 84 deletions

View File

@@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-23T05:20:48.817593Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=18171FDF6007NW" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

View File

@@ -14,8 +14,8 @@ android {
applicationId = "com.atridad.openclimb"
minSdk = 33
targetSdk = 36
versionCode = 15
versionName = "1.1.0"
versionCode = 17
versionName = "1.1.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -12,7 +12,6 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
@@ -52,7 +51,8 @@
android:name=".service.SessionTrackingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
android:foregroundServiceType="specialUse"
android:description="@string/session_tracking_service_description">
<meta-data
android:name="android.app.foreground_service_type"
android:value="specialUse" />

View File

@@ -7,7 +7,6 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R
@@ -17,13 +16,13 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.runBlocking
class SessionTrackingService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var notificationJob: Job? = null
private var monitoringJob: Job? = null
private var wakeLock: PowerManager.WakeLock? = null
private lateinit var repository: ClimbRepository
private lateinit var notificationManager: NotificationManager
@@ -58,7 +57,6 @@ class SessionTrackingService : Service() {
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel()
acquireWakeLock()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -87,59 +85,51 @@ class SessionTrackingService : Service() {
}
}
}
// Return START_STICKY to restart service if it gets killed
return START_STICKY
return START_REDELIVER_INTENT
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
// If the app is removed from recent tasks, ensure the service keeps running
// This helps maintain the notification even if the user swipes away the app
}
override fun onLowMemory() {
super.onLowMemory()
// Don't stop the service on low memory, just log it
// The notification is important for user experience
}
override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) {
// Cancel any existing jobs
notificationJob?.cancel()
monitoringJob?.cancel()
// Start the main notification update job
try {
createAndShowNotification(sessionId)
} catch (e: Exception) {
e.printStackTrace()
}
notificationJob = serviceScope.launch {
try {
// Initial notification update
updateNotification(sessionId)
if (!isNotificationActive()) {
delay(1000L)
createAndShowNotification(sessionId)
}
// Update every 2 seconds for better performance
while (isActive) {
delay(2000L)
delay(5000L)
updateNotification(sessionId)
}
} catch (e: Exception) {
// Log error and continue
e.printStackTrace()
}
}
// Start the monitoring job that ensures notification stays active
monitoringJob = serviceScope.launch {
try {
while (isActive) {
delay(5000L) // Check every 5 seconds
delay(10000L)
// Verify the notification is still active
if (!isNotificationActive()) {
// Notification was dismissed, recreate it
updateNotification(sessionId)
}
// Verify the session is still active
val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
@@ -155,7 +145,6 @@ class SessionTrackingService : Service() {
private fun stopSessionTracking() {
notificationJob?.cancel()
monitoringJob?.cancel()
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
@@ -171,14 +160,37 @@ class SessionTrackingService : Service() {
private suspend fun updateNotification(sessionId: String) {
try {
val session = repository.getSessionById(sessionId)
createAndShowNotification(sessionId)
} catch (e: Exception) {
e.printStackTrace()
try {
delay(10000L)
createAndShowNotification(sessionId)
} catch (retryException: Exception) {
retryException.printStackTrace()
stopSessionTracking()
}
}
}
private fun createAndShowNotification(sessionId: String) {
try {
val session = runBlocking {
repository.getSessionById(sessionId)
}
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
return
}
val gym = repository.getGymById(session.gymId)
val attempts = repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
val gym = runBlocking {
repository.getGymById(session.gymId)
}
val attempts = runBlocking {
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
}
val duration = session.startTime?.let { startTime ->
try {
@@ -205,7 +217,7 @@ class SessionTrackingService : Service() {
.setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true)
.setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent())
@@ -221,24 +233,13 @@ class SessionTrackingService : Service() {
)
.build()
// Always start foreground to ensure service stays alive
startForeground(NOTIFICATION_ID, notification)
// Also notify separately to ensure it's visible
notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) {
e.printStackTrace()
// Don't stop the service on notification errors, just log them
// Try to restart the notification after a delay
try {
delay(5000L)
updateNotification(sessionId)
} catch (retryException: Exception) {
retryException.printStackTrace()
// If retry fails, stop the service to prevent infinite loops
stopSessionTracking()
}
throw e
}
}
@@ -269,50 +270,23 @@ class SessionTrackingService : Service() {
val channel = NotificationChannel(
CHANNEL_ID,
"Session Tracking",
NotificationManager.IMPORTANCE_LOW
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Shows active climbing session information"
setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
enableLights(false)
enableVibration(false)
setSound(null, null)
}
notificationManager.createNotificationChannel(channel)
}
private fun acquireWakeLock() {
try {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"OpenClimb:SessionTrackingWakeLock"
).apply {
acquire(10*60*1000L) // 10 minutes timeout
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun releaseWakeLock() {
try {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
} catch (e: Exception) {
e.printStackTrace()
}
}
override fun onDestroy() {
super.onDestroy()
notificationJob?.cancel()
monitoringJob?.cancel()
releaseWakeLock()
serviceScope.cancel()
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.ui.components.ImagePicker
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@@ -80,7 +81,7 @@ fun AddEditGymScreen(
val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
if (isEditing) {
viewModel.updateGym(gym.copy(id = gymId))
viewModel.updateGym(gym.copy(id = gymId!!))
} else {
viewModel.addGym(gym)
}
@@ -348,7 +349,7 @@ fun AddEditProblemScreen(
)
if (isEditing) {
viewModel.updateProblem(problem.copy(id = problemId))
viewModel.updateProblem(problem.copy(id = problemId!!))
} else {
viewModel.addProblem(problem)
}
@@ -688,6 +689,7 @@ fun AddEditSessionScreen(
) {
val isEditing = sessionId != null
val gyms by viewModel.gyms.collectAsState()
val context = LocalContext.current
// Session form state
var selectedGym by remember { mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } }) }
@@ -727,15 +729,14 @@ fun AddEditSessionScreen(
TextButton(
onClick = {
selectedGym?.let { gym ->
val session = ClimbSession.create(
gymId = gym.id,
notes = sessionNotes.ifBlank { null }
)
if (isEditing) {
viewModel.updateSession(session.copy(id = sessionId))
val session = ClimbSession.create(
gymId = gym.id,
notes = sessionNotes.ifBlank { null }
)
viewModel.updateSession(session.copy(id = sessionId!!))
} else {
viewModel.addSession(session)
viewModel.startSession(context, gym.id, sessionNotes.ifBlank { null })
}
onNavigateBack()
}

View File

@@ -1,3 +1,4 @@
<resources>
<string name="app_name">OpenClimb</string>
<string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string>
</resources>