android: rich GPS notification with pace, request POST_NOTIFICATIONS

- Notification title: "Bocken — Tracking GPS for active Workout"
- Live updates with elapsed time, distance, and pace (min/km)
- Request POST_NOTIFICATIONS permission at runtime (Android 13+)
- Page titles: "- Fitness" → "- Bocken" (missed in prior commit)
This commit is contained in:
2026-03-24 18:23:14 +01:00
parent 28b2494a08
commit 8fff5f14b5
10 changed files with 131 additions and 39 deletions

View File

@@ -14,12 +14,25 @@ class AndroidBridge(private val context: Context) {
@JavascriptInterface
fun startLocationService() {
// Request background location if not yet granted (Android 10+)
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
) {
if (context is Activity) {
ActivityCompat.requestPermissions(
context,
arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),

View File

@@ -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<JSONObject>())
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<JSONObject>
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(

View File

@@ -24,7 +24,7 @@
}));
</script>
<svelte:head><title>{lang === 'en' ? 'Exercises' : 'Übungen'} - Fitness</title></svelte:head>
<svelte:head><title>{lang === 'en' ? 'Exercises' : 'Übungen'} - Bocken</title></svelte:head>
<div class="exercises-page">
<h1>{t('exercises_title', lang)}</h1>

View File

@@ -163,7 +163,7 @@
}
</script>
<svelte:head><title>{exercise?.localName ?? (lang === 'en' ? 'Exercise' : 'Übung')} - Fitness</title></svelte:head>
<svelte:head><title>{exercise?.localName ?? (lang === 'en' ? 'Exercise' : 'Übung')} - Bocken</title></svelte:head>
<div class="exercise-detail">
<h1>{exercise?.localName ?? 'Exercise'}</h1>

View File

@@ -39,7 +39,7 @@
}
</script>
<svelte:head><title>{t('history_title', lang)} - Fitness</title></svelte:head>
<svelte:head><title>{t('history_title', lang)} - Bocken</title></svelte:head>
<div class="history-page">
<h1>{t('history_title', lang)}</h1>

View File

@@ -506,7 +506,7 @@
</script>
<svelte:head>
<title>{session?.name ?? (lang === 'en' ? 'Workout' : 'Training')} - Fitness</title>
<title>{session?.name ?? (lang === 'en' ? 'Workout' : 'Training')} - Bocken</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</svelte:head>

View File

@@ -259,7 +259,7 @@
}
</script>
<svelte:head><title>{lang === 'en' ? 'Measure' : 'Messen'} - Fitness</title></svelte:head>
<svelte:head><title>{lang === 'en' ? 'Measure' : 'Messen'} - Bocken</title></svelte:head>
<div class="measure-page">
<h1>{t('measure_title', lang)}</h1>

View File

@@ -125,7 +125,7 @@
</script>
<svelte:head><title>{t('stats_title', lang)} - Fitness</title></svelte:head>
<svelte:head><title>{t('stats_title', lang)} - Bocken</title></svelte:head>
<div class="stats-page">
<h1>{t('stats_title', lang)}</h1>

View File

@@ -290,7 +290,7 @@
}
</script>
<svelte:head><title>{lang === 'en' ? 'Workout' : 'Training'} - Fitness</title></svelte:head>
<svelte:head><title>{lang === 'en' ? 'Workout' : 'Training'} - Bocken</title></svelte:head>
<div class="template-view">
{#if hasSchedule && nextTemplate}

View File

@@ -519,7 +519,7 @@
</script>
<svelte:head>
<title>{workout.name || (lang === 'en' ? 'Workout' : 'Training')} - Fitness</title>
<title>{workout.name || (lang === 'en' ? 'Workout' : 'Training')} - Bocken</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</svelte:head>