diff --git a/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt b/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt index 662ee25..9d7e0bc 100644 --- a/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt +++ b/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt @@ -14,12 +14,25 @@ class AndroidBridge(private val context: Context) { @JavascriptInterface fun startLocationService() { - // Request background location if not yet granted (Android 10+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) - != PackageManager.PERMISSION_GRANTED - ) { - if (context is Activity) { + if (context is Activity) { + // Request notification permission on Android 13+ (required for foreground service notification) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + context, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 1003 + ) + } + } + + // Request background location on Android 10+ (required for screen-off GPS) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) + != PackageManager.PERMISSION_GRANTED + ) { ActivityCompat.requestPermissions( context, arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), diff --git a/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt b/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt index 8b747c0..53063b2 100644 --- a/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt +++ b/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt @@ -7,7 +7,6 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent -import android.location.Location import android.location.LocationListener import android.location.LocationManager import android.os.Build @@ -15,11 +14,19 @@ import android.os.IBinder import org.json.JSONArray import org.json.JSONObject import java.util.Collections +import kotlin.math.* class LocationForegroundService : Service() { private var locationManager: LocationManager? = null private var locationListener: LocationListener? = null + private var notificationManager: NotificationManager? = null + private var pendingIntent: PendingIntent? = null + private var startTimeMs: Long = 0L + private var lastLat: Double = Double.NaN + private var lastLng: Double = Double.NaN + private var lastTimestamp: Long = 0L + private var currentPaceMinKm: Double = 0.0 companion object { const val CHANNEL_ID = "gps_tracking" @@ -30,8 +37,9 @@ class LocationForegroundService : Service() { private val pointBuffer = Collections.synchronizedList(mutableListOf()) var tracking = false private set + var totalDistanceKm: Double = 0.0 + private set - /** Drain all accumulated points and return as JSON string. Clears the buffer. */ fun drainPoints(): String { val drained: List synchronized(pointBuffer) { @@ -42,6 +50,15 @@ class LocationForegroundService : Service() { for (p in drained) arr.put(p) return arr.toString() } + + private fun haversineKm(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double { + val R = 6371.0 + val dLat = Math.toRadians(lat2 - lat1) + val dLng = Math.toRadians(lng2 - lng1) + val a = sin(dLat / 2).pow(2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLng / 2).pow(2) + return 2 * R * asin(sqrt(a)) + } } override fun onBind(intent: Intent?): IBinder? = null @@ -49,35 +66,26 @@ class LocationForegroundService : Service() { override fun onCreate() { super.onCreate() createNotificationChannel() + notificationManager = getSystemService(NotificationManager::class.java) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val notificationIntent = Intent(this, MainActivity::class.java).apply { + startTimeMs = System.currentTimeMillis() + totalDistanceKm = 0.0 + lastLat = Double.NaN + lastLng = Double.NaN + lastTimestamp = 0L + currentPaceMinKm = 0.0 + + val notifIntent = Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) } - val pendingIntent = PendingIntent.getActivity( - this, 0, notificationIntent, + pendingIntent = PendingIntent.getActivity( + this, 0, notifIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Notification.Builder(this, CHANNEL_ID) - .setContentTitle("GPS Tracking") - .setContentText("Recording your workout route") - .setSmallIcon(android.R.drawable.ic_menu_mylocation) - .setContentIntent(pendingIntent) - .setOngoing(true) - .build() - } else { - @Suppress("DEPRECATION") - Notification.Builder(this) - .setContentTitle("GPS Tracking") - .setContentText("Recording your workout route") - .setSmallIcon(android.R.drawable.ic_menu_mylocation) - .setContentIntent(pendingIntent) - .setOngoing(true) - .build() - } + val notification = buildNotification("0:00", "0.00 km", "") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground(NOTIFICATION_ID, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) @@ -91,19 +99,90 @@ class LocationForegroundService : Service() { return START_STICKY } + private fun formatPace(paceMinKm: Double): String { + if (paceMinKm <= 0 || paceMinKm > 60) return "" + val mins = paceMinKm.toInt() + val secs = ((paceMinKm - mins) * 60).toInt() + return "%d:%02d /km".format(mins, secs) + } + + private fun buildNotification(elapsed: String, distance: String, pace: String): Notification { + val parts = mutableListOf(elapsed, distance) + if (pace.isNotEmpty()) parts.add(pace) + val text = parts.joinToString(" · ") + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + .setContentTitle("Bocken — Tracking GPS for active Workout") + .setContentText(text) + .setSmallIcon(android.R.drawable.ic_menu_mylocation) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + } else { + @Suppress("DEPRECATION") + Notification.Builder(this) + .setContentTitle("Bocken — Tracking GPS for active Workout") + .setContentText(text) + .setSmallIcon(android.R.drawable.ic_menu_mylocation) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + } + } + + private fun formatElapsed(): String { + val secs = (System.currentTimeMillis() - startTimeMs) / 1000 + val h = secs / 3600 + val m = (secs % 3600) / 60 + val s = secs % 60 + return if (h > 0) { + "%d:%02d:%02d".format(h, m, s) + } else { + "%d:%02d".format(m, s) + } + } + + private fun updateNotification() { + val notification = buildNotification( + formatElapsed(), + "%.2f km".format(totalDistanceKm), + formatPace(currentPaceMinKm) + ) + notificationManager?.notify(NOTIFICATION_ID, notification) + } + @Suppress("MissingPermission") private fun startLocationUpdates() { locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager locationListener = LocationListener { location -> + val lat = location.latitude + val lng = location.longitude + + // Accumulate distance and compute pace + val now = location.time + if (!lastLat.isNaN()) { + val segmentKm = haversineKm(lastLat, lastLng, lat, lng) + totalDistanceKm += segmentKm + if (segmentKm > 0.001 && lastTimestamp > 0) { + val dtMin = (now - lastTimestamp) / 60000.0 + currentPaceMinKm = dtMin / segmentKm + } + } + lastLat = lat + lastLng = lng + lastTimestamp = now + val point = JSONObject().apply { - put("lat", location.latitude) - put("lng", location.longitude) + put("lat", lat) + put("lng", lng) if (location.hasAltitude()) put("altitude", location.altitude) if (location.hasSpeed()) put("speed", location.speed.toDouble()) put("timestamp", location.time) } pointBuffer.add(point) + + updateNotification() } locationManager?.requestLocationUpdates( diff --git a/src/routes/fitness/[exercises=fitnessExercises]/+page.svelte b/src/routes/fitness/[exercises=fitnessExercises]/+page.svelte index 273e144..a3a62ce 100644 --- a/src/routes/fitness/[exercises=fitnessExercises]/+page.svelte +++ b/src/routes/fitness/[exercises=fitnessExercises]/+page.svelte @@ -24,7 +24,7 @@ })); -{lang === 'en' ? 'Exercises' : 'Übungen'} - Fitness +{lang === 'en' ? 'Exercises' : 'Übungen'} - Bocken

{t('exercises_title', lang)}

diff --git a/src/routes/fitness/[exercises=fitnessExercises]/[id]/+page.svelte b/src/routes/fitness/[exercises=fitnessExercises]/[id]/+page.svelte index f391bb5..23b6b5a 100644 --- a/src/routes/fitness/[exercises=fitnessExercises]/[id]/+page.svelte +++ b/src/routes/fitness/[exercises=fitnessExercises]/[id]/+page.svelte @@ -163,7 +163,7 @@ } -{exercise?.localName ?? (lang === 'en' ? 'Exercise' : 'Übung')} - Fitness +{exercise?.localName ?? (lang === 'en' ? 'Exercise' : 'Übung')} - Bocken

{exercise?.localName ?? 'Exercise'}

diff --git a/src/routes/fitness/[history=fitnessHistory]/+page.svelte b/src/routes/fitness/[history=fitnessHistory]/+page.svelte index ba440b3..9b50c5d 100644 --- a/src/routes/fitness/[history=fitnessHistory]/+page.svelte +++ b/src/routes/fitness/[history=fitnessHistory]/+page.svelte @@ -39,7 +39,7 @@ } -{t('history_title', lang)} - Fitness +{t('history_title', lang)} - Bocken

