feat: add TTS voice guidance during GPS-tracked workouts
Voice announcements run entirely in the Android foreground service (works with screen locked). Configurable via web UI before starting GPS: time-based or distance-based intervals, selectable metrics (total time, distance, avg/split/current pace), language (en/de). Also syncs workout pause/resume state to the native service — pausing the workout timer now freezes the Android-side elapsed time, distance accumulation, and TTS triggers. Includes TTS engine detection with install prompt if none found, and Android 11+ package visibility query for TTS service discovery.
This commit is contained in:
@@ -11,6 +11,12 @@
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -6,14 +6,20 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.webkit.JavascriptInterface
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
|
||||
class AndroidBridge(private val context: Context) {
|
||||
|
||||
private var ttsForVoices: TextToSpeech? = null
|
||||
|
||||
@JavascriptInterface
|
||||
fun startLocationService() {
|
||||
fun startLocationService(ttsConfigJson: String) {
|
||||
if (context is Activity) {
|
||||
// Request notification permission on Android 13+ (required for foreground service notification)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
@@ -42,7 +48,9 @@ class AndroidBridge(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent(context, LocationForegroundService::class.java)
|
||||
val intent = Intent(context, LocationForegroundService::class.java).apply {
|
||||
putExtra("ttsConfig", ttsConfigJson)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
@@ -50,6 +58,12 @@ class AndroidBridge(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Backwards-compatible overload for calls without TTS config */
|
||||
@JavascriptInterface
|
||||
fun startLocationService() {
|
||||
startLocationService("{}")
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun stopLocationService() {
|
||||
val intent = Intent(context, LocationForegroundService::class.java)
|
||||
@@ -65,4 +79,62 @@ class AndroidBridge(private val context: Context) {
|
||||
fun isTracking(): Boolean {
|
||||
return LocationForegroundService.tracking
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun pauseTracking() {
|
||||
LocationForegroundService.instance?.doPause()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun resumeTracking() {
|
||||
LocationForegroundService.instance?.doResume()
|
||||
}
|
||||
|
||||
/** Returns true if at least one TTS engine is installed on the device. */
|
||||
@JavascriptInterface
|
||||
fun hasTtsEngine(): Boolean {
|
||||
val dummy = TextToSpeech(context, null)
|
||||
val hasEngine = dummy.engines.isNotEmpty()
|
||||
dummy.shutdown()
|
||||
return hasEngine
|
||||
}
|
||||
|
||||
/** Opens the Android TTS install intent (prompts user to install a TTS engine). */
|
||||
@JavascriptInterface
|
||||
fun installTtsEngine() {
|
||||
val intent = Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available TTS voices as a JSON array.
|
||||
* Each entry: { "id": "...", "name": "...", "language": "en-US" }
|
||||
* This initializes a temporary TTS engine; the result is returned asynchronously
|
||||
* via a callback, but since @JavascriptInterface is synchronous we block briefly.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun getAvailableTtsVoices(): String {
|
||||
val result = JSONArray()
|
||||
try {
|
||||
val latch = java.util.concurrent.CountDownLatch(1)
|
||||
var engine: TextToSpeech? = null
|
||||
engine = TextToSpeech(context) { status ->
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
engine?.voices?.forEach { voice ->
|
||||
val obj = JSONObject().apply {
|
||||
put("id", voice.name)
|
||||
put("name", voice.name)
|
||||
put("language", voice.locale.toLanguageTag())
|
||||
}
|
||||
result.put(obj)
|
||||
}
|
||||
}
|
||||
latch.countDown()
|
||||
}
|
||||
latch.await(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
engine.shutdown()
|
||||
} catch (_: Exception) {}
|
||||
return result.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,24 +10,77 @@ import android.content.Intent
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import kotlin.math.*
|
||||
|
||||
class LocationForegroundService : Service() {
|
||||
private const val TAG = "BockenTTS"
|
||||
|
||||
class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
||||
|
||||
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 pausedAccumulatedMs: Long = 0L // total time spent paused
|
||||
private var pausedSinceMs: Long = 0L // timestamp when last paused (0 = not paused)
|
||||
private var lastLat: Double = Double.NaN
|
||||
private var lastLng: Double = Double.NaN
|
||||
private var lastTimestamp: Long = 0L
|
||||
private var currentPaceMinKm: Double = 0.0
|
||||
|
||||
// TTS
|
||||
private var tts: TextToSpeech? = null
|
||||
private var ttsReady = false
|
||||
private var ttsConfig: TtsConfig? = null
|
||||
private var ttsTimeHandler: Handler? = null
|
||||
private var ttsTimeRunnable: Runnable? = null
|
||||
private var lastAnnouncementDistanceKm: Double = 0.0
|
||||
private var lastAnnouncementTimeMs: Long = 0L
|
||||
private var splitDistanceAtLastAnnouncement: Double = 0.0
|
||||
private var splitTimeAtLastAnnouncement: Long = 0L
|
||||
|
||||
data class TtsConfig(
|
||||
val enabled: Boolean = false,
|
||||
val triggerType: String = "distance", // "distance" or "time"
|
||||
val triggerValue: Double = 1.0, // km or minutes
|
||||
val metrics: List<String> = listOf("totalTime", "totalDistance", "avgPace"),
|
||||
val language: String = "en",
|
||||
val voiceId: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun fromJson(json: String): TtsConfig {
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
val metricsArr = obj.optJSONArray("metrics")
|
||||
val metrics = if (metricsArr != null) {
|
||||
(0 until metricsArr.length()).map { metricsArr.getString(it) }
|
||||
} else {
|
||||
listOf("totalTime", "totalDistance", "avgPace")
|
||||
}
|
||||
TtsConfig(
|
||||
enabled = obj.optBoolean("enabled", false),
|
||||
triggerType = obj.optString("triggerType", "distance"),
|
||||
triggerValue = obj.optDouble("triggerValue", 1.0),
|
||||
metrics = metrics,
|
||||
language = obj.optString("language", "en"),
|
||||
voiceId = obj.optString("voiceId", null)
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
TtsConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "gps_tracking"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
@@ -35,8 +88,12 @@ class LocationForegroundService : Service() {
|
||||
const val MIN_DISTANCE_M = 0f
|
||||
|
||||
private val pointBuffer = Collections.synchronizedList(mutableListOf<JSONObject>())
|
||||
var instance: LocationForegroundService? = null
|
||||
private set
|
||||
var tracking = false
|
||||
private set
|
||||
var paused = false
|
||||
private set
|
||||
var totalDistanceKm: Double = 0.0
|
||||
private set
|
||||
|
||||
@@ -71,12 +128,21 @@ class LocationForegroundService : Service() {
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startTimeMs = System.currentTimeMillis()
|
||||
pausedAccumulatedMs = 0L
|
||||
pausedSinceMs = 0L
|
||||
paused = false
|
||||
totalDistanceKm = 0.0
|
||||
lastLat = Double.NaN
|
||||
lastLng = Double.NaN
|
||||
lastTimestamp = 0L
|
||||
currentPaceMinKm = 0.0
|
||||
|
||||
// Parse TTS config from intent
|
||||
val configJson = intent?.getStringExtra("ttsConfig") ?: "{}"
|
||||
Log.d(TAG, "TTS config JSON: $configJson")
|
||||
ttsConfig = TtsConfig.fromJson(configJson)
|
||||
Log.d(TAG, "TTS enabled=${ttsConfig?.enabled}, trigger=${ttsConfig?.triggerType}/${ttsConfig?.triggerValue}, metrics=${ttsConfig?.metrics}")
|
||||
|
||||
val notifIntent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
@@ -95,10 +161,200 @@ class LocationForegroundService : Service() {
|
||||
|
||||
startLocationUpdates()
|
||||
tracking = true
|
||||
instance = this
|
||||
|
||||
// Initialize TTS *after* startForeground — using applicationContext for reliable engine binding
|
||||
if (ttsConfig?.enabled == true) {
|
||||
Log.d(TAG, "Initializing TTS engine (post-startForeground)...")
|
||||
lastAnnouncementDistanceKm = 0.0
|
||||
lastAnnouncementTimeMs = startTimeMs
|
||||
splitDistanceAtLastAnnouncement = 0.0
|
||||
splitTimeAtLastAnnouncement = startTimeMs
|
||||
|
||||
// Log available TTS engines
|
||||
val dummyTts = TextToSpeech(applicationContext, null)
|
||||
val engines = dummyTts.engines
|
||||
Log.d(TAG, "Available TTS engines: ${engines.map { "${it.label} (${it.name})" }}")
|
||||
dummyTts.shutdown()
|
||||
|
||||
// Try with explicit engine if available
|
||||
if (engines.isNotEmpty()) {
|
||||
val engineName = engines[0].name
|
||||
Log.d(TAG, "Trying TTS with explicit engine: $engineName")
|
||||
tts = TextToSpeech(applicationContext, this, engineName)
|
||||
} else {
|
||||
Log.e(TAG, "No TTS engines found on device!")
|
||||
tts = TextToSpeech(applicationContext, this)
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
// --- TTS ---
|
||||
|
||||
override fun onInit(status: Int) {
|
||||
Log.d(TAG, "TTS onInit status=$status (SUCCESS=${TextToSpeech.SUCCESS})")
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
val config = ttsConfig ?: return
|
||||
val locale = Locale.forLanguageTag(config.language)
|
||||
val langResult = tts?.setLanguage(locale)
|
||||
Log.d(TAG, "TTS setLanguage($locale) result=$langResult")
|
||||
|
||||
// Set specific voice if requested
|
||||
if (!config.voiceId.isNullOrEmpty()) {
|
||||
tts?.voices?.find { it.name == config.voiceId }?.let { voice ->
|
||||
tts?.voice = voice
|
||||
}
|
||||
}
|
||||
|
||||
ttsReady = true
|
||||
Log.d(TAG, "TTS ready! triggerType=${config.triggerType}, triggerValue=${config.triggerValue}")
|
||||
|
||||
// Set up time-based trigger if configured
|
||||
if (config.triggerType == "time") {
|
||||
startTimeTrigger(config.triggerValue)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "TTS init FAILED with status=$status")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTimeTrigger(intervalMinutes: Double) {
|
||||
val intervalMs = (intervalMinutes * 60 * 1000).toLong()
|
||||
Log.d(TAG, "Starting time trigger: every ${intervalMs}ms (${intervalMinutes} min)")
|
||||
ttsTimeHandler = Handler(Looper.getMainLooper())
|
||||
ttsTimeRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
Log.d(TAG, "Time trigger fired!")
|
||||
announceMetrics()
|
||||
ttsTimeHandler?.postDelayed(this, intervalMs)
|
||||
}
|
||||
}
|
||||
ttsTimeHandler?.postDelayed(ttsTimeRunnable!!, intervalMs)
|
||||
}
|
||||
|
||||
// --- Pause / Resume ---
|
||||
|
||||
fun doPause() {
|
||||
if (paused) return
|
||||
paused = true
|
||||
pausedSinceMs = System.currentTimeMillis()
|
||||
Log.d(TAG, "Tracking paused")
|
||||
|
||||
// Pause TTS time trigger
|
||||
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
|
||||
|
||||
// Update notification to show paused state
|
||||
val notification = buildNotification(formatElapsed(), "%.2f km".format(totalDistanceKm), "PAUSED")
|
||||
notificationManager?.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun doResume() {
|
||||
if (!paused) return
|
||||
// Accumulate paused duration
|
||||
pausedAccumulatedMs += System.currentTimeMillis() - pausedSinceMs
|
||||
pausedSinceMs = 0L
|
||||
paused = false
|
||||
Log.d(TAG, "Tracking resumed (total paused: ${pausedAccumulatedMs / 1000}s)")
|
||||
|
||||
// Reset last position so we don't accumulate drift during pause
|
||||
lastLat = Double.NaN
|
||||
lastLng = Double.NaN
|
||||
lastTimestamp = 0L
|
||||
|
||||
// Resume TTS time trigger
|
||||
val config = ttsConfig
|
||||
if (ttsReady && config != null && config.triggerType == "time") {
|
||||
val intervalMs = (config.triggerValue * 60 * 1000).toLong()
|
||||
ttsTimeRunnable?.let { ttsTimeHandler?.postDelayed(it, intervalMs) }
|
||||
}
|
||||
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
private fun checkDistanceTrigger() {
|
||||
val config = ttsConfig ?: return
|
||||
if (!ttsReady || config.triggerType != "distance") return
|
||||
|
||||
val sinceLast = totalDistanceKm - lastAnnouncementDistanceKm
|
||||
if (sinceLast >= config.triggerValue) {
|
||||
announceMetrics()
|
||||
lastAnnouncementDistanceKm = totalDistanceKm
|
||||
}
|
||||
}
|
||||
|
||||
private fun announceMetrics() {
|
||||
if (!ttsReady) return
|
||||
val config = ttsConfig ?: return
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val activeSecs = activeElapsedSecs()
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
for (metric in config.metrics) {
|
||||
when (metric) {
|
||||
"totalTime" -> {
|
||||
val h = activeSecs / 3600
|
||||
val m = (activeSecs % 3600) / 60
|
||||
val s = activeSecs % 60
|
||||
val timeStr = if (h > 0) {
|
||||
"$h hours $m minutes"
|
||||
} else {
|
||||
"$m minutes $s seconds"
|
||||
}
|
||||
parts.add("Time: $timeStr")
|
||||
}
|
||||
"totalDistance" -> {
|
||||
val distStr = "%.2f".format(totalDistanceKm)
|
||||
parts.add("Distance: $distStr kilometers")
|
||||
}
|
||||
"avgPace" -> {
|
||||
val elapsedMin = activeSecs / 60.0
|
||||
if (totalDistanceKm > 0.01) {
|
||||
val avgPace = elapsedMin / totalDistanceKm
|
||||
val mins = avgPace.toInt()
|
||||
val secs = ((avgPace - mins) * 60).toInt()
|
||||
parts.add("Average pace: $mins minutes $secs seconds per kilometer")
|
||||
}
|
||||
}
|
||||
"splitPace" -> {
|
||||
val splitDist = totalDistanceKm - splitDistanceAtLastAnnouncement
|
||||
val splitTimeMin = (now - splitTimeAtLastAnnouncement) / 60000.0
|
||||
if (splitDist > 0.01) {
|
||||
val splitPace = splitTimeMin / splitDist
|
||||
val mins = splitPace.toInt()
|
||||
val secs = ((splitPace - mins) * 60).toInt()
|
||||
parts.add("Split pace: $mins minutes $secs seconds per kilometer")
|
||||
}
|
||||
}
|
||||
"currentPace" -> {
|
||||
if (currentPaceMinKm > 0 && currentPaceMinKm <= 60) {
|
||||
val mins = currentPaceMinKm.toInt()
|
||||
val secs = ((currentPaceMinKm - mins) * 60).toInt()
|
||||
parts.add("Current pace: $mins minutes $secs seconds per kilometer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update split tracking
|
||||
splitDistanceAtLastAnnouncement = totalDistanceKm
|
||||
splitTimeAtLastAnnouncement = now
|
||||
lastAnnouncementTimeMs = now
|
||||
|
||||
if (parts.isNotEmpty()) {
|
||||
val text = parts.joinToString(". ")
|
||||
Log.d(TAG, "Announcing: $text")
|
||||
val result = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "workout_announcement")
|
||||
Log.d(TAG, "TTS speak() result=$result (SUCCESS=${TextToSpeech.SUCCESS})")
|
||||
} else {
|
||||
Log.d(TAG, "announceMetrics: no parts to announce")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notification / Location (unchanged) ---
|
||||
|
||||
private fun formatPace(paceMinKm: Double): String {
|
||||
if (paceMinKm <= 0 || paceMinKm > 60) return ""
|
||||
val mins = paceMinKm.toInt()
|
||||
@@ -130,8 +386,15 @@ class LocationForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns active (non-paused) elapsed time in seconds. */
|
||||
private fun activeElapsedSecs(): Long {
|
||||
val now = System.currentTimeMillis()
|
||||
val totalPaused = pausedAccumulatedMs + if (pausedSinceMs > 0) (now - pausedSinceMs) else 0L
|
||||
return (now - startTimeMs - totalPaused) / 1000
|
||||
}
|
||||
|
||||
private fun formatElapsed(): String {
|
||||
val secs = (System.currentTimeMillis() - startTimeMs) / 1000
|
||||
val secs = activeElapsedSecs()
|
||||
val h = secs / 3600
|
||||
val m = (secs % 3600) / 60
|
||||
val s = secs % 60
|
||||
@@ -158,9 +421,22 @@ class LocationForegroundService : Service() {
|
||||
locationListener = LocationListener { location ->
|
||||
val lat = location.latitude
|
||||
val lng = location.longitude
|
||||
val now = location.time
|
||||
|
||||
// Always buffer GPS points (for track drawing) even when paused
|
||||
val point = JSONObject().apply {
|
||||
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)
|
||||
|
||||
// Skip distance/pace accumulation and TTS triggers when paused
|
||||
if (paused) return@LocationListener
|
||||
|
||||
// Accumulate distance and compute pace
|
||||
val now = location.time
|
||||
if (!lastLat.isNaN()) {
|
||||
val segmentKm = haversineKm(lastLat, lastLng, lat, lng)
|
||||
totalDistanceKm += segmentKm
|
||||
@@ -173,16 +449,10 @@ class LocationForegroundService : Service() {
|
||||
lastLng = lng
|
||||
lastTimestamp = now
|
||||
|
||||
val point = JSONObject().apply {
|
||||
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()
|
||||
|
||||
// Check distance-based TTS trigger
|
||||
checkDistanceTrigger()
|
||||
}
|
||||
|
||||
locationManager?.requestLocationUpdates(
|
||||
@@ -195,9 +465,21 @@ class LocationForegroundService : Service() {
|
||||
|
||||
override fun onDestroy() {
|
||||
tracking = false
|
||||
paused = false
|
||||
instance = null
|
||||
locationListener?.let { locationManager?.removeUpdates(it) }
|
||||
locationListener = null
|
||||
locationManager = null
|
||||
|
||||
// Clean up TTS
|
||||
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
|
||||
ttsTimeHandler = null
|
||||
ttsTimeRunnable = null
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
tts = null
|
||||
ttsReady = false
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user