From d386a579e7eb0824d6eebc797e51869d37271c6f Mon Sep 17 00:00:00 2001 From: duhow Date: Sun, 26 Jul 2026 18:58:03 +0200 Subject: [PATCH 1/3] feat: keep session running without destryoing app --- app/src/main/AndroidManifest.xml | 1 + .../net/duhowpi/ftmsbridge/FtmsBridgeApp.kt | 13 ++++++++- .../net/duhowpi/ftmsbridge/MainActivity.kt | 29 ++++++++++++++++--- .../metadata/android/en-US/changelogs/6.txt | 1 + 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a8292f8..30c7c7f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,6 +60,7 @@ diff --git a/app/src/main/java/net/duhowpi/ftmsbridge/FtmsBridgeApp.kt b/app/src/main/java/net/duhowpi/ftmsbridge/FtmsBridgeApp.kt index 7db7826..f569478 100644 --- a/app/src/main/java/net/duhowpi/ftmsbridge/FtmsBridgeApp.kt +++ b/app/src/main/java/net/duhowpi/ftmsbridge/FtmsBridgeApp.kt @@ -1,5 +1,16 @@ package net.duhowpi.ftmsbridge import android.app.Application +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob -class FtmsBridgeApp : Application() +class FtmsBridgeApp : Application() { + /** + * Process-lifetime IO scope for session DB writes. Unlike an activity's lifecycleScope, + * it is not cancelled when the activity is destroyed (e.g. task swiped from recents), + * so samples keep being persisted while the foreground RecordingService keeps the + * process alive. + */ + val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) +} diff --git a/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt b/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt index b4a8399..a9c93cf 100644 --- a/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt +++ b/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt @@ -91,6 +91,13 @@ class MainActivity : AppCompatActivity() { /** Convenience: true when the state machine is in [SessionState.Recording]. */ private val isRecording: Boolean get() = stateMachine.state is SessionState.Recording + /** + * Process-lifetime scope for session DB writes: keeps persisting samples and finalising + * the session after the activity is destroyed (task swiped from recents) while the + * foreground RecordingService keeps the process alive. + */ + private val appScope get() = (application as FtmsBridgeApp).appScope + /** Convenience: true when the state machine is in [SessionState.Paused]. */ private val isMachinePaused: Boolean get() = stateMachine.state is SessionState.Paused @@ -1197,7 +1204,7 @@ class MainActivity : AppCompatActivity() { binding.lapUnitEnergy.setText(R.string.unit_kcal) // Default to lap view setWorkoutView(false) - lifecycleScope.launch(Dispatchers.IO) { + appScope.launch { val session = WorkoutSession( startTimeMs = sessionStartTime, machineType = device.machineType.name, @@ -1227,7 +1234,7 @@ class MainActivity : AppCompatActivity() { stateMachine.applyUI(ftmsConnected, hrConnected, hasHrDevice, fitnessDevice) val sessionId = currentSessionId ?: return - lifecycleScope.launch(Dispatchers.IO) { + appScope.launch { val session = db.sessionDao().getById(sessionId) ?: return@launch session.endTimeMs = System.currentTimeMillis() @@ -1364,7 +1371,7 @@ class MainActivity : AppCompatActivity() { else if (elapsedFallbackStartTime > 0) { calculateFallbackElapsedSec(sample.timestampMs) } else 0 - lifecycleScope.launch(Dispatchers.IO) { + appScope.launch { db.sampleDao().insert( WorkoutSample( sessionId = sessionId, @@ -1796,10 +1803,24 @@ class MainActivity : AppCompatActivity() { dummyTreadmill = null dummyBike = null bleScanner.stopScan() + probeConnectionManager?.disconnect() + + // Task swiped from recents (or otherwise finished) while a real device is recording: + // keep the BLE connection, loggers and foreground RecordingService alive. The BLE + // ConnectionListener keeps saving samples through appScope and finalises the session + // itself when the machine reports a stop or the device disconnects (both paths call + // stopRecording(), which also stops the service). + // ponytail: the destroyed activity instance is intentionally leaked as the session + // owner; reopening the app shows a fresh idle UI while recording continues in the + // background. Upgrade path: move session ownership into RecordingService. + if (isFinishing && stateMachine.state.hasActiveSession && ftmsConnectionManager?.isConnected == true) { + Log.i(tag, "Activity destroyed mid-session: keeping BLE and recording alive in background") + return + } + if (stateMachine.state.hasActiveSession) stopRecording() ftmsConnectionManager?.disconnect() hrConnectionManager?.disconnect() - probeConnectionManager?.disconnect() debugLogger.close() hrDebugLogger.close() } diff --git a/fastlane/metadata/android/en-US/changelogs/6.txt b/fastlane/metadata/android/en-US/changelogs/6.txt index e689d9c..69cc095 100644 --- a/fastlane/metadata/android/en-US/changelogs/6.txt +++ b/fastlane/metadata/android/en-US/changelogs/6.txt @@ -7,3 +7,4 @@ - Device scan list now uses all the available screen space and is the only scrolling area (no more double scroll) - Live chart shows a marker line at every completed kilometre - New menu in Workout History to export all workouts as a ZIP of FIT files and import them back (backup/restore) +- Recording no longer stops when the app is closed from recent apps: the session keeps running in the background until the machine stops or disconnects From 138f8ce17a3bca30128c0f01adc6c14c9878d48c Mon Sep 17 00:00:00 2001 From: duhow Date: Sun, 26 Jul 2026 19:01:13 +0200 Subject: [PATCH 2/3] show timer of running session --- .../main/java/net/duhowpi/ftmsbridge/MainActivity.kt | 1 + .../java/net/duhowpi/ftmsbridge/RecordingService.kt | 10 ++++++++++ fastlane/metadata/android/en-US/changelogs/6.txt | 1 + 3 files changed, 12 insertions(+) diff --git a/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt b/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt index a9c93cf..3f060fe 100644 --- a/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt +++ b/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt @@ -1288,6 +1288,7 @@ class MainActivity : AppCompatActivity() { private fun startRecordingService() { val intent = Intent(this, RecordingService::class.java) + .putExtra(RecordingService.EXTRA_SESSION_START_MS, sessionStartTime) ContextCompat.startForegroundService(this, intent) } diff --git a/app/src/main/java/net/duhowpi/ftmsbridge/RecordingService.kt b/app/src/main/java/net/duhowpi/ftmsbridge/RecordingService.kt index 35ff9f9..9a865bc 100644 --- a/app/src/main/java/net/duhowpi/ftmsbridge/RecordingService.kt +++ b/app/src/main/java/net/duhowpi/ftmsbridge/RecordingService.kt @@ -51,12 +51,19 @@ class RecordingService : Service() { this, 0, openIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + val startTime = intent?.getLongExtra(EXTRA_SESSION_START_MS, 0L)?.takeIf { it > 0 } + ?: System.currentTimeMillis() val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.app_name)) .setContentText(getString(R.string.recording_notification_text)) .setSmallIcon(R.drawable.ic_bluetooth) .setContentIntent(pendingIntent) .setOngoing(true) + // System-rendered elapsed timer: counts up from session start without + // the service having to re-post the notification every second. + .setWhen(startTime) + .setShowWhen(true) + .setUsesChronometer(true) .build() ServiceCompat.startForeground( @@ -93,5 +100,8 @@ class RecordingService : Service() { private const val CHANNEL_ID = "recording_channel" private const val NOTIFICATION_ID = 1 private const val WAKE_LOCK_TIMEOUT_MS = 4 * 60 * 60 * 1000L // 4 hours + + /** Unix ms when the session started; used for the notification chronometer. */ + const val EXTRA_SESSION_START_MS = "session_start_ms" } } diff --git a/fastlane/metadata/android/en-US/changelogs/6.txt b/fastlane/metadata/android/en-US/changelogs/6.txt index 69cc095..ffb9e5a 100644 --- a/fastlane/metadata/android/en-US/changelogs/6.txt +++ b/fastlane/metadata/android/en-US/changelogs/6.txt @@ -8,3 +8,4 @@ - Live chart shows a marker line at every completed kilometre - New menu in Workout History to export all workouts as a ZIP of FIT files and import them back (backup/restore) - Recording no longer stops when the app is closed from recent apps: the session keeps running in the background until the machine stops or disconnects +- The recording notification now shows a live timer with the elapsed session time From 9599ed8b452c75bfab158a93c346e81c061711b0 Mon Sep 17 00:00:00 2001 From: duhow Date: Sun, 26 Jul 2026 19:15:50 +0200 Subject: [PATCH 3/3] show details of exercise in notification --- .../net/duhowpi/ftmsbridge/MainActivity.kt | 24 ++++++++ .../duhowpi/ftmsbridge/RecordingService.kt | 55 ++++++++++++------- .../metadata/android/en-US/changelogs/6.txt | 1 + 3 files changed, 59 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt b/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt index 3f060fe..e43ea0a 100644 --- a/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt +++ b/app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt @@ -193,6 +193,9 @@ class MainActivity : AppCompatActivity() { private var lastSavedSampleMs: Long = 0 private var lastSavedSampleData: FitnessSample? = null + // Live-metrics notification refresh throttle (5 seconds) + private var lastNotificationUpdateMs: Long = 0 + // Throttled scan list updates (1 second) private val updateHandler = Handler(Looper.getMainLooper()) private val scanListUpdateRunnable = object : Runnable { @@ -1282,6 +1285,7 @@ class MainActivity : AppCompatActivity() { lastFallbackElapsedSec = 0 lastSavedSampleMs = 0 lastSavedSampleData = null + lastNotificationUpdateMs = 0 lastHeartRateBpm = 0 lastFtmsSample = null } @@ -1346,6 +1350,7 @@ class MainActivity : AppCompatActivity() { private fun saveSample(sample: FitnessSample) { val sessionId = currentSessionId ?: return + updateRecordingNotification(sample) val nowMs = System.currentTimeMillis() val msSinceLast = nowMs - lastSavedSampleMs @@ -1391,6 +1396,25 @@ class MainActivity : AppCompatActivity() { } } + /** + * Refreshes the foreground-service notification text with live metrics, at most every 5 s. + * Treadmills show speed; bikes and cross-trainers show cadence instead. + * E.g. "Treadmill - 8.0 km/h - 394 kcal" or "Bike - 82 rpm - 210 kcal". + */ + private fun updateRecordingNotification(sample: FitnessSample) { + val nowMs = System.currentTimeMillis() + if (nowMs - lastNotificationUpdateMs < 5_000) return + lastNotificationUpdateMs = nowMs + val device: FitnessDevice = fitnessDevice ?: dummyTreadmill ?: dummyBike ?: return + val metric = if (isActiveTreadmill) { + "%.1f %s".format(sample.speedKmh, getString(R.string.unit_kmh)) + } else { + "${sample.cadenceRpm.toInt()} ${getString(R.string.unit_rpm)}" + } + val text = "${device.deviceName} - $metric - ${sample.totalEnergyKcal} ${getString(R.string.unit_kcal)}" + RecordingService.updateMetrics(this, text, sessionStartTime) + } + // ---- Debug dialog ------------------------------------------------------- private fun showDebugDialog() { diff --git a/app/src/main/java/net/duhowpi/ftmsbridge/RecordingService.kt b/app/src/main/java/net/duhowpi/ftmsbridge/RecordingService.kt index 9a865bc..b1b1368 100644 --- a/app/src/main/java/net/duhowpi/ftmsbridge/RecordingService.kt +++ b/app/src/main/java/net/duhowpi/ftmsbridge/RecordingService.kt @@ -5,6 +5,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service +import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.os.IBinder @@ -44,32 +45,12 @@ class RecordingService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val openIntent = Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - } - val pendingIntent = PendingIntent.getActivity( - this, 0, openIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) val startTime = intent?.getLongExtra(EXTRA_SESSION_START_MS, 0L)?.takeIf { it > 0 } ?: System.currentTimeMillis() - val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.recording_notification_text)) - .setSmallIcon(R.drawable.ic_bluetooth) - .setContentIntent(pendingIntent) - .setOngoing(true) - // System-rendered elapsed timer: counts up from session start without - // the service having to re-post the notification every second. - .setWhen(startTime) - .setShowWhen(true) - .setUsesChronometer(true) - .build() - ServiceCompat.startForeground( this, NOTIFICATION_ID, - notification, + buildNotification(this, getString(R.string.recording_notification_text), startTime), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE ) @@ -103,5 +84,37 @@ class RecordingService : Service() { /** Unix ms when the session started; used for the notification chronometer. */ const val EXTRA_SESSION_START_MS = "session_start_ms" + + /** + * Re-posts the ongoing recording notification with live workout metrics. + * No-op when the foreground notification is not showing (service stopped). + */ + fun updateMetrics(context: Context, text: String, sessionStartMs: Long) { + val nm = context.getSystemService(NotificationManager::class.java) + if (nm.activeNotifications.none { it.id == NOTIFICATION_ID }) return + nm.notify(NOTIFICATION_ID, buildNotification(context, text, sessionStartMs)) + } + + private fun buildNotification(context: Context, text: String, startTime: Long): Notification { + val openIntent = Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + val pendingIntent = PendingIntent.getActivity( + context, 0, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(context.getString(R.string.app_name)) + .setContentText(text) + .setSmallIcon(R.drawable.ic_bluetooth) + .setContentIntent(pendingIntent) + .setOngoing(true) + // System-rendered elapsed timer: counts up from session start without + // the service having to re-post the notification every second. + .setWhen(startTime) + .setShowWhen(true) + .setUsesChronometer(true) + .build() + } } } diff --git a/fastlane/metadata/android/en-US/changelogs/6.txt b/fastlane/metadata/android/en-US/changelogs/6.txt index ffb9e5a..4b0f80d 100644 --- a/fastlane/metadata/android/en-US/changelogs/6.txt +++ b/fastlane/metadata/android/en-US/changelogs/6.txt @@ -9,3 +9,4 @@ - New menu in Workout History to export all workouts as a ZIP of FIT files and import them back (backup/restore) - Recording no longer stops when the app is closed from recent apps: the session keeps running in the background until the machine stops or disconnects - The recording notification now shows a live timer with the elapsed session time +- The recording notification shows live workout data (speed or cadence, and calories), updated every few seconds