Skip to content
Closed
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
local.properties
app/release
app/debug
/debug

# Claude Code
.claude/
Expand Down
23 changes: 23 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Gradle: Assemble Debug",
"type": "node-terminal",
"request": "launch",
"command": ".\\gradlew.bat :app:assembleDebug"
},
{
"name": "Gradle: Install Debug",
"type": "node-terminal",
"request": "launch",
"command": ".\\gradlew.bat :app:installDebug"
},
{
"name": "ADB: Launch Grupetto",
"type": "node-terminal",
"request": "launch",
"command": "adb shell monkey -p com.spop.poverlay -c android.intent.category.LAUNCHER 1"
}
]
}
37 changes: 33 additions & 4 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,37 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="onepeloton.permission.SUBSCRIPTION_TYPE_ACCESS" />
<!-- Permission for G700 CrossTrainer MetricsService
Unused currently because it seems we can still use the Affernet service
but leaving this here as a hint for future support.
Findings from reverse-engineering the MetricsService APK (com.onepeloton.workoutservices.app) for future reference:

- AIDL interface: com.onepeloton.workoutservices.metrics.IMetricsServiceInterface
- Binding requires Intent with action set to the interface name and package set to "com.onepeloton.workoutservices.app"
- Binding also requires a <queries> element in the manifest for package visibility (Android 11+)
- Permission required: com.onepeloton.permission.METRICS_SERVICE
- The interface is callback-based, NOT polling-based:
- registerCallback(IMetricsServiceCallback) [transact code 3]
- The callback receives onUpdate(Bundle) with metrics data
- fetch(String) -> Bundle [transact code 2] for synchronous queries
- There is no transact code 14 and no BikeData parcelable
- Metrics are delivered in nested maps inside a Bundle:
- bundle.get("metrics_bike_map") -> Map containing:
- "cadence" -> {"current": Double, "average": Double, "max": Double}
- "power" -> {"current": Double, "average": Double, "max": Double}
- "resistance" -> {"current": Double, "average
<uses-permission android:name="com.onepeloton.permission.METRICS_SERVICE" /> -->

<!-- Required on Android 11+ (API 30+) for package visibility
<queries>
<package android:name="com.onepeloton.workoutservices.app" />
</queries> -->

<!-- Bluetooth permissions for BLE FTMS -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
Expand All @@ -22,6 +50,8 @@
android:name="android.hardware.bluetooth_le"
android:required="true" />



<application
android:name=".GrupettoApplication"
android:allowBackup="true"
Expand All @@ -48,14 +78,13 @@

<service
android:name=".overlay.OverlayService"
android:exported="true"
android:foregroundServiceType="specialUse">
android:exported="false"
android:foregroundServiceType="connectedDevice|specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Displays live workout statistics overlay" />
</service>



</application>

</manifest>
287 changes: 286 additions & 1 deletion app/src/main/java/com/spop/poverlay/ConfigurationPage.kt

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions app/src/main/java/com/spop/poverlay/ConfigurationRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class ConfigurationRepository(context: Context, lifecycleOwner: LifecycleOwner)

enum class Preferences(val key: String) {
ShowTimerWhenMinimized("showTimerWhenMinimized"),
ShowCaloriesWhenMinimized("showCaloriesWhenMinimized"),
BleTxEnabled("bleTxEnabled"),
BleFtmsDeviceName("bleFtmsDeviceName"),
SerialNumber("serialNumber")
Expand All @@ -26,11 +27,13 @@ class ConfigurationRepository(context: Context, lifecycleOwner: LifecycleOwner)
}

private val mutableShowTimerWhenMinimized = MutableStateFlow(true)
private val mutableShowCaloriesWhenMinimized = MutableStateFlow(true)
private val mutableBleTxEnabled = MutableStateFlow(true)
private val mutableBleFtmsDeviceName = MutableStateFlow("Grupetto FTMS")
private val mutableSerialNumber = MutableStateFlow("")

val showTimerWhenMinimized = mutableShowTimerWhenMinimized
val showCaloriesWhenMinimized = mutableShowCaloriesWhenMinimized
val bleTxEnabled = mutableBleTxEnabled
val bleFtmsDeviceName = mutableBleFtmsDeviceName
val serialNumber = mutableSerialNumber
Expand Down Expand Up @@ -66,6 +69,13 @@ class ConfigurationRepository(context: Context, lifecycleOwner: LifecycleOwner)
}
}

fun setShowCaloriesWhenMinimized(isShown: Boolean) {
mutableShowCaloriesWhenMinimized.value = isShown
sharedPreferences.edit {
putBoolean(Preferences.ShowCaloriesWhenMinimized.key, isShown)
}
}

