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:
@@ -14,12 +14,25 @@ class AndroidBridge(private val context: Context) {
|
|||||||
|
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun startLocationService() {
|
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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
if (context is Activity) {
|
|
||||||
ActivityCompat.requestPermissions(
|
ActivityCompat.requestPermissions(
|
||||||
context,
|
context,
|
||||||
arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
|
arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import android.app.PendingIntent
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.location.Location
|
|
||||||
import android.location.LocationListener
|
import android.location.LocationListener
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -15,11 +14,19 @@ import android.os.IBinder
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
class LocationForegroundService : Service() {
|
class LocationForegroundService : Service() {
|
||||||
|
|
||||||
private var locationManager: LocationManager? = null
|
private var locationManager: LocationManager? = null
|
||||||
private var locationListener: LocationListener? = 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 {
|
companion object {
|
||||||
const val CHANNEL_ID = "gps_tracking"
|
const val CHANNEL_ID = "gps_tracking"
|
||||||
@@ -30,8 +37,9 @@ class LocationForegroundService : Service() {
|
|||||||
private val pointBuffer = Collections.synchronizedList(mutableListOf<JSONObject>())
|
private val pointBuffer = Collections.synchronizedList(mutableListOf<JSONObject>())
|
||||||
var tracking = false
|
var tracking = false
|
||||||
private set
|
private set
|
||||||
|
var totalDistanceKm: Double = 0.0
|
||||||
|
private set
|
||||||
|
|
||||||
/** Drain all accumulated points and return as JSON string. Clears the buffer. */
|
|
||||||
fun drainPoints(): String {
|
fun drainPoints(): String {
|
||||||
val drained: List<JSONObject>
|
val drained: List<JSONObject>
|
||||||
synchronized(pointBuffer) {
|
synchronized(pointBuffer) {
|
||||||
@@ -42,6 +50,15 @@ class LocationForegroundService : Service() {
|
|||||||
for (p in drained) arr.put(p)
|
for (p in drained) arr.put(p)
|
||||||
return arr.toString()
|
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
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
@@ -49,35 +66,26 @@ class LocationForegroundService : Service() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
|
notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
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)
|
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
pendingIntent = PendingIntent.getActivity(
|
||||||
this, 0, notificationIntent,
|
this, 0, notifIntent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
val notification = buildNotification("0:00", "0.00 km", "")
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
startForeground(NOTIFICATION_ID, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
|
startForeground(NOTIFICATION_ID, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
|
||||||
@@ -91,19 +99,90 @@ class LocationForegroundService : Service() {
|
|||||||
return START_STICKY
|
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")
|
@Suppress("MissingPermission")
|
||||||
private fun startLocationUpdates() {
|
private fun startLocationUpdates() {
|
||||||
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
|
|
||||||
locationListener = LocationListener { location ->
|
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 {
|
val point = JSONObject().apply {
|
||||||
put("lat", location.latitude)
|
put("lat", lat)
|
||||||
put("lng", location.longitude)
|
put("lng", lng)
|
||||||
if (location.hasAltitude()) put("altitude", location.altitude)
|
if (location.hasAltitude()) put("altitude", location.altitude)
|
||||||
if (location.hasSpeed()) put("speed", location.speed.toDouble())
|
if (location.hasSpeed()) put("speed", location.speed.toDouble())
|
||||||
put("timestamp", location.time)
|
put("timestamp", location.time)
|
||||||
}
|
}
|
||||||
pointBuffer.add(point)
|
pointBuffer.add(point)
|
||||||
|
|
||||||
|
updateNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
locationManager?.requestLocationUpdates(
|
locationManager?.requestLocationUpdates(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
}));
|
}));
|
||||||
</script>
|
</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">
|
<div class="exercises-page">
|
||||||
<h1>{t('exercises_title', lang)}</h1>
|
<h1>{t('exercises_title', lang)}</h1>
|
||||||
|
|||||||
@@ -163,7 +163,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="exercise-detail">
|
||||||
<h1>{exercise?.localName ?? 'Exercise'}</h1>
|
<h1>{exercise?.localName ?? 'Exercise'}</h1>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="history-page">
|
||||||
<h1>{t('history_title', lang)}</h1>
|
<h1>{t('history_title', lang)}</h1>
|
||||||
|
|||||||
@@ -506,7 +506,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<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" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
@@ -259,7 +259,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="measure-page">
|
||||||
<h1>{t('measure_title', lang)}</h1>
|
<h1>{t('measure_title', lang)}</h1>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
|
|
||||||
</script>
|
</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">
|
<div class="stats-page">
|
||||||
<h1>{t('stats_title', lang)}</h1>
|
<h1>{t('stats_title', lang)}</h1>
|
||||||
|
|||||||
@@ -290,7 +290,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="template-view">
|
||||||
{#if hasSchedule && nextTemplate}
|
{#if hasSchedule && nextTemplate}
|
||||||
|
|||||||
@@ -519,7 +519,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<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" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user