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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

View File

@@ -14,8 +14,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 33 minSdk = 33
targetSdk = 36 targetSdk = 36
versionCode = 15 versionCode = 17
versionName = "1.1.0" versionName = "1.1.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -52,7 +51,8 @@
android:name=".service.SessionTrackingService" android:name=".service.SessionTrackingService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="specialUse"> android:foregroundServiceType="specialUse"
android:description="@string/session_tracking_service_description">
<meta-data <meta-data
android:name="android.app.foreground_service_type" android:name="android.app.foreground_service_type"
android:value="specialUse" /> android:value="specialUse" />

View File

@@ -7,7 +7,6 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.atridad.openclimb.MainActivity import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R import com.atridad.openclimb.R
@@ -17,13 +16,13 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlinx.coroutines.runBlocking
class SessionTrackingService : Service() { class SessionTrackingService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var notificationJob: Job? = null private var notificationJob: Job? = null
private var monitoringJob: Job? = null private var monitoringJob: Job? = null
private var wakeLock: PowerManager.WakeLock? = null
private lateinit var repository: ClimbRepository private lateinit var repository: ClimbRepository
private lateinit var notificationManager: NotificationManager private lateinit var notificationManager: NotificationManager
@@ -58,7 +57,6 @@ class SessionTrackingService : Service() {
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel() createNotificationChannel()
acquireWakeLock()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 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?) { override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent) 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 override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) { private fun startSessionTracking(sessionId: String) {
// Cancel any existing jobs
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel() monitoringJob?.cancel()
// Start the main notification update job try {
createAndShowNotification(sessionId)
} catch (e: Exception) {
e.printStackTrace()
}
notificationJob = serviceScope.launch { notificationJob = serviceScope.launch {
try { try {
// Initial notification update if (!isNotificationActive()) {
updateNotification(sessionId) delay(1000L)
createAndShowNotification(sessionId)
}
// Update every 2 seconds for better performance
while (isActive) { while (isActive) {
delay(2000L) delay(5000L)
updateNotification(sessionId) updateNotification(sessionId)
} }
} catch (e: Exception) { } catch (e: Exception) {
// Log error and continue
e.printStackTrace() e.printStackTrace()
} }
} }
// Start the monitoring job that ensures notification stays active
monitoringJob = serviceScope.launch { monitoringJob = serviceScope.launch {
try { try {
while (isActive) { while (isActive) {
delay(5000L) // Check every 5 seconds delay(10000L)
// Verify the notification is still active
if (!isNotificationActive()) { if (!isNotificationActive()) {
// Notification was dismissed, recreate it
updateNotification(sessionId) updateNotification(sessionId)
} }
// Verify the session is still active
val session = repository.getSessionById(sessionId) val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) { if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking() stopSessionTracking()
@@ -155,7 +145,6 @@ class SessionTrackingService : Service() {
private fun stopSessionTracking() { private fun stopSessionTracking() {
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel() monitoringJob?.cancel()
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
@@ -171,14 +160,37 @@ class SessionTrackingService : Service() {
private suspend fun updateNotification(sessionId: String) { private suspend fun updateNotification(sessionId: String) {
try { 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) { if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking() stopSessionTracking()
return return
} }
val gym = repository.getGymById(session.gymId) val gym = runBlocking {
val attempts = repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList() repository.getGymById(session.gymId)
}
val attempts = runBlocking {
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
}
val duration = session.startTime?.let { startTime -> val duration = session.startTime?.let { startTime ->
try { try {
@@ -205,7 +217,7 @@ class SessionTrackingService : Service() {
.setSmallIcon(R.drawable.ic_mountains) .setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false) .setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent()) .setContentIntent(createOpenAppIntent())
@@ -221,24 +233,13 @@ class SessionTrackingService : Service() {
) )
.build() .build()
// Always start foreground to ensure service stays alive
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
// Also notify separately to ensure it's visible
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
// Don't stop the service on notification errors, just log them throw e
// 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()
}
} }
} }
@@ -269,50 +270,23 @@ class SessionTrackingService : Service() {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Session Tracking", "Session Tracking",
NotificationManager.IMPORTANCE_LOW 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)
} }
notificationManager.createNotificationChannel(channel) 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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel() monitoringJob?.cancel()
releaseWakeLock()
serviceScope.cancel() 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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.ui.components.ImagePicker import com.atridad.openclimb.ui.components.ImagePicker
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel 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) val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
if (isEditing) { if (isEditing) {
viewModel.updateGym(gym.copy(id = gymId)) viewModel.updateGym(gym.copy(id = gymId!!))
} else { } else {
viewModel.addGym(gym) viewModel.addGym(gym)
} }
@@ -348,7 +349,7 @@ fun AddEditProblemScreen(
) )
if (isEditing) { if (isEditing) {
viewModel.updateProblem(problem.copy(id = problemId)) viewModel.updateProblem(problem.copy(id = problemId!!))
} else { } else {
viewModel.addProblem(problem) viewModel.addProblem(problem)
} }
@@ -688,6 +689,7 @@ fun AddEditSessionScreen(
) { ) {
val isEditing = sessionId != null val isEditing = sessionId != null
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val context = LocalContext.current
// Session form state // Session form state
var selectedGym by remember { mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } }) } var selectedGym by remember { mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } }) }
@@ -727,15 +729,14 @@ fun AddEditSessionScreen(
TextButton( TextButton(
onClick = { onClick = {
selectedGym?.let { gym -> selectedGym?.let { gym ->
val session = ClimbSession.create(
gymId = gym.id,
notes = sessionNotes.ifBlank { null }
)
if (isEditing) { 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 { } else {
viewModel.addSession(session) viewModel.startSession(context, gym.id, sessionNotes.ifBlank { null })
} }
onNavigateBack() onNavigateBack()
} }

View File

@@ -1,3 +1,4 @@
<resources> <resources>
<string name="app_name">OpenClimb</string> <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> </resources>