fun setBleTxEnabled(enabled: Boolean) {
mutableBleTxEnabled.value = enabled
sharedPreferences.edit {
Expand Down Expand Up @@ -98,6 +108,10 @@ class ConfigurationRepository(context: Context, lifecycleOwner: LifecycleOwner)
sharedPreferences
.getBoolean(Preferences.ShowTimerWhenMinimized.key, true)

mutableShowCaloriesWhenMinimized.value =
sharedPreferences
.getBoolean(Preferences.ShowCaloriesWhenMinimized.key, true)

mutableBleTxEnabled.value =
sharedPreferences
.getBoolean(Preferences.BleTxEnabled.key, true)
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/java/com/spop/poverlay/ConfigurationViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import androidx.lifecycle.viewModelScope
import com.spop.poverlay.overlay.OverlayService
import com.spop.poverlay.releases.Release
import com.spop.poverlay.releases.ReleaseChecker
import com.spop.poverlay.sensor.heartrate.HeartRateDevice
import com.spop.poverlay.sensor.heartrate.HeartRateManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
Expand All @@ -31,11 +33,19 @@ class ConfigurationViewModel(
val showPermissionInfo = mutableStateOf(false)
val infoPopup = MutableLiveData<String>()

val hrConnectedDevice = HeartRateManager.connectedDevice
val hrDiscoveredDevices = HeartRateManager.discoveredDevices
val hrSavedDevices = HeartRateManager.savedDevices
val hrIsScanning = HeartRateManager.isScanning

var latestRelease = mutableStateOf<Release?>(null)

val showTimerWhenMinimized
get() = configurationRepository.showTimerWhenMinimized

val showCaloriesWhenMinimized
get() = configurationRepository.showCaloriesWhenMinimized

val bleTxEnabled
get() = configurationRepository.bleTxEnabled

Expand All @@ -46,6 +56,7 @@ class ConfigurationViewModel(

init {
updatePermissionState()
HeartRateManager.start(getApplication())
if (bleTxEnabled.value && hasBluetoothPermissions()) {
bleServer.start()
}
Expand All @@ -63,6 +74,10 @@ class ConfigurationViewModel(
configurationRepository.setShowTimerWhenMinimized(isChecked)
}

fun onShowCaloriesWhenMinimizedClicked(isChecked: Boolean) {
configurationRepository.setShowCaloriesWhenMinimized(isChecked)
}

fun onBleTxEnabledClicked(isChecked: Boolean) {
configurationRepository.setBleTxEnabled(isChecked)
if (isChecked) {
Expand Down Expand Up @@ -159,6 +174,22 @@ class ConfigurationViewModel(
getApplication<Application>().startActivity(browserIntent)
}

fun startHeartRateDiscovery() {
HeartRateManager.startDiscovery()
}

fun stopHeartRateDiscovery() {
HeartRateManager.stopDiscovery()
}

fun connectHeartRateDevice(device: HeartRateDevice) {
HeartRateManager.connectTo(device)
}

fun forgetHeartRateDevice(address: String) {
HeartRateManager.forgetDevice(address)
}

fun onResume() {
updatePermissionState()
viewModelScope.launch(Dispatchers.IO) {
Expand Down
22 changes: 15 additions & 7 deletions app/src/main/java/com/spop/poverlay/GrupettoApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,27 @@ import android.app.Application
import android.bluetooth.BluetoothManager
import android.content.Context
import com.spop.poverlay.ble.BleServer
import com.spop.poverlay.sensor.SensorSnapshotRepository
import com.spop.poverlay.sensor.interfaces.DummySensorInterface
import com.spop.poverlay.sensor.interfaces.PelotonBikePlusSensorInterface
import com.spop.poverlay.sensor.interfaces.PelotonBikeSensorInterfaceV1New
import com.spop.poverlay.sensor.interfaces.SensorInterface
import com.spop.poverlay.util.IsBikePlus
import com.spop.poverlay.util.IsRunningOnPeloton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import timber.log.Timber

class GrupettoApplication : Application() {
lateinit var bleServer: BleServer
private set
lateinit var sensorInterface: SensorInterface
private set
lateinit var sensorSnapshotRepository: SensorSnapshotRepository
private set

private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

override fun onCreate() {
super.onCreate()
Expand All @@ -22,8 +33,9 @@ class GrupettoApplication : Application() {
}

val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val sensorInterface = createSensorInterface()
bleServer = BleServer(this, bluetoothManager, sensorInterface)
sensorInterface = createSensorInterface()
sensorSnapshotRepository = SensorSnapshotRepository(sensorInterface, appScope)
bleServer = BleServer(this, bluetoothManager, sensorSnapshotRepository)
}

private fun createSensorInterface(): SensorInterface {
Expand All @@ -35,11 +47,7 @@ class GrupettoApplication : Application() {
}
} else {
// For testing on an emulator
object : SensorInterface {
override val cadence = kotlinx.coroutines.flow.MutableStateFlow(0f)
override val power = kotlinx.coroutines.flow.MutableStateFlow(0f)
override val resistance = kotlinx.coroutines.flow.MutableStateFlow(0f)
}
DummySensorInterface()
}
}
}
Loading