{t('history_title', lang)}

diff --git a/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte b/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte index 1682fc0..f114103 100644 --- a/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte +++ b/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte @@ -506,7 +506,7 @@ - {session?.name ?? (lang === 'en' ? 'Workout' : 'Training')} - Fitness + {session?.name ?? (lang === 'en' ? 'Workout' : 'Training')} - Bocken diff --git a/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte b/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte index 132ca86..1c88d6e 100644 --- a/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte +++ b/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte @@ -259,7 +259,7 @@ } -{lang === 'en' ? 'Measure' : 'Messen'} - Fitness +{lang === 'en' ? 'Measure' : 'Messen'} - Bocken

{t('measure_title', lang)}

diff --git a/src/routes/fitness/[stats=fitnessStats]/+page.svelte b/src/routes/fitness/[stats=fitnessStats]/+page.svelte index a1ef92b..ac531f9 100644 --- a/src/routes/fitness/[stats=fitnessStats]/+page.svelte +++ b/src/routes/fitness/[stats=fitnessStats]/+page.svelte @@ -125,7 +125,7 @@ -{t('stats_title', lang)} - Fitness +{t('stats_title', lang)} - Bocken

{t('stats_title', lang)}

diff --git a/src/routes/fitness/[workout=fitnessWorkout]/+page.svelte b/src/routes/fitness/[workout=fitnessWorkout]/+page.svelte index c351dc3..61c564a 100644 --- a/src/routes/fitness/[workout=fitnessWorkout]/+page.svelte +++ b/src/routes/fitness/[workout=fitnessWorkout]/+page.svelte @@ -290,7 +290,7 @@ } -{lang === 'en' ? 'Workout' : 'Training'} - Fitness +{lang === 'en' ? 'Workout' : 'Training'} - Bocken
{#if hasSchedule && nextTemplate} diff --git a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte index 0302732..d553796 100644 --- a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte +++ b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte @@ -519,7 +519,7 @@ - {workout.name || (lang === 'en' ? 'Workout' : 'Training')} - Fitness + {workout.name || (lang === 'en' ? 'Workout' : 'Training')} - Bocken