Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
<service
android:name=".RecordingService"
android:exported="false"
android:stopWithTask="false"
android:foregroundServiceType="connectedDevice" />

</application>
Expand Down
13 changes: 12 additions & 1 deletion app/src/main/java/net/duhowpi/ftmsbridge/FtmsBridgeApp.kt
Original file line number Diff line number Diff line change
@@ -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)
}
54 changes: 50 additions & 4 deletions app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -186,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 {
Expand Down Expand Up @@ -1197,7 +1207,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,
Expand Down Expand Up @@ -1227,7 +1237,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()

Expand Down Expand Up @@ -1275,12 +1285,14 @@ class MainActivity : AppCompatActivity() {
lastFallbackElapsedSec = 0
lastSavedSampleMs = 0
lastSavedSampleData = null
lastNotificationUpdateMs = 0
lastHeartRateBpm = 0
lastFtmsSample = null
}

private fun startRecordingService() {
val intent = Intent(this, RecordingService::class.java)
.putExtra(RecordingService.EXTRA_SESSION_START_MS, sessionStartTime)
ContextCompat.startForegroundService(this, intent)
}

Expand Down Expand Up @@ -1338,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

Expand All @@ -1364,7 +1377,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,
Expand All @@ -1383,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() {
Expand Down Expand Up @@ -1796,10 +1828,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()
}
Expand Down
55 changes: 39 additions & 16 deletions app/src/main/java/net/duhowpi/ftmsbridge/RecordingService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,25 +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 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)
.build()

val startTime = intent?.getLongExtra(EXTRA_SESSION_START_MS, 0L)?.takeIf { it > 0 }
?: System.currentTimeMillis()
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
buildNotification(this, getString(R.string.recording_notification_text), startTime),
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
)

Expand Down Expand Up @@ -93,5 +81,40 @@ 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"

/**
* 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()
}
}
}
3 changes: 3 additions & 0 deletions fastlane/metadata/android/en-US/changelogs/6.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@
- 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
- 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