Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
cf2adeef7a
|
|||
|
a7481135b4
|
|||
|
748a23e1c0
|
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
if (isEditing) {
|
||||||
val session = ClimbSession.create(
|
val session = ClimbSession.create(
|
||||||
gymId = gym.id,
|
gymId = gym.id,
|
||||||
notes = sessionNotes.ifBlank { null }
|
notes = sessionNotes.ifBlank { null }
|
||||||
)
|
)
|
||||||
|
viewModel.updateSession(session.copy(id = sessionId!!))
|
||||||
if (isEditing) {
|
|
||||||
viewModel.updateSession(session.copy(id = sessionId))
|
|
||||||
} else {
|
} else {
|
||||||
viewModel.addSession(session)
|
viewModel.startSession(context, gym.id, sessionNotes.ifBlank { null })
|
||||||
}
|
}
|
||||||
onNavigateBack()
|
onNavigateBack()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user