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
|
||||
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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user